diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c4db85207..f5fd60ea4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -192,10 +192,25 @@ jobs: - name: Build run: xmake build -y stfc-community-mod + - name: Test confirmation setting contracts + shell: pwsh + run: ./tests/run-confirmation-settings.ps1 + - name: Report compiler cache shell: pwsh run: sccache --show-stats + - name: Test startup config saves + shell: pwsh + env: + PACKAGE_DIR: ${{ steps.xmake_cache_paths.outputs.package_dir }} + run: | + $header = Get-ChildItem -LiteralPath (Join-Path $env:PACKAGE_DIR 't/toml++') -Recurse -Filter toml.h | + Where-Object { $_.Directory.Name -eq 'toml++' } | Select-Object -First 1 + if (-not $header) { throw 'Built toml++ package not found.' } + ./tests/run-config-save.ps1 -TomlInclude $header.Directory.Parent.FullName + ./tests/run-settings.ps1 + - name: Package shell: pwsh run: | @@ -477,6 +492,34 @@ jobs: shell: bash run: sccache --show-stats + - name: Test startup config saves + shell: bash + env: + PACKAGE_DIR: ${{ steps.xmake_cache_paths.outputs.package_dir }} + run: | + set -euo pipefail + TOML_HEADER=$(find "$PACKAGE_DIR/t/toml++" -path '*/include/toml++/toml.h' -print -quit) + test -n "$TOML_HEADER" + bash tests/run-config-save.sh "$(dirname "$(dirname "$TOML_HEADER")")" + bash tests/run-settings.sh + - name: Test confirmation setting contracts + shell: bash + run: bash tests/run-confirmation-settings.sh + + - name: Test loaded Mach-O hook boundaries + shell: bash + run: | + xmake build -y macos-hook-extent-tests + xmake run macos-hook-extent-tests + + - name: Verify ARM64 debug core + if: ${{ matrix.arch == 'arm64' }} + shell: bash + run: | + xmake f -p macosx -a arm64 -m debug --target_minver=14.6 -y + xmake -y mods + xmake f -p macosx -a arm64 -m release --target_minver=14.6 -y + - name: Report Swift module cache shell: bash run: | diff --git a/docs/MOD_SETTINGS_FOUNDATION.md b/docs/MOD_SETTINGS_FOUNDATION.md new file mode 100644 index 000000000..bfaa37bee --- /dev/null +++ b/docs/MOD_SETTINGS_FOUNDATION.md @@ -0,0 +1,112 @@ +# Boolean settings foundation and native FC control + +The controller and Fleet Commander preference adapter back a Windows x64 native +confirmation-page control. Mod-owned TOML +persistence and the Community Mod category are separate work. + +`settings/boolean_settings.h` is independent of Unity and storage. Definitions have +a stable ID, readable label, read callback, and immediate-write callback. Registry +IDs are unique and registration freezes on first lookup. All operations belong to +the constructing thread; there is no polling, background work or disk I/O. + +Consumers must keep unknown state separate from a boolean. `ReadResult` carries +availability, an optional positive value, and an adapter generation. A snapshot +also carries controller identity, revision and lifecycle epoch. Passing a stale +or foreign snapshot rejects the request. `RenderScope` suppresses user-write +handling around native binding/refresh callbacks. Reentrant application is Busy. + +Writes re-read before applying, skip already-satisfied values and verify readback. +Failed or uncertain writes never trigger an automatic reverse write. The returned +snapshot contains a fresh authoritative read when available; `Unverified` must +not be rendered as successful application. `AppliedVerified` is local verification, +not a claim of cloud durability. + +## Fleet Commander adapter + +`FleetCommanderConfirmationSetting()` provides the native UI setting. ON means show confirmation. Reads use the existing +PersistentPrefsManager's `GetBool(key, false, false)`: the final false prevents +insertion of a missing preference while preserving the game's default. Writes use +the native FC setter. The adapter never instantiates managers, invokes abilities, +forces cloud saves, or enumerates other preferences. + +Metadata is resolved lazily and checked before access. Two weak handles detect +replacement of the preference manager or saved-data object without retaining +account data. Unavailability invalidates the observed generation. One process-wide +root holds the constant, non-sensitive preference key; no work runs while idle. + +The UI calls `InvalidateFleetCommanderConfirmationSession()` before the native +preference manager's RegisterEvents (initialization/reload), session-start handler, +and cloud-load entry. It immediately invalidates live view snapshots. These are +substantive functions; neither the tiny OnApplicationReload wrapper nor the +LifecycleUpdatedEventHandler save-timer path is hooked. Exact-client account +transition validation is still required; object identity alone is insufficient. + +The prototype recovery shortcut has been removed. Use the native settings row; +legacy `enable_fc_ability_confirmation` entries are no longer consumed. + +## Standalone controller tests + +Windows (clang++ with the installed C++ toolchain): + +```powershell +clang++ -std=c++23 -Wall -Wextra -Werror -I mods/src tests/boolean_settings_test.cc -o boolean_settings_test.exe +./boolean_settings_test.exe +``` + +On Unix, use the equivalent compiler invocation with `-pthread`. Keep executables +outside tracked source. These tests cover the pure state machine, not native ABI, +account lifecycle wiring, frame timings, or cloud persistence. + +Before extending the UI, measure the baseline and candidate with the same scene, +FPS cap and diagnostics: no scheduled closed-menu work or per-frame allocations; +initial target <=1 ms added normal bind/refresh work at p95, <=2 ms per normal +operation. These are proposed UI acceptance budgets, not measured P1 results. + +## Native UI adapter (P2 candidate) + +The first registered control is `[MOD] Confirm Fleet Commander abilities`, under +the existing confirmation category. ON means show confirmations; OFF means skip. +The adapter is independent of mod hotkeys and does not install a global localization +hook. It overrides TextLocalizer after native binding and clears its own overrides +on release/rebind, using weak ownership records rather than matching visible text. + +`BooleanView` retains the displayed snapshot. Rendering suppresses writes; stale +clicks conflict; an uncertain apply remains unresolved until a subsequent bind. +Rejected writes with known readback retain that value and show a retry message. +Unknown values suppress both native switch/state visual nodes while retaining the +label. The prefab must prove that those nodes are descendants of the row and do +not contain the label; otherwise that UI is unsupported. Exact visual validation +of this behavior remains a release gate. + +Eight weak view records bound bookkeeping. Native contexts own rows/delegates; +there are no strong roots retaining historical settings pages. Native release +clears records, with dead-record reclamation on binding as a fallback. A successful +write refreshes other live framework views. No polling or file work is scheduled. + +Each callback registration owns a permanent MethodInfo copy with replaced direct, +virtual and runtime-invoker pointers. Matching native schema supplies reflection +metadata only; the donor MethodInfo remains untouched. Closed delegates must point +to that owned descriptor. The native setter delegate is deliberately inert: +only a live widget's explicit change handler can submit its displayed snapshot. +Reflection and refresh callbacks cannot authorize writes. This does not claim a +general managed-method registration API. + +All seven hook bodies are preflighted for signatures, distinct addresses, exact +Windows unwind-table entries and at least 64 bytes of native extent. Hooks remain +inert until installation completes. Other platforms omit this control; support +awaits their own native extent and runtime evidence. + +Additional standalone tests: + +```powershell +clang++ -std=c++23 -Wall -Wextra -Werror -I mods/src tests/boolean_view_test.cc -o boolean_view_test.exe +./boolean_view_test.exe +clang++ -std=c++23 -Wall -Wextra -Werror -Wno-unused-parameter -I mods/src -I third_party/libil2cpp tests/native_boolean_callback_test.cc -o native_boolean_callback_test.exe +./native_boolean_callback_test.exe +``` + +These cover view failure transitions and owned native callback invocation pointers. +They do not establish delegate construction, DynamicInvoke, Unity pooling, unknown +prefab presentation, account transitions, cloud durability or frame-time budgets +on a running game. Those require the exact candidate artifact, not the earlier +play prototype's successful tests. diff --git a/docs/MOD_SETTINGS_NAVIGATION.md b/docs/MOD_SETTINGS_NAVIGATION.md new file mode 100644 index 000000000..322e6c771 --- /dev/null +++ b/docs/MOD_SETTINGS_NAVIGATION.md @@ -0,0 +1,78 @@ +# Rebuildable mod settings pages + +This foundation separates presentation placement from a setting's owner. The +intended native path is Settings > Mod Settings > group > setting. Group names +and final membership are deliberately undecided; moving a control must not rename +its stored setting or introduce another copy of its value. Confirmation controls +continue to belong on the native confirmation page. + +`PageCatalog` holds stable page IDs, labels, parent IDs and references to existing +`BooleanSetting` instances. Parents register first; invalid parents, duplicate +pages and conflicting setting owners are rejected. The same setting can appear +on different pages, with the same authoritative read/write adapter. Registration +freezes at the first build. Definitions and setting owners outlive their views. + +The catalog builds a parent-first plan once during installation; each new native +settings context receives fresh managed pages from that plan. Empty branches are omitted, +including an empty root. Building a plan neither reads nor writes settings and +retains no Unity objects. Views reuse `BooleanView` for guarded rendering, stale +request rejection and authoritative readback. A released view cannot authorize +another write. Rebuilding reads current state when each new view binds. + +The native adapter must create fresh managed contexts from this plan, avoid +duplicate roots within one context, and release any temporary roots on failure. +Pooled widgets must clear owned label/state overrides before reuse. No setting +registration may install an additional copy of an existing widget detour. + +Current build261 metadata exposes both root and parent-taking `AddCategory` +overloads on `SettingsContext`, plus parent-taking toggle/selection builders. +The Windows bridge calls that native builder and adds boolean rows through the +existing confirmation adapter. It restores owned text overrides on category +unbind/rebind and page destruction; titles use the same scoped human-text override +as existing confirmation labels. No global localization hook is installed. +Four substantive category/page lifecycle hooks are installed only when registered +pages exist. Current x64 bodies are 366, 250, 572 and 608 bytes respectively, each +larger than SPUD's 24-byte overwrite. Other platforms omit the native UI pending +their own hook evidence. Metadata/builds alone do not validate presentation or +callback lifetime; repeated navigation/pooling remains a runtime gate. + +Register through `ModPages()` before settings installation. The production catalog +is empty: no final group layout, settings placement or new preference is shipped +by this infrastructure slice. This supersedes the earlier General > Community Mod +placement proposal; native confirmation placement remains unchanged. + +The current native bridge shares the `ModConfirmationSettings` patch installation +and its debug installation switch. Disabling that patch disables both native UI +surfaces. Settings retain their own identity and persistence independently of it. +The shared native adapter currently supports eight simultaneously bound mod +boolean rows across pages. Plan populated groups within that existing limit; +catalog registration does not itself guarantee native widget capacity. + +For a temporary Windows debug navigation fixture, launch with +`STFC_MOD_SETTINGS_NAV_TEST=1`. It builds Mod Settings > Infrastructure Test > +Nested Group and mirrors the existing FC setting owner. It does not create a +second preference. A separate Infrastructure test toggle holds only an in-memory +fixture value, proving that multiple rows use different owners. Leave the FC +switch alone when checking labels, nesting and Back; it writes the real FC +preference if intentionally clicked. The synthetic toggle writes no file. The +environment option is absent from release builds and defaults off. Remove it and +restart to return to the empty production catalog. No data is cleared. +For the read-callback lifecycle check, additionally set +`STFC_MOD_SETTINGS_NAV_REENTRY_TEST=1`. The synthetic reader once releases its +own bookkeeping and rebinds the same native widget. A bounded PASS/FAIL log checks +that the in-flight slot is not reused. This probe does not run for real settings. +That option also adds a second synthetic toggle. Changing the Infrastructure test +toggle once invokes the second setter, which releases and rebinds the first row +while both requests are active. A separate nested-write PASS/FAIL log verifies +that the outer request's slot stays protected. Revisit afterward to check readback. + +Persistence stays with explicit feature adapters. A live mod change and its +asynchronous save result are distinct; page construction never calls the TOML +writer. The current writer supports its one known mode setting. This work does +not add arbitrary TOML browsing, a second save worker, automatic config hot reload, +sliders/selection abstractions without a consumer, or speculative profiler options. + +Run `tests/run-settings.ps1` on Windows or `bash tests/run-settings.sh` on macOS. +The catalog fixture covers repeated builds, empty branches, registration failures, +shared setting identity, existing BooleanView readback/unbind semantics and UI-thread +ownership. The same runners retain the original boolean/view/callback fixtures. diff --git a/docs/config-save.md b/docs/config-save.md new file mode 100644 index 000000000..19afa7417 --- /dev/null +++ b/docs/config-save.md @@ -0,0 +1,127 @@ +# Startup config saves + +`Config::Save` writes complete TOML documents for two startup callers: the initial +default config and the generated runtime snapshot. It keeps `File::MakePath` +routing and the existing generated-file warning. Save errors are logged once by +the caller; startup continues with the in-memory configuration. + +`SaveConfigDocument` serializes with toml++, parses the output before touching +disk, exclusively creates a sibling temporary file, checks writing and closing, +and replaces the destination. Startup callers remain synchronous. These +whole-document saves do not merge concurrent setting changes or preserve comments. + +Windows uses `ReplaceFileW` to preserve existing permissions and streams, with a +temporary backup for its documented partial-failure cases. A missing destination +falls back to a non-replacing move. The caller's startup existence check is not an +exclusive create transaction. Ordinary failures clean up the temporary file; partial +replacement failures retain recovery files and report their location. The backup +name is the reported temporary path plus `.bak`. Recovery is not automatic. +macOS uses rename after copying the existing permission bits. Extended metadata +and hard-link identity are not preserved by that path. Existing symlinks are +resolved before staging. Replacement requires directory permissions in addition +to any file access checks; it cannot exactly match an in-place overwrite. + +Successful close/replacement is not a guarantee against power loss. A forced exit +can leave a temporary file. No automatic stale-file sweep is installed. + +Run the isolated Windows fixtures with `tests/run-config-save.ps1` after the +normal AX build has installed toml++; `-TomlInclude` can select another include +directory. Fixtures never access the installed game's files. + +On macOS, run `bash tests/run-config-save.sh TOML_INCLUDE_DIR`. Both native macOS +CI jobs run these fixtures after the normal build, including permission-bit and +symlink checks. The failure fixture injects short writes and failed closes at +compile time; it does not install test controls in the mod. + +Native behavior references: +- [Windows ReplaceFileW](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew) +- [POSIX rename](https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html) + +## Runtime edits + +The instant-warp mode shortcut changes the active mode immediately, then asks one +worker to persist `ui.auto_confirm_instant_warp`. The same worker can serve other +explicitly registered keys. Each key retains its own acknowledged value and at +most one pending request; new submissions replace that key's pending value while +an active save finishes. Registration closes when the worker starts on its first +request. Unregistered keys are rejected. The worker does not read game objects or +call Unity. + +A submission can delay its save until a quiet interval has elapsed. Replacing a +pending request restarts that key's interval, without delaying other ready keys. +Normal quit drains accepted requests, including delayed ones; cancellation wakes +the worker and discards pending requests. There is one worker for the file, with +no per-control threads or timers. Live sliders and shortcut editing in the child +settings work use these capabilities; this branch retains instant warp as its +only registered game setting. Each feature owns its key registration, validation, +and choice of delay. + +The worker reads the current file for each attempt. `TomlEditor` caches a parsed +document only while its source bytes match. It uses toml++ source regions to +replace the selected value, preserving unrelated bytes, comments and line endings. +Missing settings are inserted only when reparsing proves the candidate means +exactly the intended document. Values are typed booleans, strings, signed 64-bit +integers or finite doubles and encoded by toml++; quotes, backslashes and newlines +cannot become new TOML instructions. Newly requested NaN/infinity values are +rejected. Existing unrelated TOML values are preserved. Feature-specific numeric +ranges remain the caller's responsibility. + +Each request compares the selected value against the last acknowledged disk value, +including whether it was absent. String quoting/escape spelling is not part of +that semantic comparison. Unrelated external +changes survive. A value already equal to the requested value succeeds without a +write; a different external value reports a conflict. Invalid TOML, unsupported +value types and I/O errors leave the live setting alone and log one message per failed +attempt, without file contents or values. No automatic retry loop is installed. +The acknowledged value advances only after success. To reconcile a conflict, +restore the original disk value, select the externally saved mode, or restart to +load the file. Runtime edits do not rewrite the startup-only generated snapshot. + +Failure state is tracked per key: saving B cannot clear a failure for A. A later +successful save of A clears A's failure. Failure to start the worker is tracked +the same way and does not prevent a later submission from trying again. Reporter +callbacks include the section/key, run on the worker, and cannot stop it by +throwing. The game adapter exposes aggregate failure status and one optional +process-lifetime observer, called on the game thread only when that status +changes. It uses the existing update dispatcher even if persistence setup failed. +The adapter conservatively retains failures from submissions it could not track. +Settings UI wording and widgets belong to the consumers, not the writer. + +The checked replacement re-reads the source after staging and rejects changed +bytes before commit. This is best-effort conflict detection, not an atomic +compare-and-swap with arbitrary external editors: an external write can still +race the final native replacement. File deletion is an I/O error, not permission +to recreate the user's file from cached content. + +Runtime persistence supports Windows x64 and macOS clients with compatible Unity quit methods. +The adapter resolves `Internal_ApplicationWantsToQuit()` and `Quit(int)` by their +complete managed signatures, without pinning client addresses or instruction bytes. +On macOS the loaded quit method must also pass native extent/prologue validation. +Incompatible bindings retain session-only changes with a save-failure notice. +The adapter is idempotent, allowing native settings and keyboard consumers to +request the same persistence lifecycle. + +An idle normal quit closes admission and passes the original vote through without +replaying quit. When work is active, normal quit stops admission, drains accepted work, then resumes the game's quit +request after observing native worker termination. Save failures do not prevent +exit. A genuine game veto is respected and is not retried automatically. If the +game vetoes after draining, persistence remains stopped for that session; +subsequent mode shortcuts still affect gameplay but are session-only. A stalled +OS write can delay normal quit. On Windows, F10 remains the escape path. With pending work, +F10 cancels queued requests and allows the active write up to 500 ms on an +independent native thread before terminating. With no pending/active write it +terminates immediately. No disk operation or wait runs in the key handler. +The existing ScreenManager.Update dispatcher supplies one idle callback; there +is no extra frame detour or per-frame logging. Hook controls have process lifetime; +hot unloading the mod is unsupported. + +The fixture runners also cover preserving edits, escaped values, conflicts, +per-key coalescing, quiet-period expiry/replacement, failed-save baselines, +draining and cancellation. They use isolated files and compile-time seams; +no test switches or injected test delays ship in the mod. +The Windows and macOS adapter fixtures execute the production lifecycle functions with +controlled worker/Unity boundaries. A disk/reload test also checks a pending 95% +galaxy threshold survives orderly shutdown. Separate Windows child processes exercise real native +force-close calls, including a stalled cancellation caller and the 500 ms wait. +Its 5-second watchdog allows scheduling overhead; this is not a hard real-time +deadline guarantee or evidence that the current game detour fired. diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index bd067d2a8..a70a8ca66 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 04b7f970c..52c3d691e 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 72b77cb88..99b09f96c 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index ca5a5850f..b25e7a49b 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 20855aed0..6024cf44e 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index cd1fb5c77..84a47f431 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 6fad76481..16855fe9f 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 59649e9f8..6f8d431f7 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index eaf83d806..e5bb73b8d 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 0ebcbb1b5..9000ae496 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/mods/src/config.cc b/mods/src/config.cc index d60e54c4d..fb949d9d1 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1,4 +1,6 @@ #include "config.h" +#include "config_save.h" +#include "patches/runtime_config.h" #include "file.h" #include "patches/mapkey.h" #include "prime/KeyCode.h" @@ -93,10 +95,10 @@ Config::Config() void Config::Save(const toml::table& config, const std::string_view filename, bool apply_warning) { - std::ofstream config_file; + std::ostringstream config_file; auto config_path = File::MakePath(filename, true); - config_file.open(config_path); + config_file.exceptions(std::ios::badbit | std::ios::failbit); if (apply_warning) { char defaultFile[255], configFile[255]; @@ -118,8 +120,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, std::filesystem::path(config_path), config_file.str()); } Config& Config::Get() @@ -1047,6 +1048,8 @@ void Config::Load() this->auto_confirm_instant_warp = get_auto_confirm_instant_warp(config, parsed, DCU::auto_confirm_instant_warp, write_config); this->installInstantWarpConfirmationHooks = true; + // Internal installation switch; UI availability is checked by the native adapter. + this->installModConfirmationSettings = true; read_instant_warp_filter(config, parsed, "instant_warp_auto_jump", this->instant_warp_auto_jump, this->instant_warp_auto_jump_all, DCU::instant_warp_auto_jump, write_config); read_instant_warp_filter(config, parsed, "instant_warp_auto_warp", this->instant_warp_auto_warp, @@ -1420,9 +1423,16 @@ void Config::Load() message << "Creating " << File::Config() << " (default config file)"; spdlog::warn(message.str()); - Config::Save(parsed, File::Config(), false); + try { + Config::Save(parsed, File::Config(), false); + config = parsed; // First runtime comparison must match the file just created. + } catch (const std::exception& error) { + spdlog::error("Could not save default config: {}", error.what()); + } } + runtime_config::Configure(config); + message.str(""); message << "Creating " << File::Vars() << " (final config file)"; spdlog::info(message.str()); @@ -1435,7 +1445,11 @@ void Config::Load() std::filesystem::remove(FILE_DEF_PARSED); } - Config::Save(parsed, File::Vars()); + try { + Config::Save(parsed, File::Vars()); + } catch (const std::exception& error) { + spdlog::error("Could not save runtime config: {}", error.what()); + } std::cout << "\n\n-----------------------------\n\n" << parsed << "\n\n-----------------------------\nVersion " diff --git a/mods/src/config.h b/mods/src/config.h index cd9d4de6d..9698467e4 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -268,6 +268,7 @@ class Config final bool installDailyFactionBulkClaimHooks; bool installInstantWarpConfirmationHooks; bool installAudioEventHooks; + bool installModConfirmationSettings; std::string config_settings_url; std::string config_assets_url_override; diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc new file mode 100644 index 000000000..f722c0ade --- /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/il2cpp/method_contract.h b/mods/src/il2cpp/method_contract.h new file mode 100644 index 000000000..d7d1ccdc5 --- /dev/null +++ b/mods/src/il2cpp/method_contract.h @@ -0,0 +1,44 @@ +#pragma once + +#include "il2cpp-functions.h" +#include +#include +#include + +namespace method_contract +{ +inline bool Type(const Il2CppType* type, const char* name) +{ + if (!type || type->byref) return false; + auto* actual = il2cpp_type_get_name(type); + const bool matches = actual && std::strcmp(actual, name) == 0; + il2cpp_free(actual); + return matches; +} + +// Resolve the entire managed signature, including static/instance dispatch. +// A renamed, ambiguous or generic method is not a compatible callback. +inline const MethodInfo* Resolve(Il2CppClass* cls, const char* name, bool is_static, + const char* result, std::initializer_list parameters) +{ + if (!cls) return nullptr; + const MethodInfo* found = nullptr; + void* iterator = nullptr; + while (auto* method = il2cpp_class_get_methods(cls, &iterator)) { + if (std::strcmp(method->name, name) != 0 || !method->methodPointer || method->is_generic || method->is_inflated + || bool(method->flags & METHOD_ATTRIBUTE_STATIC) != is_static + || method->parameters_count != parameters.size() || !Type(method->return_type, result)) continue; + bool matches = true; + unsigned i = 0; + for (auto* parameter : parameters) + matches = Type(method->parameters[i++], parameter) && matches; + if (!matches) continue; + if (found) return nullptr; + found = method; + } + return found; +} + +inline void* Pointer(const MethodInfo* method) +{ return method ? reinterpret_cast(method->methodPointer) : nullptr; } +} // namespace method_contract diff --git a/mods/src/patches/native_hook_extent.cc b/mods/src/patches/native_hook_extent.cc new file mode 100644 index 000000000..8836e0a28 --- /dev/null +++ b/mods/src/patches/native_hook_extent.cc @@ -0,0 +1,103 @@ +#include "native_hook_extent.h" + +#if __APPLE__ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// LC_FUNCTION_STARTS records ULEB128 deltas from __TEXT. Require an exact +// entry and a following entry: the last function has no proven extent here. +size_t FunctionExtent(const void* method) +{ + Dl_info info{}; + if (!method || !dladdr(method, &info) || !info.dli_fbase) + return 0; + const auto* header = static_cast(info.dli_fbase); + if (header->magic != MH_MAGIC_64) + return 0; + const auto* cursor = reinterpret_cast(header + 1); + const auto* end = cursor + header->sizeofcmds; + const segment_command_64* text = nullptr; + const segment_command_64* linkedit = nullptr; + const linkedit_data_command* starts = nullptr; + for (uint32_t i = 0; i < header->ncmds; ++i) { + if (size_t(end - cursor) < sizeof(load_command)) + return 0; + const auto* command = reinterpret_cast(cursor); + if (command->cmdsize < sizeof(load_command) || command->cmdsize > size_t(end - cursor)) + return 0; + if (command->cmd == LC_SEGMENT_64 && command->cmdsize >= sizeof(segment_command_64)) { + const auto* segment = reinterpret_cast(command); + if (std::strncmp(segment->segname, "__TEXT", 16) == 0) + text = segment; + if (std::strncmp(segment->segname, "__LINKEDIT", 16) == 0) + linkedit = segment; + } + if (command->cmd == LC_FUNCTION_STARTS && command->cmdsize >= sizeof(linkedit_data_command)) + starts = reinterpret_cast(command); + cursor += command->cmdsize; + } + if (!text || !linkedit || !starts || starts->dataoff < linkedit->fileoff) + return 0; + const uint64_t offset = starts->dataoff - linkedit->fileoff; + if (offset > linkedit->filesize || starts->datasize > linkedit->filesize - offset || offset > linkedit->vmsize + || starts->datasize > linkedit->vmsize - offset) + return 0; + const uintptr_t base = reinterpret_cast(header); + const uintptr_t target = reinterpret_cast(method); + if (target < base || target - base >= text->vmsize) + return 0; + const auto slide = base - text->vmaddr; + cursor = reinterpret_cast(slide + linkedit->vmaddr + offset); + end = cursor + starts->datasize; + uint64_t address = 0; + bool found = false; + while (cursor < end) { + uint64_t delta = 0; + unsigned shift = 0; + uint8_t byte; + do { + if (cursor == end || shift >= 64) + return 0; + byte = *cursor++; + if (shift == 63 && (byte & 0x7e)) + return 0; + delta |= uint64_t(byte & 0x7f) << shift; + shift += 7; + } while (byte & 0x80); + if (!delta || delta > text->vmsize - address) + return 0; + address += delta; + if (found) + return address - (target - base); + if (address > target - base) + return 0; + found = address == target - base; + } + return 0; +} + +} // namespace +#endif + +bool native_hooks::MacHookFits(const void* method, std::size_t minimum_extent) +{ +#if __APPLE__ + const auto extent = FunctionExtent(method); + const bool fits = extent >= std::max(minimum_extent, std::size_t{32}) + && spud::has_detour_prologue(method, extent); + spdlog::info("[MacHookExtent] entry={} bytes={} accepted={}", method, extent, fits); + return fits; +#else + (void)method; + (void)minimum_extent; + return false; +#endif +} diff --git a/mods/src/patches/native_hook_extent.h b/mods/src/patches/native_hook_extent.h new file mode 100644 index 000000000..5acdea051 --- /dev/null +++ b/mods/src/patches/native_hook_extent.h @@ -0,0 +1,11 @@ +#pragma once +#include + +namespace native_hooks +{ +// Validates the entry in the loaded image, before SPUD changes any bytes. +// Unsupported images/architectures fail closed. +// Small, exact-entry callbacks may request a lower floor; the host decoder +// still requires the complete SPUD overwrite window without an early exit. +bool MacHookFits(const void* method, std::size_t minimum_extent = 64); +} // namespace native_hooks 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/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index 124f53eba..337704693 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -1,4 +1,5 @@ #include "config.h" +#include "patches/runtime_config.h" #include @@ -339,6 +340,7 @@ void CycleAutoConfirmInstantWarp(Config& config) } spdlog::info("Auto-confirm instant warp set to {}", state); + runtime_config::SaveWarpMode(state); } bool MoveOfficerCanvas(bool goLeft) @@ -497,7 +499,8 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) #ifdef _WIN32 if (MapKey::IsDown(GameFunction::Quit)) { - TerminateProcess(GetCurrentProcess(), 1); + runtime_config::ForceClose(); + return; } #elif defined(__APPLE__) if (MapKey::IsDown(GameFunction::Quit)) { @@ -587,6 +590,7 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) if (!is_in_chat) { if (!Key::IsInputFocused()) { + if (MapKey::IsDown(GameFunction::SelectCurrent)) { auto fleet_bar = ObjectFinder::Get(); if (fleet_bar) { @@ -1497,6 +1501,7 @@ void InstallHotkeyHooks() InstallShortcutHintHooks(); install_screen_manager_update_hook(); + runtime_config::Install(); #ifdef _MODDBG fleet_watch::InstallRuntimeProbe(); #endif diff --git a/mods/src/patches/parts/mod_confirmation_settings.cc b/mods/src/patches/parts/mod_confirmation_settings.cc new file mode 100644 index 000000000..74addcac0 --- /dev/null +++ b/mods/src/patches/parts/mod_confirmation_settings.cc @@ -0,0 +1,27 @@ +#include "patches/runtime_config.h" +#include "settings/native/page_navigation.h" +#include "settings/native/value_widgets.h" +#include + +void InstallModConfirmationSettings() +{ + // Check loaded-image extents before installing native UI hooks. +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + using namespace mod_settings::native; + try { + // Settings persistence must also work when keyboard hooks are disabled. + runtime_config::Install(); + if (!InstallCoreValueWidgets()) + return; + try { + InstallPages(); + } catch (...) { + DisablePages(); + spdlog::warn("[ModSettings] Navigation unavailable; native confirmation control remains available"); + } + spdlog::info("[ModSettings] Native settings adapter installed"); + } catch (...) { + Warn(); + } +#endif +} diff --git a/mods/src/patches/parts/runtime_config.cc b/mods/src/patches/parts/runtime_config.cc new file mode 100644 index 000000000..714b95e60 --- /dev/null +++ b/mods/src/patches/parts/runtime_config.cc @@ -0,0 +1,294 @@ +#ifdef CONFIG_RUNTIME_TEST +#include CONFIG_RUNTIME_TEST // Isolated fixture substitutes Unity/worker boundaries only. +#else +#include "patches/runtime_config.h" +#include "file.h" +#include "runtime_config_writer.h" +#include + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "patches/screen_update_hook.h" +#include "patches/native_hook_extent.h" +#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; + +// 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()); + if (failed != reported_save_failure) { + reported_save_failure = failed; + 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 +} +#ifndef CONFIG_RUNTIME_TEST +void Configure(const toml::table& loaded) +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + if (writer) + return; + std::optional initial; + if (auto value = loaded["ui"]["auto_confirm_instant_warp"].value()) + initial = *value; + try { + writer = new config_edit::RuntimeConfigWriter(File::MakePath(File::Config()), initial, 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) + 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(method_contract::Pointer(wants))) { + spdlog::warn("Runtime config persistence unavailable: Mac quit hook validation failed"); + 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 (available && !forcing && owner == CurrentThreadToken() && !quit_depth) { + std::lock_guard lock(lifecycle); + if (!draining) { + if (writer->Submit(section, key, std::move(value), delay)) + 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. + } + } + } + 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 + } +} + +void SaveWarpMode(const char* mode) noexcept +{ + try { + const std::string value(mode); + if (value != "none" && value != "warp" && value != "jump") + return; + SaveSetting("ui", "auto_confirm_instant_warp", value, {}); + } catch (...) { // Keep value construction inside the shortcut's failure boundary. +#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/patches.cc b/mods/src/patches/patches.cc index 9579b9692..c267af937 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -48,6 +48,7 @@ void InstallDoubleClickAssignShipHooks(); void InstallInstantWarpConfirmationHooks(); void InstallForbiddenTechConfirmationHooks(); void InstallAudioEventHooks(); +void InstallModConfirmationSettings(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -76,6 +77,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) spdlog::set_level(log_level); spdlog::flush_on(log_level); + spud::set_detour_diagnostic_handler([](const char* message) { spdlog::error("[Spud] {}", message); }); #if VERSION_PATCH if constexpr (sizeof(VERSION_COMMIT_HASH) > 1) { @@ -151,6 +153,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"InstantWarpConfirm", {InstallInstantWarpConfirmationHooks, &cfg.installInstantWarpConfirmationHooks}}, {"ForbiddenTechConfirm", {InstallForbiddenTechConfirmationHooks, &cfg.auto_confirm_ft_upgrade}}, {"AudioEvents", {InstallAudioEventHooks, &cfg.installAudioEventHooks}}, + {"ModConfirmationSettings", {InstallModConfirmationSettings, &cfg.installModConfirmationSettings}}, }; 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..8af45a8da --- /dev/null +++ b/mods/src/patches/runtime_config.h @@ -0,0 +1,21 @@ +#pragma once +#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 aggregate failure status changes, never by the worker. +bool SetSaveStatusObserver(void (*observer)()); +bool HasSaveFailures() noexcept; +void SaveWarpMode(const char* mode) 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..2b6a4bdbd --- /dev/null +++ b/mods/src/runtime_config_writer.cc @@ -0,0 +1,192 @@ +#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, std::optional initial, Reporter report) + : path_(std::move(path)) + , report_(report) +{ saved_.emplace(Key{"ui", "auto_confirm_instant_warp"}, Saved{std::move(initial)}); } + +RuntimeConfigWriter::~RuntimeConfigWriter() +{ + Stop(false); + if (worker_.joinable()) + worker_.join(); +} + +std::uint64_t RuntimeConfigWriter::Submit(std::string mode) +{ + if (mode != "none" && mode != "warp" && mode != "jump") + return 0; + return Submit("ui", "auto_confirm_instant_warp", std::move(mode)); +} + +bool RuntimeConfigWriter::Register(std::string section, std::string key, std::optional initial) +{ + std::lock_guard lock(mutex_); + if (worker_.joinable() || stopping_ || section.empty() || key.empty()) + return false; + return saved_.emplace(Key{std::move(section), std::move(key)}, Saved{std::move(initial)}).second; +} + +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}); + 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; + 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}; + 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; +} + +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}; + 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. + saved_.at(Key{work.edit.section, work.edit.key}).failed = + outcome != Outcome::Saved && outcome != Outcome::AlreadySaved; + 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..09b2fb0c3 --- /dev/null +++ b/mods/src/runtime_config_writer.h @@ -0,0 +1,74 @@ +#pragma once + +#include "toml_editor.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace config_edit +{ +// One owner for the configured file and all its registered runtime settings. +// Register additional keys here rather than creating another writer for the file. +class RuntimeConfigWriter +{ +public: + using Reporter = void (*)(std::string_view, std::string_view, Outcome); + struct Completion { + std::uint64_t revision = 0; + Outcome outcome = Outcome::AlreadySaved; + }; + RuntimeConfigWriter(std::filesystem::path path, std::optional initial, Reporter report = nullptr); + ~RuntimeConfigWriter(); // Tests/explicit owners only; game adapter has process lifetime. + std::uint64_t Submit(std::string mode); + // Startup registration only. Runtime submissions may update only known keys. + 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(); + 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; + }; + 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}; +}; +} // 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..a058e5091 --- /dev/null +++ b/mods/src/settings/action_setting.h @@ -0,0 +1,46 @@ +#pragma once +#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; + 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..781e5dfd4 --- /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 > 8 || 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/mod_pages.cc b/mods/src/settings/mod_pages.cc new file mode 100644 index 000000000..c9c4082f3 --- /dev/null +++ b/mods/src/settings/mod_pages.cc @@ -0,0 +1,11 @@ +#include "mod_pages.h" + +namespace mod_settings +{ +PageCatalog& ModPages() +{ + static PageCatalog catalog("community_mod.settings", "Mod Settings"); + return catalog; +} +void RegisterModPages() {} +} // 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..6ec5d2a04 --- /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..7a33acac7 --- /dev/null +++ b/mods/src/settings/native/action_widgets.cc @@ -0,0 +1,398 @@ +#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 "timing.h" +#include +#include +#include + +namespace mod_settings::native +{ +namespace +{ + NativeCallback actionCallback; + NativeCallback actionGetter; + bool actionsActive = false; +} // namespace +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(); + } + 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 = 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; + timing::Scope measurement(timing::Operation::RefreshActions); + RefreshPageRows(); + RefreshPageSummaries(); + // 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()) + 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) + || !action.getContext || !Reference(action.getContext->return_type) + || !Instance(action.getContext, 0, action.getContext->return_type->type) + || !Instance(clickSchema, 0, IL2CPP_TYPE_VOID) || !Instance(getSchema, 0, IL2CPP_TYPE_STRING) + || !actionCallback.Initialize(clickSchema, InvokeAction) + || !actionGetter.Initialize(getSchema, EmptyHeadingValue)) + throw std::runtime_error("settings command schema"); + // build261 x64 GameAssembly 487af4bb: SetWidgetData CFD5F0..CFD8A5 (693) + // and OnAboutToReleaseContext CFD430..CFD53F (271), vs SPUD's 24 bytes. + // Metadata resolves current addresses; unwind checks still gate each load. + for (auto* target : {action.refresh, action.release}) { + if (!Instance(target, 0, IL2CPP_TYPE_VOID) || !Extent(target) + || action.refresh->methodPointer == action.release->methodPointer) + throw std::runtime_error("settings command hook extent"); + 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("settings command hook overlap"); + if (SelectionActive()) + for (auto* existing : {SelectionMeta().refresh, SelectionMeta().changed, SelectionMeta().release}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error("settings command selection overlap"); + if (SliderActive()) + for (auto* existing : + {SliderMeta().refresh, SliderMeta().changed, SliderMeta().release, SliderMeta().valueLabel}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error("settings command slider overlap"); + if (HeadingsActive()) + for (auto* existing : {HeadingMeta().refresh, HeadingMeta().clear}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error("settings command heading overlap"); + } + if (!SPUD_STATIC_DETOUR(action.refresh->methodPointer, ActionRefreshHook) + || !SPUD_STATIC_DETOUR(action.release->methodPointer, ActionReleaseHook)) + throw std::runtime_error("settings command hook installation"); + actionsActive = true; + } catch (const std::exception& error) { + spdlog::warn("[ModSettings] Commands unavailable: {}", error.what()); + } + } +} + +} // 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..76ee9dc78 --- /dev/null +++ b/mods/src/settings/native/action_widgets.h @@ -0,0 +1,19 @@ +#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(); +void RefreshActions(); +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..3909ba06d --- /dev/null +++ b/mods/src/settings/native/interop.cc @@ -0,0 +1,159 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "interop.h" +#include "settings/page_catalog.h" +#include "settings/windows_hook_extent.h" +#include "patches/native_hook_extent.h" +#include +#include + +namespace mod_settings::native +{ +namespace +{ + bool warned = false; +} +void Warn(const char* reason) +{ + if (!warned) { + warned = true; + spdlog::warn("[ModSettings] {}", reason); + } +} +bool Type(const Il2CppType* type, int expected) +{ return type && !type->byref && type->type == expected; } +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; +} +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); +} +FieldInfo* Field(Il2CppClass* cls, const char* name) +{ + auto* field = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; + if (!field || !Reference(field->type) || (field->type->attrs & FIELD_ATTRIBUTE_STATIC)) + throw std::runtime_error("settings reference field"); + 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("settings invocation"); + Il2CppException* error = nullptr; + auto* result = il2cpp_runtime_invoke(method, object, args, &error); + if (error) + throw std::runtime_error("settings managed exception"); + 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) +{ return Invoke(object ? il2cpp_class_get_method_from_name(object->klass, name, count) : nullptr, object, args); } +bool Boolean(Il2CppObject* boxed) +{ + if (!boxed || !Type(il2cpp_class_get_type(boxed->klass), IL2CPP_TYPE_BOOLEAN)) + throw std::runtime_error("settings boolean result"); + return *static_cast(il2cpp_object_unbox(boxed)); +} +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); +} +bool Extent(const MethodInfo* method) +{ +#if __APPLE__ + const bool fits = method && native_hooks::MacHookFits(reinterpret_cast(method->methodPointer)); + if (!fits) + spdlog::warn("[ModSettings] Mac hook rejected: {}", method ? method->name : "missing method"); + return fits; +#else + return method && WindowsHookFits(method->methodPointer); +#endif +} + +} // 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..c2aeec11f --- /dev/null +++ b/mods/src/settings/native/interop.h @@ -0,0 +1,50 @@ +#pragma once + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#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* reason = "native control unavailable"); +bool Type(const Il2CppType* type, int expected); +bool Instance(const MethodInfo* method, int count, int result); +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); +bool Boolean(Il2CppObject* boxed); +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); +bool Extent(const MethodInfo* method); +} // 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..5bab5e744 --- /dev/null +++ b/mods/src/settings/native/page_navigation.cc @@ -0,0 +1,681 @@ +#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/mod_pages.h" +#include "settings/page_sections.h" +#include "settings/native_boolean_callback.h" +#include "timing.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; + PageSections sections; + std::vector shown; // Comparison only; native context/panel owns rows. + bool conditional = false; +} sectionPage; +using SectionRefreshScope = PageSections::RefreshScope; +void ClearSectionPage() +{ + timing::Flush(); + const auto* leaving = std::exchange(sectionPage.page, nullptr); + Free(sectionPage.controller); + Free(sectionPage.context); + 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) +{ + timing::Scope measurement(timing::Operation::ShowPage); + 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 (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(); + } +} +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) +{ + 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(error.what()); + if (sectionClick) + return; + } catch (...) { + Warn("settings section unavailable"); + if (sectionClick) + return; + } + 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 (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(); + } +} +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 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; + timing::Scope measurement(timing::Operation::BuildTree); + 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 InstallPages() +{ + RegisterModPages(); +#ifdef _MODDBG + // Temporary opt-in navigation fixture; no real mod feature placement is chosen. + // It mirrors the existing FC owner so rebuilds never introduce a second value. + if (const auto* probe = std::getenv("STFC_MOD_SETTINGS_NAV_TEST"); probe && std::strcmp(probe, "1") == 0) { + auto& catalog = ModPages(); + catalog.AddPage("community_mod.test", "Infrastructure Test", "community_mod.settings"); + catalog.AddPage("community_mod.test.nested", "Nested Group", "community_mod.test"); + catalog.AddBoolean("community_mod.test.nested", FleetCommanderConfirmationSetting()); + static bool value = false; + static BooleanSetting fixture({"community_mod.test.enabled", "[MOD] Infrastructure test toggle", + [] { + ExerciseReadReentry(); + return ReadResult::Known(value, 1); + }, + [](bool desired, std::uint64_t generation) { + if (generation != 1) + return ApplyResult::Rejected; + ExerciseNestedWrite(); + value = desired; + return ApplyResult::Applied; + }}); + catalog.AddBoolean("community_mod.test.nested", fixture); + if (ReentryProbeEnabled()) { + static bool nestedValue = false; + static BooleanSetting nestedFixture({"community_mod.test.nested_write", "[MOD] Nested write test toggle", + [] { return ReadResult::Known(nestedValue, 1); }, + [](bool desired, std::uint64_t generation) { + if (generation != 1) + return ApplyResult::Rejected; + RebindOuterWrite(); + nestedValue = desired; + return ApplyResult::Applied; + }}); + catalog.AddBoolean("community_mod.test.nested", nestedFixture); + } + } +#endif + 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{ + "Active this session; couldn't save. See mod log.", "", + "", 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}; + for (std::size_t i = 0; i < hooks.size(); ++i) { + if (!Instance(hooks[i], i == 2 ? 1 : 0, IL2CPP_TYPE_VOID) || !Extent(hooks[i])) + throw std::runtime_error("settings page hook metadata/extent"); + for (std::size_t j = 0; j < i; ++j) + if (hooks[i]->methodPointer == hooks[j]->methodPointer) + throw std::runtime_error("settings page shared hook"); + 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("settings page overlaps existing hook"); + } + 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]) || !Reference(m.selected->parameters[0])) + throw std::runtime_error("settings category signature"); + 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) || !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]) || !heading.getContext + || !Reference(heading.getContext->return_type) + || !Instance(heading.getContext, 0, heading.getContext->return_type->type) + || !headingGetter.Initialize(get, EmptyHeadingValue)) + throw std::runtime_error("heading callback schema"); + const std::array targets{heading.refresh, heading.clear}; + for (auto* target : targets) { + if (!Instance(target, 0, IL2CPP_TYPE_VOID) || !Extent(target) + || targets[0]->methodPointer == targets[1]->methodPointer) + throw std::runtime_error("heading hook metadata/extent"); + 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("heading hook overlap"); + if (SelectionActive()) + for (auto* existing : {SelectionMeta().refresh, SelectionMeta().changed, SelectionMeta().release}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error("heading selection overlap"); + if (SliderActive()) + for (auto* existing : + {SliderMeta().refresh, SliderMeta().changed, SliderMeta().release, SliderMeta().valueLabel}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error("heading slider overlap"); + } + if (!SPUD_STATIC_DETOUR(heading.refresh->methodPointer, HeadingRefreshHook) + || !SPUD_STATIC_DETOUR(heading.clear->methodPointer, HeadingClearHook)) + throw std::runtime_error("heading hook installation"); + headingsActive = true; + } + InstallActionWidgets(); + if (ActionsActive() && !runtime_config::SetSaveStatusObserver(RefreshActions)) + 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"); + } + if (!SPUD_STATIC_DETOUR(m.bind->methodPointer, CategoryBindHook) + || !SPUD_STATIC_DETOUR(m.release->methodPointer, CategoryReleaseHook) + || !SPUD_STATIC_DETOUR(m.selected->methodPointer, PageSelectedHook) + || !SPUD_STATIC_DETOUR(m.destroyed->methodPointer, PageDestroyedHook)) + throw std::runtime_error("settings page hook installation"); + 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..118abb78d --- /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(); + } + try { + if (auto* label = Target(it->label)) + Call(label, "ClearTextOverride"); + } catch (...) { + Warn(); + } + 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(); + } + } + 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/timing.h b/mods/src/settings/native/timing.h new file mode 100644 index 000000000..b1f85f773 --- /dev/null +++ b/mods/src/settings/native/timing.h @@ -0,0 +1,76 @@ +#pragma once + +// Opt-in measurement at existing settings boundaries. No new hook or frame +// callback, and no logging while interacting: aggregate when leaving a page. +#include +#include +#include +#include +#include +#include + +namespace mod_settings::native::timing +{ +enum class Operation { BuildTree, ShowPage, RefreshActions, Count }; +#ifdef _MODDBG +inline bool Enabled() +{ + static const bool enabled = [] { + const auto* value = std::getenv("STFC_MOD_SETTINGS_TIMING"); + return value && std::string_view(value) == "1"; + }(); + return enabled; +} +struct Measurement { + std::size_t count = 0; + double total = 0, maximum = 0; +}; +inline std::array(Operation::Count)> measurements; +class Scope +{ +public: + explicit Scope(Operation operation) + : operation_(operation) + { + if (Enabled()) + start_ = std::chrono::steady_clock::now(); + } + ~Scope() + { + if (!Enabled()) + return; + const auto ms = std::chrono::duration(std::chrono::steady_clock::now() - start_).count(); + auto& result = measurements[static_cast(operation_)]; + ++result.count; + result.total += ms; + result.maximum = std::max(result.maximum, ms); + } + +private: + Operation operation_; + std::chrono::steady_clock::time_point start_; +}; +inline void Flush() noexcept +{ + if (!Enabled()) + return; + constexpr std::array names{"build-tree", "show-page", "refresh-actions"}; + for (std::size_t i = 0; i < measurements.size(); ++i) { + auto& result = measurements[i]; + if (!result.count) + continue; + try { + spdlog::info("[SettingsTiming] {} count={} mean_ms={:.3f} max_ms={:.3f}", names[i], result.count, + result.total / result.count, result.maximum); + } catch (...) { + } + result = {}; + } +} +#else +struct Scope { + explicit Scope(Operation) {} +}; +inline void Flush() noexcept {} +#endif +} // namespace mod_settings::native::timing 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..8ecc8109c --- /dev/null +++ b/mods/src/settings/native/value_widgets.cc @@ -0,0 +1,1002 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "page_navigation.h" +#include "patches/parts/fc_confirmation_reset.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; + 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(); + } + // 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 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)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()); +} +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()); + // 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 = 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] = 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(); +} +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(); + } + try { + AddPages(director, context); + } catch (...) { + Warn(); + } +} +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(error.what()); + if (owned) { + HideUnsupported(widget); + return; + } + } catch (...) { + if (owned) { + HideUnsupported(widget); + return; + } + Warn(); + } + 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. + return; + } + } catch (...) { + if (owned) { + HideUnsupported(widget); + return; + } + Warn(); + } + 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(); + } + } + original(widget); +} +void Invalidate() +{ + ClearSectionPage(); + InvalidateFleetCommanderConfirmationSession(); + 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(); + } + } + 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); } +#ifdef _MODDBG +ValueWidget* writeProbeOuter = nullptr; +bool ReentryProbeEnabled() +{ + const auto* enabled = std::getenv("STFC_MOD_SETTINGS_NAV_REENTRY_TEST"); + return enabled && std::strcmp(enabled, "1") == 0; +} +void ExerciseReadReentry() +{ + static bool exercised = false; + if (exercised || !bindingView || !ReentryProbeEnabled()) + return; + exercised = true; + auto* previous = bindingView; + Root widget(Target(previous->widget)); + // Exercise the actual release bookkeeping and refresh path inside a reader. + // Keep the native context bound so this is independent of game navigation. + Clear(*previous); + Invoke(WidgetMeta(widget.get()).refresh, widget.get()); + auto* rebound = FindValueWidget(widget.get()); + spdlog::info("[ModSettings] Read reentry fixture: {}", + rebound && rebound != previous && previous->binding ? "PASS" : "FAIL"); +} +void ExerciseNestedWrite() +{ + static bool exercised = false; + if (exercised || !ReentryProbeEnabled()) + return; + ValueWidget* outer = nullptr; + ValueWidget* nested = nullptr; + for (auto& view : ValueViews()) { + if (!Target(view.widget) || !view.state) + continue; + if (view.requesting && view.state->id() == "community_mod.test.enabled") + outer = &view; + if (view.state->id() == "community_mod.test.nested_write") + nested = &view; + } + if (!outer || !nested) + return; + exercised = true; + struct Scope { + explicit Scope(ValueWidget* outer) + { writeProbeOuter = outer; } + ~Scope() + { writeProbeOuter = nullptr; } + } scope(outer); + Root widget(Target(nested->widget)); + bool desired = !nested->state->value().value_or(false); + void* args[] = {&desired}; + Invoke(ToggleMeta().changed, widget.get(), args); +} +void RebindOuterWrite() +{ + if (!writeProbeOuter) + return; + Root widget(Target(writeProbeOuter->widget)); + Clear(*writeProbeOuter); + Invoke(WidgetMeta(widget.get()).refresh, widget.get()); + auto* rebound = FindValueWidget(widget.get()); + spdlog::info("[ModSettings] Nested write reentry fixture: {}", + rebound && rebound != writeProbeOuter && writeProbeOuter->requesting ? "PASS" : "FAIL"); +} +#endif + +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) || !Instance(set, 1, IL2CPP_TYPE_VOID) + || !Type(set->parameters[0], IL2CPP_TYPE_I4) || !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) + || !Instance(selection.querySetter, 1, IL2CPP_TYPE_VOID) || !Reference(selection.querySetter->parameters[0]) + || !selection.getContext || !Reference(selection.getContext->return_type) + || !Instance(selection.getContext, 0, selection.getContext->return_type->type) + || !selectionGetter.Initialize(get, GetSelected) || !selectionSetter.Initialize(set, SetSelected)) + throw std::runtime_error("selection 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("selection transition signature"); + const std::array targets{selection.refresh, selection.changed, selection.release, transition}; + for (std::size_t i = 0; i < targets.size(); ++i) { + if (!Instance(targets[i], i == 3 ? 2 : i == 1 ? 1 : 0, IL2CPP_TYPE_VOID) || !Extent(targets[i])) + throw std::runtime_error("selection hook metadata/extent"); + if (i == 1 && !Type(targets[i]->parameters[0], IL2CPP_TYPE_BOOLEAN)) + throw std::runtime_error("selection changed signature"); + for (std::size_t j = 0; j < i; ++j) + if (targets[i]->methodPointer == targets[j]->methodPointer) + throw std::runtime_error("selection shared hook"); + 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("selection hook overlap"); + } + for (const auto& page : Pages()) + for (auto* choice : page.Controls()) + if (!choice->state().SetChangeObserver(RefreshViews)) + throw std::runtime_error("selection observer ownership"); + if (!SPUD_STATIC_DETOUR(selection.refresh->methodPointer, SelectionRefreshHook) + || !SPUD_STATIC_DETOUR(selection.changed->methodPointer, SelectionChangedHook) + || !SPUD_STATIC_DETOUR(selection.release->methodPointer, ReleaseHook) + || !SPUD_STATIC_DETOUR(transition->methodPointer, SelectionTransitionHook)) + throw std::runtime_error("selection hook installation"); + 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) || !Instance(set, 1, IL2CPP_TYPE_VOID) + || !Type(set->parameters[0], IL2CPP_TYPE_R4) || !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) + || !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) + || !slider.getContext || !Reference(slider.getContext->return_type) + || !Instance(slider.getContext, 0, slider.getContext->return_type->type) + || !sliderGetter.Initialize(get, GetNumber) || !sliderSetter.Initialize(set, SetNumber)) + throw std::runtime_error("slider callback schema"); + const std::array targets{slider.refresh, slider.changed, slider.release, slider.valueLabel}; + 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) || !Extent(targets[i])) + throw std::runtime_error("slider hook metadata/extent"); + if (takesValue && !Type(targets[i]->parameters[0], IL2CPP_TYPE_R4)) + throw std::runtime_error("slider changed signature"); + for (std::size_t j = 0; j < i; ++j) + if (targets[i]->methodPointer == targets[j]->methodPointer) + throw std::runtime_error("slider shared hook"); + 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("slider hook overlap"); + if (selectionActive) + for (auto* existing : {SelectionMeta().refresh, SelectionMeta().changed, SelectionMeta().release}) + if (targets[i]->methodPointer == existing->methodPointer) + throw std::runtime_error("slider selection overlap"); + } + for (const auto& page : Pages()) + for (auto* setting : page.Controls()) + if (!setting->state().SetChangeObserver(RefreshViews)) + throw std::runtime_error("slider observer ownership"); + if (!SPUD_STATIC_DETOUR(slider.refresh->methodPointer, SliderRefreshHook) + || !SPUD_STATIC_DETOUR(slider.changed->methodPointer, SliderChangedHook) + || !SPUD_STATIC_DETOUR(slider.release->methodPointer, ReleaseHook) + || !SPUD_STATIC_DETOUR(slider.valueLabel->methodPointer, SliderValueLabelHook)) + throw std::runtime_error("slider hook installation"); + 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}; + for (std::size_t i = 0; i < hooks.size(); ++i) { + if (!Instance(hooks[i], i == 0 || i == 2 ? 1 : 0, IL2CPP_TYPE_VOID) || !Extent(hooks[i])) + throw std::runtime_error("settings hook metadata/extent"); + for (std::size_t j = 0; j < i; ++j) + if (hooks[i]->methodPointer == hooks[j]->methodPointer) + throw std::runtime_error("settings shared hook"); + } + 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) || !Instance(setSchema, 1, IL2CPP_TYPE_VOID) + || !Type(setSchema->parameters[0], IL2CPP_TYPE_BOOLEAN) || !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) || !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]) + || !Type(m.changed->parameters[0], IL2CPP_TYPE_BOOLEAN) || !Reference(m.addGeneral->parameters[0]) + || !m.getContext || !Reference(m.getContext->return_type) || !Instance(m.querySetter, 1, IL2CPP_TYPE_VOID) + || !Reference(m.querySetter->parameters[0]) || !getter.Initialize(getSchema, GetEnabled) + || !setter.Initialize(setSchema, SetEnabled) || !query.Initialize(querySchema, QueryState)) + throw std::runtime_error("settings callback schema"); + uiThread = std::this_thread::get_id(); + if (!FleetCommanderConfirmationSetting().SetChangeObserver(RefreshViews)) + throw std::runtime_error("settings observer ownership"); + // 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. + if (!SPUD_STATIC_DETOUR(m.refresh->methodPointer, RefreshHook) + || !SPUD_STATIC_DETOUR(m.changed->methodPointer, ChangedHook) + || !SPUD_STATIC_DETOUR(m.release->methodPointer, ReleaseHook) + || !SPUD_STATIC_DETOUR(m.reload->methodPointer, ReloadHook) + || !SPUD_STATIC_DETOUR(m.session->methodPointer, SessionHook) + || !SPUD_STATIC_DETOUR(m.load->methodPointer, LoadHook) + || !SPUD_STATIC_DETOUR(m.addGeneral->methodPointer, AddGeneralHook)) + throw std::runtime_error("settings hook installation"); + active = true; + return true; + } catch (...) { + Warn(); + } + 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..08ecc755a --- /dev/null +++ b/mods/src/settings/native/value_widgets.h @@ -0,0 +1,74 @@ +#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. +#ifdef _MODDBG +bool ReentryProbeEnabled(); +void ExerciseReadReentry(); +void ExerciseNestedWrite(); +void RebindOuterWrite(); +#endif +} // 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..3c63a703c --- /dev/null +++ b/mods/src/settings/native_view_state.h @@ -0,0 +1,139 @@ +#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 "Out of range; edit TOML"; + 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(); + } + 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..cc4929d34 --- /dev/null +++ b/mods/src/settings/page_catalog.h @@ -0,0 +1,257 @@ +#pragma once + +#include "action_setting.h" +#include "boolean_settings.h" +#include "choice_setting.h" +#include "slider_setting.h" +#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. + // Page departure owns draft/capture cancellation, never a pooled row release. + std::function leave; + std::function summary; + bool HasConditionalSections() const + { + 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 + { + 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; + + 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) + { return AddControl(page, setting); } + 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 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) + { + 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); + 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/slider_setting.h b/mods/src/settings/slider_setting.h new file mode 100644 index 000000000..59242c044 --- /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/value_settings.h b/mods/src/settings/value_settings.h new file mode 100644 index 000000000..b4b9f89cd --- /dev/null +++ b/mods/src/settings/value_settings.h @@ -0,0 +1,215 @@ +#pragma once + +#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; +}; +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; } + + // 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_; + } + + 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 (observed.owner != identity_ || !observed.state.known() || observed.epoch != before.epoch + || observed.revision != before.revision || observed.state.generation != before.state.generation + || observed.state.value != before.state.value) + return {Outcome::Conflict, before}; + if (*before.state.value == 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) { + 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}; + } + +private: + 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; + void (*change_observer_)() = nullptr; +}; + +} // 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..c9148e0fc --- /dev/null +++ b/mods/src/settings/value_view.h @@ -0,0 +1,69 @@ +#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; + } + 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: + ValueSetting& setting_; + Snapshot snapshot_; + bool bound_ = false; + bool unresolved_ = true; + bool failed_ = false; +}; +} // namespace mod_settings diff --git a/mods/src/settings/windows_hook_extent.h b/mods/src/settings/windows_hook_extent.h new file mode 100644 index 000000000..bad94854b --- /dev/null +++ b/mods/src/settings/windows_hook_extent.h @@ -0,0 +1,16 @@ +#pragma once +#if defined(_WIN32) && defined(_M_X64) +#include +namespace mod_settings +{ +// x64 SPUD reserves 24 bytes. Reject tiny thunks and interior entry points; +// exact-client evidence is still required before enabling a new hook target. +inline bool WindowsHookFits(const void* method) +{ + DWORD64 base = 0; + const auto address = reinterpret_cast(method); + const auto* entry = method ? RtlLookupFunctionEntry(address, &base, nullptr) : nullptr; + return entry && base + entry->BeginAddress == address && entry->EndAddress - entry->BeginAddress >= 64; +} +} // namespace mod_settings +#endif diff --git a/mods/src/toml_editor.cc b/mods/src/toml_editor.cc new file mode 100644 index 000000000..b142d773a --- /dev/null +++ b/mods/src/toml_editor.cc @@ -0,0 +1,179 @@ +#include "toml_editor.h" +#include "config_save.h" + +#include +#include +#include + +namespace config_edit +{ +namespace +{ + std::string Encode(const Value& value) + { + return std::visit( + [](const auto& item) { + const toml::value node(item); + std::ostringstream output; + output.exceptions(std::ios::badbit | std::ios::failbit); + output << toml::toml_formatter(node, toml::format_flags::none); + return output.str(); + }, + 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"); + } + + std::size_t BomSize(std::string_view text) + { return text.starts_with("\xef\xbb\xbf") ? 3 : 0; } + + // toml++ columns count Unicode codepoints, including CR; only LF starts a line. + std::size_t Offset(std::string_view text, toml::source_position target) + { + toml::source_position current{1, 1}; + for (auto i = BomSize(text);;) { + if (current == target) + return i; + if (i >= text.size()) + throw std::invalid_argument("invalid source region"); + const auto byte = static_cast(text[i]); + if (byte == '\n') { + ++current.line; + current.column = 1; + } else + ++current.column; + ++i; + // Input has already passed TOML/UTF-8 validation. + while (i < text.size() && (static_cast(text[i]) & 0xc0) == 0x80) + ++i; + } + } +} // 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 = toml::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* node = parent ? parent->get(request.key) : nullptr; + const auto current = ReadValue(node); + if (current && *current == request.desired) + return {Outcome::AlreadySaved, {}}; + if (current != request.expected) + return {Outcome::Conflict, {}}; + + auto desired_document = document; + if (!parent) + desired_document.insert(request.section, toml::table{}); + std::visit( + [&](const auto& value) { + desired_document.get_as(request.section)->insert_or_assign(request.key, value); + }, + request.desired); + + // Accept a candidate only if a fresh parse has exactly the intended meaning. + auto accepts = [&](const std::string& candidate) { + try { + return toml::parse(candidate) == desired_document; + } catch (const toml::parse_error&) { + return false; + } + }; + const auto encoded = Encode(request.desired); + if (node) { + const auto begin = Offset(text, node->source().begin); + const auto end = Offset(text, node->source().end); + if (end < begin || end > text.size()) + return {Outcome::Unsupported, {}}; + auto result = text; + result.replace(begin, end - begin, encoded); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + return {Outcome::Unsupported, {}}; + } + + const auto newline = text.find("\r\n") != std::string::npos ? "\r\n" : "\n"; + const auto assignment = Encode(Value{request.key}) + " = " + encoded; + if (parent && parent->is_inline()) { + const auto end = Offset(text, parent->source().end); + if (!end || text[end - 1] != '}') + return {Outcome::Unsupported, {}}; + auto result = text; + result.insert(end - 1, (parent->empty() ? " " : ", ") + assignment + " "); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + return {Outcome::Unsupported, {}}; + } + if (parent) { + const auto begin = Offset(text, parent->source().begin); + if (begin < text.size() && text[begin] == '[') { + auto end = text.find('\n', begin); + auto result = text; + if (end == std::string::npos) + result += std::string(newline) + assignment + newline; + else + result.insert(end + 1, assignment + newline); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + } + // Dotted/implicit tables can sometimes be extended at the document root. + auto result = text; + result.insert(BomSize(text), Encode(Value{request.section}) + "." + assignment + newline); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + } + auto result = text; + if (!result.empty() && result.back() != '\n') + result += newline; + result += "[" + Encode(Value{request.section}) + "]" + newline + assignment + newline; + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + return {Outcome::Unsupported, {}}; + } catch (const toml::parse_error&) { + return {Outcome::InvalidDocument, {}}; + } 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; + // Source locations belong to the old document; refresh on the next request. + 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/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..b5ac54813 --- /dev/null +++ b/tests/choice_setting_test.cc @@ -0,0 +1,71 @@ +#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); + + 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/macos_hook_extent_tests.cc b/tests/macos_hook_extent_tests.cc new file mode 100644 index 000000000..4b09ec733 --- /dev/null +++ b/tests/macos_hook_extent_tests.cc @@ -0,0 +1,43 @@ +#include "patches/native_hook_extent.h" +#include + +// Real Mach-O entries exercise the loaded image, not a mocked function table. +// Keep the bodies independent of optimizer/prologue choices on both CPUs. +__attribute__((naked, noinline, used)) void LongEntry() +{ __asm__ volatile(".rept 96\n nop\n .endr\n ret"); } +__attribute__((naked, noinline, used)) void ShortEntry() +{ +#if defined(__aarch64__) + __asm__ volatile(".rept 10\n nop\n .endr\n ret"); +#else + __asm__ volatile(".rept 40\n nop\n .endr\n ret"); +#endif +} +__attribute__((naked, noinline, used)) void PaddedReturn() +{ __asm__ volatile("ret\n .rept 96\n nop\n .endr"); } +__attribute__((naked, noinline, used)) void TailThunk() +{ +#if defined(__aarch64__) + __asm__ volatile("b 1f\n .rept 96\n nop\n .endr\n 1: ret"); +#else + __asm__ volatile("jmp 1f\n .rept 96\n nop\n .endr\n 1: ret"); +#endif +} +__attribute__((naked, noinline, used)) void LastEntry() +{ __asm__ volatile("ret"); } + +int main() +{ + const auto* entry = reinterpret_cast(&LongEntry); + const auto* short_entry = reinterpret_cast(&ShortEntry); + if (native_hooks::MacHookFits(short_entry) || !native_hooks::MacHookFits(short_entry, 32) + || native_hooks::MacHookFits(reinterpret_cast(&PaddedReturn), 32) + || native_hooks::MacHookFits(reinterpret_cast(&TailThunk), 32) + || !native_hooks::MacHookFits(entry) || native_hooks::MacHookFits(entry + 4) + || native_hooks::MacHookFits(reinterpret_cast(&PaddedReturn)) + || native_hooks::MacHookFits(reinterpret_cast(&TailThunk)) || native_hooks::MacHookFits(nullptr)) { + std::fputs("Mac hook extent regression failed\n", stderr); + return 1; + } + std::puts("Mac hook extent regression passed"); +} diff --git a/tests/native_boolean_callback_test.cc b/tests/native_boolean_callback_test.cc new file mode 100644 index 000000000..ca622486f --- /dev/null +++ b/tests/native_boolean_callback_test.cc @@ -0,0 +1,62 @@ +#include "settings/native_boolean_callback.h" +#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; } +} // 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.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)); + 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..d1f48f795 --- /dev/null +++ b/tests/page_catalog_test.cc @@ -0,0 +1,208 @@ +#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{}; + }, + {}}); + PageCatalog conditional("conditional", "Conditional sections"); + assert(conditional.AddBoolean("conditional", master) == 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("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("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/run-config-save.ps1 b/tests/run-config-save.ps1 new file mode 100644 index 000000000..abf73bd68 --- /dev/null +++ b/tests/run-config-save.ps1 @@ -0,0 +1,107 @@ +[CmdletBinding()] +param([string]$TomlInclude) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +function Get-PermissionState([string]$Path) { + $acl = Get-Acl -LiteralPath $Path + # Windows can normalize descriptor control bits; compare actual rules, + # ownership and inheritance protection rather than serialized SDDL spelling. + [ordered]@{ + Owner = $acl.Owner + Group = $acl.Group + Protected = $acl.AreAccessRulesProtected + Rules = @($acl.Access | Select-Object IdentityReference, FileSystemRights, + AccessControlType, IsInherited, InheritanceFlags, PropagationFlags) + } | 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 with AX first, or supply -TomlInclude.' } + $TomlInclude = $header.Directory.Parent.FullName + } + New-Item -ItemType Directory -Force build/config-save-test | Out-Null + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/config_save_test.cc mods/src/config_save.cc /Febuild/config-save-test/test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Config save test compilation failed.' } + $fixtureRoot = Join-Path $repoRoot ('build/config-save-test/' + [guid]::NewGuid()) + & ./build/config-save-test/test.exe (Join-Path $fixtureRoot 'created') + if ($LASTEXITCODE -ne 0) { throw 'Initial config creation regression failed.' } + $fixtureRoot = Join-Path $fixtureRoot 'permissions' + # Establish the baseline independently, before the first production save. + New-Item -ItemType Directory -Path $fixtureRoot | Out-Null + $testFile = Join-Path $fixtureRoot 'settings.toml' + Set-Content -LiteralPath $testFile -Value 'enabled = false' + if (-not ((Get-Acl -LiteralPath $testFile).Access | Where-Object IsInherited)) { + throw 'Fixture must have inherited permission entries.' + } + $inherited = Get-PermissionState $testFile + & ./build/config-save-test/test.exe $fixtureRoot + if ($LASTEXITCODE -ne 0 -or (Get-PermissionState $testFile) -ne $inherited) { + throw 'Inherited ACL regression failed.' + } + $acl = Get-Acl -LiteralPath $testFile + $acl.SetAccessRuleProtection($true, $true) + Set-Acl -LiteralPath $testFile -AclObject $acl + $explicit = Get-PermissionState $testFile + & ./build/config-save-test/test.exe $fixtureRoot + if ($LASTEXITCODE -ne 0 -or (Get-PermissionState $testFile) -ne $explicit) { + throw 'Explicit ACL regression failed.' + } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/config_save_failure_test.cc /Febuild/config-save-test/failure-test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Config failure test compilation failed.' } + & ./build/config-save-test/failure-test.exe (Join-Path $fixtureRoot 'failures') + if ($LASTEXITCODE -ne 0) { throw 'Config failure regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/toml_editor_test.cc mods/src/toml_editor.cc mods/src/config_save.cc ` + /Febuild/config-save-test/editor-test.exe /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'TOML editor test compilation failed.' } + & ./build/config-save-test/editor-test.exe (Join-Path $fixtureRoot 'editor') + if ($LASTEXITCODE -ne 0) { throw 'TOML editor regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/runtime_config_writer_test.cc /Febuild/config-save-test/worker-test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Runtime writer test compilation failed.' } + & ./build/config-save-test/worker-test.exe + if ($LASTEXITCODE -ne 0) { throw 'Runtime writer regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Itests ` + tests/runtime_config_test.cc /Febuild/config-save-test/adapter-test.exe /Fobuild/config-save-test/ + if ($LASTEXITCODE -ne 0) { throw 'Native adapter test compilation failed.' } + & ./build/config-save-test/adapter-test.exe + if ($LASTEXITCODE -ne 0) { throw 'Native adapter regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/runtime_config_persistence_test.cc mods/src/runtime_config_writer.cc ` + mods/src/toml_editor.cc mods/src/config_save.cc ` + /Febuild/config-save-test/persistence-test.exe /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Persistence reload test compilation failed.' } + & ./build/config-save-test/persistence-test.exe (Join-Path $fixtureRoot 'reload') + if ($LASTEXITCODE -ne 0) { throw 'Persistence reload 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 $repoRoot 'build/config-save-test/adapter-test.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') { + if ($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 'Native force-close child-process fixtures passed (500ms requested deadline; 5s harness watchdog).' +} finally { + Pop-Location +} diff --git a/tests/run-config-save.sh b/tests/run-config-save.sh new file mode 100644 index 000000000..a370ced9b --- /dev/null +++ b/tests/run-config-save.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Pass the include directory of the toml++ package used by the normal build. +toml_include="${1:?usage: run-config-save.sh TOML_INCLUDE_DIR}" +cd "$(dirname "$0")/.." +mkdir -p build/config-save-test +test_root="$(mktemp -d "$PWD/build/config-save-test/run-XXXXXX")" + +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/config_save_test.cc mods/src/config_save.cc -o "$test_root/test" +"$test_root/test" "$test_root/ordinary" +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/config_save_failure_test.cc -o "$test_root/failure-test" +"$test_root/failure-test" "$test_root/failures" +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/toml_editor_test.cc mods/src/toml_editor.cc mods/src/config_save.cc -o "$test_root/editor-test" +"$test_root/editor-test" "$test_root/editor" +clang++ -std=c++23 -pthread -I mods/src -I "$toml_include" \ + tests/runtime_config_writer_test.cc -o "$test_root/worker-test" +"$test_root/worker-test" + +# Compile the actual platform adapter, including Mac save admission and quit draining. +clang++ -std=c++23 -pthread -I mods/src -I tests \ + tests/runtime_config_test.cc -o "$test_root/adapter-test" +"$test_root/adapter-test" + +clang++ -std=c++23 -pthread -I mods/src -I "$toml_include" \ + tests/runtime_config_persistence_test.cc mods/src/runtime_config_writer.cc \ + mods/src/toml_editor.cc mods/src/config_save.cc -o "$test_root/persistence-test" +"$test_root/persistence-test" "$test_root/reload" diff --git a/tests/run-confirmation-settings.ps1 b/tests/run-confirmation-settings.ps1 new file mode 100644 index 000000000..eaa0c7d31 --- /dev/null +++ b/tests/run-confirmation-settings.ps1 @@ -0,0 +1,17 @@ +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + New-Item -ItemType Directory -Force build/settings-test | Out-Null + foreach ($fixture in @('boolean_settings', 'boolean_view', 'native_boolean_callback')) { + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src /Ithird_party/libil2cpp ` + "tests/${fixture}_test.cc" "/Febuild/settings-test/$fixture.exe" /Fobuild/settings-test/ + if ($LASTEXITCODE -ne 0) { throw "Settings fixture compilation failed: $fixture" } + & "./build/settings-test/$fixture.exe" + if ($LASTEXITCODE -ne 0) { throw "Settings fixture failed: $fixture" } + } +} finally { + Pop-Location +} diff --git a/tests/run-confirmation-settings.sh b/tests/run-confirmation-settings.sh new file mode 100644 index 000000000..f22f7c042 --- /dev/null +++ b/tests/run-confirmation-settings.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p build/settings-test +for fixture in boolean_settings boolean_view native_boolean_callback; do + clang++ -std=c++23 -pthread -I mods/src -I third_party/libil2cpp \ + "tests/${fixture}_test.cc" -o "build/settings-test/$fixture" + "build/settings-test/$fixture" +done diff --git a/tests/run-settings.ps1 b/tests/run-settings.ps1 new file mode 100644 index 000000000..068877f4c --- /dev/null +++ b/tests/run-settings.ps1 @@ -0,0 +1,17 @@ +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + New-Item -ItemType Directory -Force build/settings-test | Out-Null + foreach ($fixture in @('boolean_settings', 'boolean_view', 'native_boolean_callback', 'page_catalog', 'page_sections', 'choice_setting', 'slider_setting')) { + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src /Ithird_party/libil2cpp ` + "tests/${fixture}_test.cc" "/Febuild/settings-test/$fixture.exe" /Fobuild/settings-test/ + if ($LASTEXITCODE -ne 0) { throw "Settings fixture compilation failed: $fixture" } + & "./build/settings-test/$fixture.exe" + if ($LASTEXITCODE -ne 0) { throw "Settings fixture failed: $fixture" } + } +} finally { + Pop-Location +} diff --git a/tests/run-settings.sh b/tests/run-settings.sh new file mode 100644 index 000000000..cc0eb0d02 --- /dev/null +++ b/tests/run-settings.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p build/settings-test +for fixture in boolean_settings boolean_view native_boolean_callback page_catalog page_sections choice_setting slider_setting; do + clang++ -std=c++23 -pthread -I mods/src -I third_party/libil2cpp \ + "tests/${fixture}_test.cc" -o "build/settings-test/$fixture" + "build/settings-test/$fixture" +done diff --git a/tests/runtime_config_fixture.h b/tests/runtime_config_fixture.h new file mode 100644 index 000000000..ad7ab1817 --- /dev/null +++ b/tests/runtime_config_fixture.h @@ -0,0 +1,69 @@ +#pragma once +#if _WIN32 +#include +#endif +#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 { Conflict, InvalidDocument, Unsupported }; +// Controllable boundary; the fixture below includes the actual adapter bodies. +struct RuntimeConfigWriter { + 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..e91c010e7 --- /dev/null +++ b/tests/runtime_config_persistence_test.cc @@ -0,0 +1,37 @@ +#include "runtime_config_writer.h" +#include "config_save.h" +#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, 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("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["unrelated"]["value"].value() == 42); + assert(bytes.starts_with("# keep this comment")); + 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..77ef4daf4 --- /dev/null +++ b/tests/runtime_config_test.cc @@ -0,0 +1,130 @@ +#define CONFIG_RUNTIME_TEST "runtime_config_fixture.h" +#include "../mods/src/patches/parts/runtime_config.cc" +#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; + 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::SaveWarpMode("warp"); + fixture_update_callback(); + assert(notices == 3 && runtime_config::HasSaveFailures()); + available = true; + writer = &fixture; + runtime_config::SaveWarpMode("jump"); + Update(); + assert(notices == 3 && runtime_config::HasSaveFailures()); // Rejected edits remain session-only. + Reset(); + fixture.failures = true; // Transient thread-start failure is tracked by its key. + runtime_config::SaveWarpMode("warp"); + assert(runtime_config::HasSaveFailures() && !persistence_unavailable); + fixture.failures = false; + runtime_config::SaveWarpMode("jump"); + assert(!runtime_config::HasSaveFailures()); + Reset(); + runtime_config::SaveWarpMode("invalid"); + assert(fixture.submissions == 0); // Generic submission must retain the mode wrapper's domain check. + assert(WantsQuit([] { return true; })); + assert(fixture.stopped && stopped && !draining); + runtime_config::SaveWarpMode("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::SaveWarpMode("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::SaveWarpMode("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..9cb2f5fef --- /dev/null +++ b/tests/runtime_config_writer_test.cc @@ -0,0 +1,264 @@ +#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", Value{std::string("none")}, Report); + Check(writer.Submit("invalid") == 0); + Check(writer.Submit("warp") == 1); + AwaitSave(); + Check(writer.HasWork()); + Check(writer.Submit("jump") == 2); + Check(writer.Submit("none") == 3); + writer.Stop(false); + Check(!writer.PollStopped()); + Check(writer.Submit("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); + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused", Value{std::string("none")}); + writer.Submit("warp"); + AwaitSave(); + writer.Submit("jump"); + writer.Stop(false); // Force-close may arrive during an orderly drain. + writer.RequestCancelPending(); + Check(writer.Submit("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", Value{std::string("none")}, Report); + Check(writer.Register("graphics", "threshold", Value{0.5})); + const auto failed = writer.Submit("warp"); + AwaitSave(); + Release(); + AwaitCompletion(writer, failed); + Check(writer.HasFailures()); + AwaitCompletion(writer, writer.Submit("graphics", "threshold", 0.7)); + Check(writer.HasFailures()); // Saving B cannot hide A's failed save. + AwaitCompletion(writer, writer.Submit("jump")); + Check(!writer.HasFailures()); // A later successful save of A clears it. + Check(reports == 1); + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused", Value{std::string("none")}); + fail_thread_start = true; + Check(!writer.Submit("warp")); + Check(writer.HasFailure("ui", "auto_confirm_instant_warp") && writer.HasFailures() && !writer.HasWork()); + Check(!writer.HasFailure("other", "unregistered")); + const auto retry = writer.Submit("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", Value{std::string("none")}, Report); + writer.Submit("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", Value{std::string("none")}); + Check(writer.Register("graphics", "threshold", Value{0.5})); + Check(!writer.Submit("other", "unregistered", true)); + writer.Submit("warp"); + AwaitSave(); + Check(!writer.Register("graphics", "late", Value{true})); + writer.Submit("graphics", "threshold", 0.6, 150ms); + writer.Submit("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", std::nullopt); + 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", std::nullopt); + 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", std::nullopt); + 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/slider_setting_test.cc b/tests/slider_setting_test.cc new file mode 100644 index 000000000..284913846 --- /dev/null +++ b/tests/slider_setting_test.cc @@ -0,0 +1,115 @@ +#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() == "Out of range; edit TOML"); + speedView.Bind(); // Reopening cannot repair a value outside this editor's range. + assert(value == 2000.0f && speedView.unavailableReason() == "Out of range; edit TOML"); + 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/toml_editor_test.cc b/tests/toml_editor_test.cc new file mode 100644 index 000000000..14a6f22ce --- /dev/null +++ b/tests/toml_editor_test.cc @@ -0,0 +1,116 @@ +#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"; + 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/xmake.lua b/tests/xmake.lua new file mode 100644 index 000000000..88088ac4d --- /dev/null +++ b/tests/xmake.lua @@ -0,0 +1,11 @@ +if is_plat("macosx") then + target("macos-hook-extent-tests") + do + set_kind("binary") + set_default(false) + add_files("macos_hook_extent_tests.cc", "../mods/src/patches/native_hook_extent.cc") + add_includedirs("../mods/src") + add_packages("spud", "spdlog") + set_policy("build.optimization.lto", false) + end +end diff --git a/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt b/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt index eba1509e8..6d3e45fda 100644 --- a/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt +++ b/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt @@ -215,8 +215,11 @@ target_sources( "src/detour/x86_64/relocators.cc" "src/detour/x86_64/relocators.h" "src/detour/detour.cc" + "src/detour/prologue.cc" "src/detour/detour_impl.h" "src/detour/fwd.h" + "src/detour/target_registry.cc" + "src/detour/target_registry.h" "src/detour/remapper.cc" "src/detour/remapper.h" "src/memory/protection.cc" @@ -238,7 +241,7 @@ if(NOT SPUD_NO_INSTALL) ) endif() if(SPUD_BUILD_TESTS) - set(TEST_SRCS "tests/signature.cc") + set(TEST_SRCS "tests/detour_registry.cc") file( GLOB_RECURSE TEST_DETOUR_SHARED_SRCS tests/detour/shared/*.cc @@ -306,35 +309,35 @@ if(SPUD_BUILD_TESTS) add_executable( "spud.test" ${TEST_SRCS} - "tests/test_util.h" - "tests/signature.cc" ) - target_include_directories("spud.test" PRIVATE "tests") + target_include_directories("spud.test" PRIVATE "tests" "src") target_link_libraries( "spud.test" PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" ) add_dependencies("spud.test" "Catch2" "spud") - add_executable("spud.benchmark" ${BENCHMARK_SRCS}) - target_include_directories("spud.benchmark" PRIVATE "benchmark") - if(SPUD_COMPARE_LIBS) - target_link_libraries( - "spud.benchmark" - PRIVATE - "Catch2" - "Catch2::Catch2WithMain" - "spud" - "lime" - "minhook" - "PolyHook2" - ) - else() - target_link_libraries( - "spud.benchmark" - PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" - ) + if(BENCHMARK_SRCS) + add_executable("spud.benchmark" ${BENCHMARK_SRCS}) + target_include_directories("spud.benchmark" PRIVATE "benchmark") + if(SPUD_COMPARE_LIBS) + target_link_libraries( + "spud.benchmark" + PRIVATE + "Catch2" + "Catch2::Catch2WithMain" + "spud" + "lime" + "minhook" + "PolyHook2" + ) + else() + target_link_libraries( + "spud.benchmark" + PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" + ) + endif() + add_dependencies("spud.benchmark" "Catch2" "spud") + add_test(NAME spud.benchmark COMMAND spud.benchmark) endif() - add_dependencies("spud.benchmark" "Catch2" "spud") add_test(NAME spud.test COMMAND spud.test) - add_test(NAME spud.benchmark COMMAND spud.benchmark) endif() diff --git a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h index 946de5cc1..7f72f9c42 100644 --- a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h +++ b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #if __cpp_lib_source_location && SPUD_DETOUR_TRACING @@ -17,6 +18,25 @@ #include "utils.h" namespace spud { + +// Read-only, bounded check for enough complete host instructions before a +// return/tail jump. The caller must supply a readable, independently verified +// native extent. This does not install a detour or promise relocation success. +bool has_detour_prologue(const void *address, size_t extent); + +enum class detour_install_status : uint8_t { + not_installed, + installed, + already_installed, + duplicate_target, +}; + +using detour_diagnostic_handler = void (*)(const char *message); + +// Installs a process-local diagnostic sink. The handler must have process lifetime; replacement does not wait for +// concurrent reports. The message is borrowed for the duration of the callback. +void set_detour_diagnostic_handler(detour_diagnostic_handler handler) noexcept; + namespace detail { struct detour { @@ -29,27 +49,23 @@ struct detour { return this->trampoline_; } + detour_install_status last_install_status() const { + return last_install_status_; + } + using Self = detail::detour; public: detour(detour &&other) noexcept { - this->trampoline_ = other.trampoline_; - this->address_ = other.address_; - this->func_ = other.func_; - this->wrapper_ = other.wrapper_; - this->context_container_ = std::move(other.context_container_); - this->original_func_data_ = other.original_func_data_; - other.original_func_data_.clear(); + move_from(std::move(other)); } detour &operator=(detour &&other) noexcept { - this->trampoline_ = other.trampoline_; - this->address_ = other.address_; - this->func_ = other.func_; - this->wrapper_ = other.wrapper_; - this->context_container_ = std::move(other.context_container_); - this->original_func_data_ = other.original_func_data_; - other.original_func_data_.clear(); + if (this == &other) { + return *this; + } + remove(); + move_from(std::move(other)); return *this; } @@ -66,24 +82,19 @@ struct detour { #if __cpp_lib_source_location && SPUD_DETOUR_TRACING detour(uintptr_t address, uintptr_t func, uintptr_t wrapper, const std::source_location location = std::source_location::current()) - : address_(address), func_(func), wrapper_(wrapper), + : requested_address_(address), address_(address), func_(func), + wrapper_(wrapper), context_container_(std::make_unique()), location_(location) {} #else detour(uintptr_t address, uintptr_t func, uintptr_t wrapper) - : address_(address), func_(func), wrapper_(wrapper), + : requested_address_(address), address_(address), func_(func), + wrapper_(wrapper), context_container_(std::make_unique()) {} #endif Self &install(Arch arch = Arch::kHost); void remove(); - Self &detach() { - original_func_data_.clear(); - // We are intentionally ignoring the return value here - // TODO(alex): Move this to a global cleanup thing - (void)context_container_.release(); - - return *this; - } + Self &detach(); static uintptr_t get_context_value(); @@ -91,6 +102,22 @@ struct detour { detour(detour const &) = delete; detour &operator=(detour const &) = delete; + void move_from(detour &&other) noexcept { + requested_address_ = other.requested_address_; + address_ = other.address_; + func_ = other.func_; + wrapper_ = other.wrapper_; + context_container_ = std::move(other.context_container_); + trampoline_ = other.trampoline_; + original_func_data_ = std::move(other.original_func_data_); + installed_ = std::exchange(other.installed_, false); + last_install_status_ = other.last_install_status_; + + other.trampoline_ = 0; + other.last_install_status_ = detour_install_status::not_installed; + } + + uintptr_t requested_address_; uintptr_t address_; uintptr_t func_; uintptr_t wrapper_; @@ -98,6 +125,9 @@ struct detour { std::unique_ptr context_container_ = nullptr; uintptr_t trampoline_ = 0; std::vector original_func_data_ = {}; + bool installed_ = false; + detour_install_status last_install_status_ = + detour_install_status::not_installed; #if __cpp_lib_source_location && SPUD_DETOUR_TRACING const std::source_location location_ = std::source_location::current(); #endif diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc b/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc index e5cd87d42..5767fabca 100644 --- a/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc @@ -5,11 +5,16 @@ #include "detour_impl.h" #include "remapper.h" +#include "target_registry.h" #if SPUD_OS_APPLE #include #endif +#include +#include +#include +#include #include #if SPUD_OS_APPLE || SPUD_OS_LINUX @@ -21,7 +26,45 @@ extern "C" uintptr_t ASM_FUNC(spud_read_context_value, ()); namespace spud { +namespace { +std::atomic diagnostic_handler = nullptr; +} + +void set_detour_diagnostic_handler(detour_diagnostic_handler handler) noexcept { + diagnostic_handler.store(handler, std::memory_order_release); +} + namespace detail { +namespace { + +void report_conflict(const target_owner &candidate, + const target_owner &incumbent) noexcept { + std::array message{}; + std::snprintf( + message.data(), message.size(), + "spud: duplicate detour rejected (requested=0x%" PRIxPTR + ", canonical=0x%" PRIxPTR ", replacement=0x%" PRIxPTR + "); existing owner requested=0x%" PRIxPTR ", canonical=0x%" PRIxPTR + ", replacement=0x%" PRIxPTR ")", + candidate.requested_address, candidate.canonical_address, + candidate.replacement_address, incumbent.requested_address, + incumbent.canonical_address, incumbent.replacement_address); + + if (const auto handler = diagnostic_handler.load(std::memory_order_acquire); + handler != nullptr) { + try { + handler(message.data()); + return; + } catch (...) { + } + } + + std::fprintf(stderr, "%s\n", message.data()); + std::fflush(stderr); +} + +} // namespace + struct DetourImpl { std::vector (*create_absolute_jump)(uintptr_t target, uintptr_t data); @@ -51,84 +94,127 @@ const static std::array kDetourImpls = { }; detail::detour &detour::install(Arch arch) { + if (installed_) { + last_install_status_ = detour_install_status::already_installed; + return *this; + } + if (context_container_ == nullptr) { + last_install_status_ = detour_install_status::not_installed; + return *this; + } + const auto &impl = kDetourImpls[arch]; // We don't want to hook things that point to a direct jump // This will resolve that jump and instead we hook the underlying function // func_ = impl.maybe_resolve_jump(func_); // TODO(alex): This will break hook stacking most likely right now... - address_ = impl.maybe_resolve_jump(address_); + const auto canonical_address = impl.maybe_resolve_jump(requested_address_); + const target_owner candidate = { + .requested_address = requested_address_, + .canonical_address = canonical_address, + .owner_token = reinterpret_cast(context_container_.get()), + .replacement_address = func_, + }; + const auto claim = detour_target_registry().claim(candidate); + if (claim.status == target_claim_status::conflict) { + last_install_status_ = detour_install_status::duplicate_target; + report_conflict(candidate, claim.incumbent); + return *this; + } + if (claim.status == target_claim_status::already_owned) { + last_install_status_ = detour_install_status::already_installed; + return *this; + } + + address_ = canonical_address; wrapper_ = impl.maybe_resolve_jump(wrapper_); - const auto jump = impl.create_absolute_jump( - wrapper_, reinterpret_cast(context_container_.get())); + bool jit_write_protection_disabled = false; + try { + const auto jump = impl.create_absolute_jump( + wrapper_, reinterpret_cast(context_container_.get())); - auto [relocation_infos, required_trampoline_size] = - impl.collect_relocations(address_, jump.size()); + auto [relocation_infos, required_trampoline_size] = + impl.collect_relocations(address_, jump.size()); - // Required trampoline size is how many bytes we have to take up of the - // original function This will then be used to calculate the expanded size, - // since we do have some instruction replacement and expansion going on + // Required trampoline size is how many bytes we have to take up of the + // original function This will then be used to calculate the expanded size, + // since we do have some instruction replacement and expansion going on - auto trampoline = impl.create_trampoline( - address_ + required_trampoline_size, - {reinterpret_cast(address_), required_trampoline_size}, - relocation_infos); + auto trampoline = impl.create_trampoline( + address_ + required_trampoline_size, + {reinterpret_cast(address_), required_trampoline_size}, + relocation_infos); - // TODO(tashcan): This isn't particularly amazing, we might have to ajdust - // permissions - disable_jit_write_protection(); + // TODO(tashcan): This isn't particularly amazing, we might have to adjust + // permissions + disable_jit_write_protection(); + jit_write_protection_disabled = true; - auto trampoline_address = alloc_executable_memory(trampoline.data.size()); - assert(trampoline_address != nullptr); + auto trampoline_address = alloc_executable_memory(trampoline.data.size()); + assert(trampoline_address != nullptr); - std::memcpy(trampoline_address, trampoline.data.data(), - trampoline.data.size()); - trampoline_ = - trampoline.start + reinterpret_cast(trampoline_address); + std::memcpy(trampoline_address, trampoline.data.data(), + trampoline.data.size()); + trampoline_ = + trampoline.start + reinterpret_cast(trampoline_address); - context_container_->func = func_; - context_container_->trampoline = trampoline_; + context_container_->func = func_; + context_container_->trampoline = trampoline_; #if __cpp_lib_source_location && SPUD_DETOUR_TRACING - context_container_->location = location_; + context_container_->location = location_; #endif - { - const auto copy_size = required_trampoline_size; - original_func_data_.resize(copy_size); - std::memcpy(original_func_data_.data(), reinterpret_cast(address_), - copy_size); - - remapper remap(address_, copy_size); { - SPUD_SCOPED_PROTECTION(remap, copy_size, - mem_protection::READ_WRITE_EXECUTE); + const auto copy_size = required_trampoline_size; + original_func_data_.resize(copy_size); + std::memcpy(original_func_data_.data(), + reinterpret_cast(address_), copy_size); - // This will leave some trashed instructions - // Which is okay for now - // TODO(tashcan): Add some kind of NOP function to make the remaining - // stuff "valid" - std::memcpy(reinterpret_cast(uintptr_t(remap)), jump.data(), - jump.size()); + remapper remap(address_, copy_size); + { + SPUD_SCOPED_PROTECTION(remap, copy_size, + mem_protection::READ_WRITE_EXECUTE); + + // This will leave some trashed instructions + // Which is okay for now + // TODO(tashcan): Add some kind of NOP function to make the remaining + // stuff "valid" + std::memcpy(reinterpret_cast(uintptr_t(remap)), jump.data(), + jump.size()); #if SPUD_OS_APPLE - sys_dcache_flush((void *)address_, copy_size); + sys_dcache_flush((void *)address_, copy_size); #endif - } + } #if SPUD_OS_APPLE - sys_icache_invalidate((void *)address_, copy_size); + sys_icache_invalidate((void *)address_, copy_size); #endif - } + } - enable_jit_write_protection(); + enable_jit_write_protection(); + jit_write_protection_disabled = false; + } catch (...) { + if (jit_write_protection_disabled) { + enable_jit_write_protection(); + } + detour_target_registry().release(candidate); + original_func_data_.clear(); + trampoline_ = 0; + address_ = requested_address_; + throw; + } + installed_ = true; + last_install_status_ = detour_install_status::installed; return *this; } void detour::remove() { - assert(context_container_.get() != nullptr); - if (original_func_data_.size() == 0) { + if (!installed_ || original_func_data_.empty()) { return; } + assert(context_container_.get() != nullptr); disable_jit_write_protection(); { @@ -143,6 +229,28 @@ void detour::remove() { enable_jit_write_protection(); original_func_data_.clear(); + detour_target_registry().release({ + .requested_address = requested_address_, + .canonical_address = address_, + .owner_token = reinterpret_cast(context_container_.get()), + .replacement_address = func_, + }); + installed_ = false; + trampoline_ = 0; + address_ = requested_address_; + last_install_status_ = detour_install_status::not_installed; +} + +detour::Self &detour::detach() { + if (!installed_ || original_func_data_.empty()) { + return *this; + } + + original_func_data_.clear(); + // The target remains patched, so both the callback context and target claim + // intentionally live until process exit. + (void)context_container_.release(); + return *this; } uintptr_t detour::get_context_value() { diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/prologue.cc b/xmake-packages/packages/s/spud/spud-src/src/detour/prologue.cc new file mode 100644 index 000000000..40179eefd --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/prologue.cc @@ -0,0 +1,63 @@ +#include +#include +#include +#if SPUD_ARCH_X86_64 +#include +#elif SPUD_ARCH_ARM64 && SPUD_AARCH64_SUPPORT +#include +#endif + +bool spud::has_detour_prologue(const void* address, size_t extent) +{ + if (!address) + return false; +#if SPUD_ARCH_X86_64 + // mov r11, imm64; jmp [rip]; destination address. + constexpr size_t overwrite = 24; + if (extent < overwrite) + return false; + ZydisDecoder decoder; + if (!ZYAN_SUCCESS(ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_64, ZYDIS_STACK_WIDTH_64))) + return false; + size_t bytes = 0; + const auto limit = std::min(extent, size_t{64}); + while (bytes < overwrite) { + ZydisDecodedInstruction instruction; + ZydisDecodedOperand operands[ZYDIS_MAX_OPERAND_COUNT]; + if (!ZYAN_SUCCESS(ZydisDecoderDecodeFull(&decoder, static_cast(address) + bytes, limit - bytes, + &instruction, operands))) + return false; + if (instruction.meta.category == ZYDIS_CATEGORY_RET || instruction.meta.category == ZYDIS_CATEGORY_UNCOND_BR + || instruction.meta.category == ZYDIS_CATEGORY_INTERRUPT) + return false; + bytes += instruction.length; + } + return true; +#elif SPUD_ARCH_ARM64 && SPUD_AARCH64_SUPPORT + // ldr; up to four mov instructions; br; destination address. + constexpr size_t overwrite = 32; + if (extent < overwrite) + return false; + csh handle{}; + if (cs_open(CS_ARCH_AARCH64, CS_MODE_LITTLE_ENDIAN, &handle) != CS_ERR_OK) + return false; + cs_option(handle, CS_OPT_DETAIL, CS_OPT_ON); + cs_insn* instructions = nullptr; + const auto count = cs_disasm(handle, static_cast(address), std::min(extent, size_t{64}), + reinterpret_cast(address), 0, &instructions); + size_t bytes = 0; + for (size_t i = 0; i < count && bytes < overwrite; ++i) { + const auto& instruction = instructions[i]; + if (cs_insn_group(handle, &instruction, CS_GRP_RET) || cs_insn_group(handle, &instruction, CS_GRP_INT) + || std::strcmp(instruction.mnemonic, "b") == 0 || std::strcmp(instruction.mnemonic, "br") == 0) + break; + bytes += instruction.size; + } + cs_free(instructions, count); + cs_close(&handle); + return bytes >= overwrite; +#else + (void)extent; + return false; +#endif +} diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc new file mode 100644 index 000000000..ac3397e3f --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc @@ -0,0 +1,78 @@ +#include "target_registry.h" + +#include + +namespace spud::detail +{ + +namespace +{ + std::array target_keys(const target_owner &owner) + { return {owner.requested_address, owner.canonical_address}; } +} // namespace + +target_claim_result target_registry::claim(const target_owner &candidate) +{ + std::lock_guard lock(mutex_); + bool owner_seen = false; + + for (const auto key : target_keys(candidate)) { + const auto entry = targets_.find(key); + if (entry == targets_.end()) { + continue; + } + if (entry->second.owner_token != candidate.owner_token) { + return {target_claim_status::conflict, entry->second}; + } + owner_seen = true; + } + if (owner_seen) { + return {target_claim_status::already_owned, {}}; + } + + std::array inserted{}; + size_t inserted_count = 0; + try { + for (const auto key : target_keys(candidate)) { + if (targets_.contains(key)) { + continue; + } + targets_.emplace(key, candidate); + inserted[inserted_count++] = key; + } + } catch (...) { + for (size_t index = 0; index < inserted_count; ++index) { + targets_.erase(inserted[index]); + } + throw; + } + + return {target_claim_status::claimed, {}}; +} + +void target_registry::release(const target_owner &owner) +{ + std::lock_guard lock(mutex_); + for (const auto key : target_keys(owner)) { + const auto entry = targets_.find(key); + if (entry != targets_.end() && entry->second.owner_token == owner.owner_token) { + targets_.erase(entry); + } + } +} + +size_t target_registry::size() const +{ + std::lock_guard lock(mutex_); + return targets_.size(); +} + +target_registry &detour_target_registry() +{ + // Detours can be destroyed during static teardown, so the registry must + // intentionally outlive every static detour object. + static auto *registry = new target_registry(); + return *registry; +} + +} // namespace spud::detail diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h new file mode 100644 index 000000000..cf8c67f9c --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include + +namespace spud::detail +{ + +struct target_owner { + uintptr_t requested_address; + uintptr_t canonical_address; + uintptr_t owner_token; + uintptr_t replacement_address; +}; + +enum class target_claim_status { + claimed, + already_owned, + conflict, +}; + +struct target_claim_result { + target_claim_status status; + target_owner incumbent{}; +}; + +class target_registry +{ +public: + target_claim_result claim(const target_owner &candidate); + void release(const target_owner &owner); + size_t size() const; + +private: + mutable std::mutex mutex_; + std::unordered_map targets_; +}; + +target_registry &detour_target_registry(); + +} // namespace spud::detail diff --git a/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc b/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc new file mode 100644 index 000000000..78465eb29 --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc @@ -0,0 +1,190 @@ +#include + +#include "detour/target_registry.h" + +#include +#include +#include +#include +#include + +#include + +namespace +{ + +#if defined(_MSC_VER) +#define SPUD_TEST_NOINLINE __declspec(noinline) +#else +#define SPUD_TEST_NOINLINE __attribute__((noinline)) +#endif + +SPUD_TEST_NOINLINE int duplicate_target(int value) +{ return value + 1; } + +SPUD_TEST_NOINLINE int move_target(int value) +{ return value + 2; } + +SPUD_TEST_NOINLINE int reinstall_target(int value) +{ return value + 3; } + +SPUD_TEST_NOINLINE int detached_target(int value) +{ return value + 4; } + +std::string last_diagnostic; + +void capture_diagnostic(const char *message) +{ last_diagnostic = message; } + +int add_ten(int (*original)(int), int value) +{ return original(value) + 10; } + +int add_twenty(int (*original)(int), int value) +{ return original(value) + 20; } + +} // namespace + +TEST_CASE("target registry rejects exact and canonical aliases") +{ + spud::detail::target_registry registry; + const spud::detail::target_owner first = { + .requested_address = 0x1000, + .canonical_address = 0x2000, + .owner_token = 0x3000, + .replacement_address = 0x4000, + }; + + REQUIRE(registry.claim(first).status == spud::detail::target_claim_status::claimed); + REQUIRE(registry.size() == 2); + REQUIRE(registry.claim(first).status == spud::detail::target_claim_status::already_owned); + + const auto exact = registry.claim({ + .requested_address = first.requested_address, + .canonical_address = 0x5000, + .owner_token = 0x6000, + .replacement_address = 0x7000, + }); + REQUIRE(exact.status == spud::detail::target_claim_status::conflict); + REQUIRE(exact.incumbent.owner_token == first.owner_token); + + const auto alias = registry.claim({ + .requested_address = 0x8000, + .canonical_address = first.canonical_address, + .owner_token = 0x9000, + .replacement_address = 0xA000, + }); + REQUIRE(alias.status == spud::detail::target_claim_status::conflict); + REQUIRE(alias.incumbent.owner_token == first.owner_token); + + registry.release(first); + REQUIRE(registry.size() == 0); +} + +TEST_CASE("target registry admits exactly one concurrent owner") +{ + spud::detail::target_registry registry; + std::barrier start(3); + std::atomic_size_t claimed = 0; + std::atomic_size_t conflicts = 0; + + const auto contender = [&](uintptr_t owner) { + start.arrive_and_wait(); + const auto result = registry.claim({ + .requested_address = 0x1000, + .canonical_address = 0x2000, + .owner_token = owner, + .replacement_address = owner + 1, + }); + if (result.status == spud::detail::target_claim_status::claimed) { + claimed.fetch_add(1, std::memory_order_relaxed); + } else if (result.status == spud::detail::target_claim_status::conflict) { + conflicts.fetch_add(1, std::memory_order_relaxed); + } + }; + + std::thread first(contender, 0x3000); + std::thread second(contender, 0x4000); + start.arrive_and_wait(); + first.join(); + second.join(); + + REQUIRE(claimed.load(std::memory_order_relaxed) == 1); + REQUIRE(conflicts.load(std::memory_order_relaxed) == 1); + REQUIRE(registry.size() == 2); +} + +TEST_CASE("duplicate detour preserves the first installed hook") +{ + int (*volatile call_target)(int) = duplicate_target; + REQUIRE(call_target(1) == 2); + + { + auto first = spud::create_detour(&duplicate_target, &add_ten); + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 12); + + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::already_installed); + REQUIRE(call_target(1) == 12); + + auto second = spud::create_detour(&duplicate_target, &add_twenty); + last_diagnostic.clear(); + spud::set_detour_diagnostic_handler(&capture_diagnostic); + second.install(); + spud::set_detour_diagnostic_handler(nullptr); + REQUIRE(second.last_install_status() == spud::detour_install_status::duplicate_target); + REQUIRE(last_diagnostic.find("duplicate detour rejected") != std::string::npos); + REQUIRE(second.trampoline() == nullptr); + REQUIRE(call_target(1) == 12); + } + REQUIRE(call_target(1) == 2); +} + +TEST_CASE("moving an installed detour preserves ownership and cleanup") +{ + int (*volatile call_target)(int) = move_target; + REQUIRE(call_target(1) == 3); + { + auto source = spud::create_detour(&move_target, &add_ten); + source.install(); + auto moved = std::move(source); + REQUIRE(moved.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 13); + } + REQUIRE(call_target(1) == 3); +} + +TEST_CASE("normal destruction releases a target for later installation") +{ + int (*volatile call_target)(int) = reinstall_target; + { + auto first = spud::create_detour(&reinstall_target, &add_ten); + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 14); + } + REQUIRE(call_target(1) == 4); + { + auto second = spud::create_detour(&reinstall_target, &add_twenty); + second.install(); + REQUIRE(second.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 24); + } + REQUIRE(call_target(1) == 4); +} + +TEST_CASE("detached hooks retain their target claim") +{ + int (*volatile call_target)(int) = detached_target; + { + auto first = spud::create_detour(&detached_target, &add_ten); + first.install().detach(); + REQUIRE(call_target(1) == 15); + } + + auto second = spud::create_detour(&detached_target, &add_twenty); + second.install(); + REQUIRE(second.last_install_status() == spud::detour_install_status::duplicate_target); + REQUIRE(call_target(1) == 15); +} diff --git a/xmake-requires.lock b/xmake-requires.lock index cad21781a..6df26d62e 100644 --- a/xmake-requires.lock +++ b/xmake-requires.lock @@ -126,11 +126,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-7#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-7" }, ["toml++#f56260b5"] = { repo = { @@ -273,11 +273,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-7#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-7" }, ["toml++#f56260b5"] = { repo = { @@ -384,11 +384,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-7#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-7" }, ["toml++#f56260b5"] = { repo = { @@ -407,4 +407,4 @@ version = "v1.3.2" } } -} \ No newline at end of file +} diff --git a/xmake.lua b/xmake.lua index 9b25acbfb..02524c0e8 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,3 +26,5 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") + +includes("tests") diff --git a/xmake/dependencies/common.lua b/xmake/dependencies/common.lua index 879f03a4d..74702f1be 100644 --- a/xmake/dependencies/common.lua +++ b/xmake/dependencies/common.lua @@ -20,7 +20,7 @@ add_requires("toml++") add_requires("nlohmann_json") add_requires("protobuf 35.1") add_requires("cpr", {system = false}) -add_requires("spud v0.2.0-3") +add_requires("spud v0.2.0-7") add_requires("libil2cpp") add_requires("simdutf", {system = false})