diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6f1268bc1..074c84738 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -206,6 +206,10 @@ jobs: - name: Build run: xmake build -y stfc-community-mod + - name: Test confirmation setting contracts + shell: pwsh + run: ./tests/run-confirmation-settings.ps1 + - name: Test x64 trampoline relocation shell: pwsh run: | @@ -218,6 +222,17 @@ jobs: 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: | @@ -521,6 +536,20 @@ 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: Report Swift module cache shell: bash run: | diff --git a/docs/MOD_SETTINGS.md b/docs/MOD_SETTINGS.md new file mode 100644 index 000000000..3702b22ef --- /dev/null +++ b/docs/MOD_SETTINGS.md @@ -0,0 +1,102 @@ +# Mod settings: current architecture and behavior + +This is the current contract for the expanded Windows x64 and macOS settings UI. The +[foundation notes](MOD_SETTINGS_FOUNDATION.md) describe the first FC-only slice; +their prototype counts and proposed budgets are historical, not current limits. + +## Ownership + +Feature adapters own live values, availability and persistence. A +`ValueSetting` owns snapshot validation, guarded application and readback. +`PageCatalog` owns placement and presentation callbacks, without Unity objects +or a second copy of configuration. Registration freezes before native creation. +Each game settings context gets a fresh tree from that immutable plan. + +The [native adapter map](MOD_SETTINGS_NATIVE_ADAPTER.md) identifies each hook +owner. Interop, value widgets, action widgets, navigation and styling are separate +concerns. Existing XMake source discovery builds them. Each detour has one owner. +The `ModConfirmationSettings` registry entry uses default-enabled +`[patches].nativesettingshooks` in every build; its C++ flag is +`installNativeSettings`. Persistence is independently controlled by +`[patches].runtimeconfighooks`. Forbidden Tech and fleet-label installation use +their owning patch switches, independent of native UI and feature values. + +## Placement and summaries + +Player tasks determine labels and navigation; TOML sections remain the storage +reference. Moving a page does not rename stored keys or change defaults. + +| Mod Settings page | Contents | Storage reference | +| --- | --- | --- | +| Camera | Keyboard zoom speed, pan glide | `[graphics]` | +| Fleet Labels | Collapsible Player and Non-player profiles | `[graphics]` | +| Map & Travel | Instant warp mode, shared with its shortcut | `[ui]` | +| Previews & Cargo | Preview shortcuts and automatic cargo previews | `[ui]` | +| HUD Buttons | Q's Trials, Field Training, Outposts and Missions; Auto / Always / Never | `[ui].hud_*` | + +Empty groups are omitted. Camera and preview controls require their existing +consumer hooks to have installed successfully. FC and Forbidden Tech remain in +the game's native confirmation page. Fleet headings start collapsed and summarize +their current mode, with a two-decimal threshold where relevant. The warp page +row shows its mode. Summaries refresh on binding and existing setting notifications. +Cargo target rows appear only while Automatically open cargo is ON; hiding them +preserves each target preference. + +## Honest state and persistence + +Readback proves the live value, not file or cloud durability. Unknown state never +looks OFF. Failed application preserves authoritative readback and never issues +an automatic reverse write. Native indicators are suppressed when unknown. + +A finite loaded value outside a slider's UI range shows `Out of range; edit TOML`. +A non-finite value shows `Invalid value; edit TOML`. Neither is clamped, saved or +fixed by reopening. TOML is loaded at startup: a manual correction takes effect +after restarting. Ordinary unavailable readers retain `Reopen to retry`. +Disabled sliders receive a feature-owned reason; only Fleet Labels says +`Select Threshold`. Keep suffixes short: the native row truncates long labels. +The shared slider widget rounds display values, including during dragging; +speed uses whole numbers and fractional controls use at most two decimals. +Rendering never rounds or saves a loaded preference. + +The existing single writer serializes TOML edits and coalesces pending changes +per key. It preserves unrelated source and detects conflicting external edits. +Runtime failure/conflict leaves the live edit active. A failure-only notice at +the top of Mod Settings pages says `Active this session; couldn't save. See mod log.` +It concerns mod TOML saves, not the native FC cloud preference. Detailed key and +failure information stays in the log. No success notices or modal dialogs appear. + +Failure state belongs to the writer's per-key records. Saving one key cannot +hide another key's failure. A later successful save of the failed key clears it. +Rejected submissions outside the writer leave a conservative session warning, +because they have no tracked completion. The existing runtime callback observes +aggregate status without taking the writer lock; native UI work happens only +when it changes, on the game thread. Opening settings never retries or writes. +F10's 500 ms best effort force close and the ordinary quit/drain path are +unchanged. See [persistence contracts](config-save.md). + +## Native views + +Widgets keep weak ownership records and restore text, tint, sprites, button +visibility and interactability before reuse. Callback identity and the currently +bound context gate commands. Value rows defer list rebinding until their +request/readback scope finishes. Headings and action rows do not persist +presentation state. The save notice uses the native button-row adapter with its +button hidden and invocation disabled; visibility is checked on refresh. +An unavailable action-widget family leaves controls usable and save details in +the log. Failure notices never create otherwise-empty groups. + +There is no new polling hook, save worker, timer or global localization hook. +Platform guards, managed signature checks and hook ownership checks remain part +of installation on Windows and macOS. +Platform builds alone do not establish runtime compatibility. + +## Validation and follow-up + +Run `tests/run-settings.ps1`, `tests/run-config-save.ps1` and the Windows build. +Fixtures cover guarded values, range preservation, conditional sections, command +identities, writer failures and shutdown. Native checks separately cover Back, +folding, conditional rows, notice layout and pooled stock-row restoration. + +Shortcut editing and automatic action discovery follow in a separate PR. +Numeric input boxes and a real client restart command remain later work. Neither generic TOML editing nor exposing every config key is +implied by this catalog. diff --git a/docs/MOD_SETTINGS_CONTROLS.md b/docs/MOD_SETTINGS_CONTROLS.md new file mode 100644 index 000000000..24af8542a --- /dev/null +++ b/docs/MOD_SETTINGS_CONTROLS.md @@ -0,0 +1,186 @@ +# Mod settings controls + +Build real controls on the navigation foundation in small slices. Register only +working controls; omit empty groups. Stable setting keys and storage owners stay +independent of labels and placement. + +See [the current architecture contract](MOD_SETTINGS.md) and +[native adapter ownership](MOD_SETTINGS_NATIVE_ADAPTER.md). + +## Layout + +| Location | Control | Existing owner | +| --- | --- | --- | +| Mod Settings > Map & Travel | Instant warp mode: Normal (ask), Warp, Jump | `ui.auto_confirm_instant_warp` and the Alt+I action | +| Mod Settings > Fleet Labels | Player label detail and zoom threshold | `graphics.zoom_label_player_detail`, `graphics.zoom_label_player_threshold` | +| Mod Settings > Fleet Labels | Non-player label detail and zoom threshold | `graphics.zoom_label_non_player_detail`, `graphics.zoom_label_non_player_threshold` | +| Mod Settings > Camera | Keyboard zoom speed and pan glide | `graphics.keyboard_zoom_speed`, `graphics.system_pan_momentum_falloff` | +| Mod Settings > Previews & Cargo | Locate/Recall while previewing; automatic cargo and target types | Existing preview/cargo keys in `[ui]` | +| Future separate branch: Hotkeys | Rebind existing actions | Existing shortcut parser and `MapKey` registrations | +| General > confirmation page | Confirm Forbidden Tech upgrades | Inverse of `ui.auto_confirm_ft_upgrade` | + +The controls branch implements shared Windows x64 and macOS adapters; platform +qualification is recorded separately from implementation. +Hotkey editing remains a separate branch. Native confirmation +controls stay on the native page. FC retains its existing owner. + +## Instant warp mode + +Use one selection, not three independently stored flags. The UI and Alt+I call +the same live mutation function. Cycle order remains Normal > Warp > Jump > Normal. +Selecting the current value does not enqueue another save. Invalid choices do +not alter live state or the file. Existing per-ship overrides retain precedence; +the picker changes only the global fallback mode. + +Reuse the existing single runtime writer, optimistic conflict handling and +source-preserving TOML edits. UI readback confirms the live value, not durable +storage; asynchronous failures produce a quiet session-only notice, with details +in the log. Reopening must +read the current owner, and shortcut changes must refresh a visible selector. +Native selection callbacks need the same rendering, stale-context and reentry +protection already exercised for boolean controls. + +Selected options use bold text and the native checkmark on a normal background, +including instant warp and both Fleet Labels profiles. White fill is transient +pressed feedback, not persistent selection or keyboard focus. The scoped adapter +uses native sprites already rendered by settings rows and restores each Image's +previous override before pooling. The shared `Selectable.DoStateTransition` +hook observes input-state changes, calls the original once, then updates only +owned selection rows. Other controls take the native path; there is no frame +polling, animation replacement, asset loading or setting write in this hook. + +## Fleet Labels and Forbidden Tech + +One Fleet Labels page contains a collapsible Player heading, its Native / +Expanded / Compact / Threshold choices and percentage slider, followed by the +same controls under a Non-player heading. Each profile has its own owner and +selection. Click either heading to hide/show its controls independently, without +navigating away. Both sections start collapsed on each page visit; expansion is +temporary presentation state and never writes TOML or changes a setting value. +The native category arrow points down when expanded and right when collapsed. +Headings use larger bold cyan text and a darkened row background. An enabled +threshold slider uses a subtle cyan accent to connect it to the selected mode, +without a white selection fill. Tints affect the row's direct `BG` Image child +(`Background` for category headings), +when present; other prefab layouts retain the text styling. Native colors and +text are restored before refresh and pooling. Styling uses the existing bind, +refresh and release hooks, with no frame polling or shared-material changes. +Threshold is stored in [0, 1], edited in 1% steps, +and enabled only in Threshold mode. At 0% labels stay compact; at 100% they stay +expanded. Reading a player-authored fractional value does not round or save it. +Each user edit updates the existing live profile and refreshes tracked labels. +The native slider callbacks use the same typed snapshot/reentry guards as choices. +Unknown values suppress the slider and numeric label; disabled known values remain +visible. Releasing a pooled widget restores its label, active state and interaction. + +The owning Zoom and Forbidden Tech patch switches control installation independently +of native settings and feature values. Supported callbacks install once; current +label profiles and the FT bypass flag are checked inside them, so live changes do +not require a restart. Windows x64 and macOS use shared adapters; native availability +requires validated metadata and completed hook families. Mac qualification remains +separate from the Windows evidence below. +Confirmation ON means the bypass flag is false. Toggling must never invoke an +upgrade callback by itself. + +The existing TOML writer now registers these additional keys at startup. One +worker serializes changes to the same file, keeping the latest pending intent +**per key**. A 150 ms quiet period coalesces slider motion; normal quit flushes the +pending value without waiting out that delay. F10 retains its existing force-close +cancellation and 500 ms best effort bound. Save failures/conflicts log the affected +section and key and leave the live setting in place. Numeric edits use the TOML +serializer and the same source-preserving edit/reparse/external-edit checks. +Page opens, section folding and native rendering never enqueue saves. + +Collapsible headings reuse the existing category bind/release and page-selection +hooks. A heading click gives the native option panel a filtered `OptionContext[]` +through the existing `BindDataContext(provider, object)` virtual slot. The original page's +children and navigation parent stay intact, including controls omitted from the +visible list. Native rebinding releases hidden widgets and refreshes expanded +ones through the same guarded readers as a normal page visit. Plain headings +remain non-interactive. No new detour, frame polling or persistence owner is added. + +On entry, native navigation establishes the selected page and Back target first; +the same callback then applies the initial collapsed list before returning. If +that presentation bind fails, the adapter attempts to restore the expanded list +so controls remain accessible. Expanding either section reads its current values. + +Exact Windows build261 unwind extents, checked before expanding installation: + +| Native target | RVA | Bytes | +| --- | --- | --- | +| SliderOptionWidget.SetWidgetData | D09C50 | 592 | +| SliderOptionWidget.OnSliderValueChanged | D0A1E0 | 117 | +| SliderOptionWidget.OnAboutToReleaseContext | D09EA0 | 288 | +| NavigationLOD.UpdateLOD | F8ECF0 | 75 | +| NavigationFleetWidget.OnDidBindContext | F7C870 | 335 | +| NavigationFleetWidget.OnAboutToReleaseContext | F7CEA0 | 283 | +| NavigationFleetWidget.OnEnable | F7D8B0 | 344 | +| NavigationFleetWidget.OnDisable | F7DAF0 | 236 | +| MessageBox.Show(context) | 70B5F0 | 81 | +| MessageBox.Show(context, callback) | 70B650 | 257 | +| TextOptionWidget.SetWidgetData | D0A470 | 293 | +| TextOptionWidget.ClearWidgetData | D0A680 | 271 | +| Selectable.DoStateTransition | 47A9650 | 805 | + +These historical measurements exceed the bundled x64 SPUD 24-byte overwrite. +Installation resolves current targets through managed metadata. Measured client SHA256: +`487af4bb9c697c353be9714359a97dddcece5dab872622a6c498a27bbfc44f40`. +This is Windows evidence, not proof of macOS hook fit or native widget behavior. + +## Camera and previews + +Keyboard zoom speed offers 0–1000 in steps of 25 with whole-number labels. Pan +glide offers 0–0.99 in steps of 0.01; it retains the existing pan formula. These +are UI editing ranges, not new TOML constraints. Out-of-range loaded values are +preserved and explained rather than clamped. The shared slider path controls +display precision without writing a loaded value. + +Preview toggles and their existing hotkeys call the same owner, so live state, +readback and saving agree. Locate/Recall labels invert their stored disable +flags. Cargo targets are visible only while auto-open is ON, with their saved +preferences retained while hidden. Hook installation success gates each group. + +## HUD Buttons + +Q's Trials, Field Training, Outposts and Missions each have a collapsed group +with an Auto / Always / Never choice and a summary of the current mode. The +default-enabled `[patches].missionhudtweakshooks` controls the complete HUD hook +family independently of those feature values. Controls are unavailable unless +the family and required Unity lifetime/visibility methods are ready. + +Changes refresh live through native achievements/outpost rules; Missions Auto +restores its tracked native visibility. Weak tracking rechecks destroyed objects, +captures replacement Missions buttons and defers inactive HUD views. Callbacks +read current choices before applying overrides or Auto restoration. Preferences +save through the shared TOML writer; live readback is separate from save success. + +The existing persistence fixture covers all twelve button/mode combinations. +Static Windows 270 overwrite fit and local build/fixture results do not establish +exact-artifact HUD/Haven interaction or supported Mac execution. Those runtime +qualification gaps remain open. + +## Future organization and commands (design notes) + +Use player tasks for navigation and TOML sections as storage references. +Introduce a group only when it gains a working control and explicit apply path. +Changing placement must not change storage identity. Confirmations continue on +the native confirmation page; arbitrary TOML keys are not discovered as controls. + +A future **Restart client** command could support controls that explicitly need +restart. It would perform an ordinary client restart, settle pending saves using +the existing lifecycle, and relaunch through a supported lifecycle owner. Cache +clearing is a separate operation and must not be called by this command. This is +an idea only: the current branch adds neither restart-only controls nor a restart +command. The ownership/relaunch details need their own design before implementation. + +Hotkey editing follows the first real selection and persistence checks. Reuse the +current parser and binding map; add an explicit capture mode with Escape to cancel, +conflict feedback and a deliberate unbind action. Gameplay shortcuts must not fire +while a chord is being captured. Do not serialize display labels as key identities. + +## Runtime gate + +Before promoting the Navigation slice, verify all three choices, Alt+I changes +while visible, Back/reopen, restart persistence, and an external TOML edit conflict. +Bind build receipts to the installed artifact. The existing synthetic navigation +probe is not evidence that a new selection widget or real persistence path works. diff --git a/docs/MOD_SETTINGS_FOUNDATION.md b/docs/MOD_SETTINGS_FOUNDATION.md new file mode 100644 index 000000000..7f45d441e --- /dev/null +++ b/docs/MOD_SETTINGS_FOUNDATION.md @@ -0,0 +1,115 @@ +# Boolean settings foundation and native FC control + +Historical foundation slice: see [the current settings contract](MOD_SETTINGS.md) +and [native adapter ownership](MOD_SETTINGS_NATIVE_ADAPTER.md) for the expanded +system. Prototype counts and proposed budgets below describe this first slice. + +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. + +Weak view records sized from the page plan 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 hooks resolve managed signatures and require distinct target addresses. +Hooks remain inert until installation completes. The native control supports +Windows x64 and macOS; other platforms omit it. + +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_NATIVE_ADAPTER.md b/docs/MOD_SETTINGS_NATIVE_ADAPTER.md new file mode 100644 index 000000000..6a49f1aae --- /dev/null +++ b/docs/MOD_SETTINGS_NATIVE_ADAPTER.md @@ -0,0 +1,50 @@ +# Native settings adapter + +The Windows x64 and macOS adapter owns native contexts, hook installation and temporary +presentation. Feature adapters own live values and persistence. + +## File ownership + +| File under `mods/src/` | Responsibility | +| --- | --- | +| `patches/parts/mod_settings.cc` | Install core value/session hooks, then optional navigation. A navigation failure leaves native confirmation controls available. | +| `settings/native/interop.*` | Managed invocation, temporary roots, weak handles, signature checks and bounded list access. No feature state or hook installation. | +| `settings/native/value_widgets.*` | Boolean, choice and slider metadata; live view records; guarded render/write/readback; confirmation placement and session invalidation. Installs value/session hooks. | +| `settings/native/value_widget_record.h` | Private lifetime record shared with value-widget styling. Navigation queries busy state without borrowing these records. | +| `settings/native/page_navigation.*` | Immutable plan, fresh page construction, navigation/Back, conditional sections, folding, summaries and heading hooks. Coordinates optional widget installation. | +| `settings/native/action_widgets.*` | Command identity, indexed presentation rows, visibility, invocation and release guards. Installs button-widget hooks. The first consumer here is a noninteractive save-failure notice. | +| `settings/native/row_style.*` | Scoped text, tint, arrow and selection-sprite overrides; restore native appearance before reuse. No writes or hook installation. | + +These are internal adapter modules. Metadata accessors support cross-module +signature and hook-overlap checks while retaining lazy resolution. Page plans +are read-only; records and active flags stay with their owners. Each detour is +installed by exactly one module. XMake's existing `src/**.cc` rule builds them. + +## Contracts + +- UI-thread ownership and weak-view lifetime rules remain unchanged. Views never + keep old settings pages or account state alive. +- Requests use the displayed snapshot, verify the owner, apply through it, then + read back. Rendering never authorizes writes. +- Value refreshes defer list rebinding while a value widget is busy. There is no + new update callback, polling, save worker or persistence path. +- Startup metadata checks, overlap checks, activation gates and install + order are preserved. Native support covers Windows x64 and macOS. + Other platforms retain the no-op entry point. +- Disabled slider wording belongs to the feature. Fleet Labels supplies + `Select Threshold`; other controls do not inherit that instruction. See + [the current state contract](MOD_SETTINGS.md). + +The entry point and member are `InstallNativeSettings` and +`Config::installNativeSettings`. The registry entry name `ModConfirmationSettings` +remains stable, with default-enabled `[patches].nativesettingshooks` controlling +installation in every build. Setting IDs, TOML keys and defaults remain stable while +presentation placement evolves. + +## Validation + +Run the settings fixtures and Windows build. Compare moved hooks against the +previous implementation, including original-call behavior and installation order. +Native smoke checks cover confirmation rows, folding/Back, choices, slider labels, +conditional cargo rows and notice appearance/recovery. Builds and fixtures do +not establish native pooling behavior or macOS hook compatibility. diff --git a/docs/MOD_SETTINGS_NAVIGATION.md b/docs/MOD_SETTINGS_NAVIGATION.md new file mode 100644 index 000000000..414c0f00b --- /dev/null +++ b/docs/MOD_SETTINGS_NAVIGATION.md @@ -0,0 +1,73 @@ +# Rebuildable mod settings pages + +This describes the navigation contracts. The current +control placement, conditional rows, summaries and native adapter ownership are +documented in [Mod Settings](MOD_SETTINGS.md). + +This foundation separates presentation placement from a setting's owner. The +intended native path is Settings > Mod Settings > group > setting. Current page membership follows the task layout in `MOD_SETTINGS.md`. 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`, `ChoiceSetting`, `SliderSetting` and `ActionSetting` instances, plus static +headings, in registration order. Multiple independent choices can share a page. +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. + +The shared native adapter supports Windows x64 and macOS and creates boolean, +selection, slider and action rows through validated managed builders. It restores +owned text overrides on category unbind/rebind and page destruction, using scoped +human-text overrides without a global localization hook. Optional category/page, +heading and value hooks install only when their registered controls need them. + +Historical build261 measurements covered four category/page lifecycle methods. +Current Windows client270 static measurements cover those methods and the heading, +action, selection and slider families; every selected SPUD overwrite window fits +its method extent. Those disk measurements do not establish live relocation, +callback lifetime or native presentation. Exact artifact navigation/pooling smoke +and supported Mac native extent/execution evidence remain qualification gates. + +Register through `ModPages()` before settings installation. Current pages are +Camera, Fleet Labels, Map & Travel, Previews & Cargo, and HUD Buttons, as documented in +[Mod Settings](MOD_SETTINGS.md). Instant warp shares Alt+I's owner and persistence. +Fleet Labels places Player and Non-player sections on one page, each with detail +choices and a percentage slider. Native text-only headings use scoped labels and +row tints cleared on refresh/clear. Native confirmation placement stays unchanged. +Selection controls share the typed setting/view guards with booleans and retain +the whole integer value in each row snapshot. Three selection-widget hooks have +verified Windows x64 extents of 146, 355 and 281 bytes. Selection prefabs may put +their toggle on the row itself: an unavailable selection clears its selected +index and disables interaction instead of hiding its label's container. + +The native adapter shares the `ModConfirmationSettings` registry entry, +controlled by default-enabled `[patches].nativesettingshooks` in all builds. +Disabling it skips native UI installation. Stable weak-view storage is sized once +from all registered control rows plus the native confirmation rows, retaining a +minimum of eight slots. There is no fixed maximum of eight rows. Native contexts +own rows and delegates; headings use scoped text records. Heading-only pages are +pruned as empty. + +Persistence stays with explicit feature adapters, independently installed through +`[patches].runtimeconfighooks`. Live changes and their asynchronous save results +are distinct; page construction never writes TOML. One worker registers mode, +fleet-label and FT keys and retains pending changes per setting. + +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..9816beb3d --- /dev/null +++ b/docs/config-save.md @@ -0,0 +1,134 @@ +# 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 private 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. + +Staging is private before writing and after close until replacement preparation: Windows uses +a protected owner/system DACL, and macOS uses mode `0600`. New documents keep +these private permissions. + +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 and dangling final symlinks are +resolved before staging; cyclic links fail without replacing the link. 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 and checks staging permissions before/during writes and after close; +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 and native mod controls change live values +immediately, then ask one worker to persist explicitly registered keys. Opening +a settings page does not submit a write. 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. This branch registers instant warp, Fleet Label +detail modes and thresholds, Camera and Preview/Cargo preferences, and the +Forbidden Tech confirmation bypass. Slider +edits use a 150 ms quiet interval. Each feature owns its key registration, +validation, and choice of delay; shortcut editing follows separately. + +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. +Each key's acknowledged value advances only after success. To reconcile a +conflict, restore its original disk value or restart to load the file. Requesting +a value already present externally also succeeds without a write when the control +can represent that value. 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-resolves the selected path and re-reads its source +after staging, rejecting a changed symlink target or 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. +Incompatible bindings retain session-only changes with a save-failure notice. +The patch registry owns installation through default-enabled `[patches].runtimeconfighooks`. +Persistence installation is independent of hotkeys and native settings. Disabling +the compatibility switch retains live changes for the session only. + +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/docs/research/20260912-settings-selection-appearance.md b/docs/research/20260912-settings-selection-appearance.md new file mode 100644 index 000000000..5930a785d --- /dev/null +++ b/docs/research/20260912-settings-selection-appearance.md @@ -0,0 +1,79 @@ +# Native settings selection appearance + +The build261 `gamesettings` Pre-Bundle contains a `LanguageOption` prefab used +by `SelectionItemOptionWidget`. Its direct children include `BG`, `Label`, +`CheckBox`, and `Arrow`. The widget's Toggle and Animator live on the row itself. +Toggle.graphic is null; the Animator controls the actual checkmark. + +`ImageToggleOn` and `ImageToggleOff` animate three distinct parts: + +| Part | On | Off | +| --- | --- | --- | +| Arrow | Active | Inactive | +| BG.sprite | `SelectedBG_raw` | Native normal background | +| Label font color | Dark | White | + +The clips also animate BG geometry. Both contain no animation events. These +findings came from read-only prefab/clip inspection, not runtime animation sampling. +The controller's selection state is independent of its pointer Button layer. + +The normal BG sprite is shared with the native category, text and slider rows. +Category rows use a direct `Background` child; text/slider/selection rows use `BG`. +This explains why looking for Image on the widget root did not tint the backgrounds. + +For owned choices, override the drawn BG sprite while retaining native selection +animation, geometry, and checkmark activity. Collect the normal and selected +sprites from existing settings widgets using weak handles. If a sprite is not +available yet, leave the native presentation for that state until a bind/input +event supplies it. No bundles are loaded, cloned, modified, or retained by this +adapter. Rich text keeps labels light at rest, dark while pressed, and bold for +the selected value. The checkmark tint follows that contrast treatment. + +Cleanup is guarded against synchronous reentry. Re-enabling a disabled Toggle +calls `Selectable.OnSetProperty` and then `DoStateTransition` immediately; the +transition must not reapply appearance while the row is being detached. All +overrides are restored before releasing widget references. An isolated fixture +compiling the actual `Clear` body reproduced this callback ordering defect before +the guard and passed afterward, including recursive clear. Its native callback +was simulated; pooled-widget behavior still needs the in-game check. + +Pointer feedback uses the existing native input-state transition event. The new +Windows x64 `Selectable.DoStateTransition` hook has an 805-byte PE unwind extent +at RVA `0x47A9650`, exceeding SPUD's 24-byte overwrite. No other mod hook owns +this target. It immediately passes through other control classes and only styles +selection rows tracked by the mod. It does not run through an Update hook. + +Client SHA256: +`487af4bb9c697c353be9714359a97dddcece5dab872622a6c498a27bbfc44f40`. +These measurements cover Windows build261 only and are not proof of macOS +hook fit. The Mac adapter now resolves managed metadata without loaded-image extent +gates. Runtime checks must verify release/drag-out behavior, +shortcut readback, selected checkmarks, and restoration of pooled native widgets. + +## Collapsible section binding + +Read-only build261 disassembly of `GameSettingsViewController.OnCategorySelected` +(`0xD067E0`) shows it sets `SettingsContext.SelectedOption`, retrieves the selected +container's `Children`, and binds the option panel with a null provider and that +IList. `OptionTabPanelWidget.OnDidBindContext` (`0xD08630`) clears/rebuilds its native +list and wires category callbacks. Section clicks reuse this panel bind with a +filtered array, keeping SelectedOption and the original children intact. The +existing page-selection detour handles only registered section categories on the +current page. This adds no new detour target. + +The category prefab has direct `Background` and `Arrow` children. Its Arrow starts +with identity local rotation. Expanded headings rotate that arrow down; release +restores its native rotation and background color before pooling. Runtime evidence +must still verify independent folding, both sections closed, Back/reopen, arrow +orientation, and restoration of ordinary category rows. + +The widget does not have `SetContext`. Build261 metadata identifies the bind call as `Widget.BindDataContext(IDataContextProvider, object)`, +virtual slot 35. The native caller's vtable offset `0x368` matches slot 35 for +this client. Resolve that slot from the non-generic Widget schema through +`il2cpp_object_get_virtual_method`; a name/count-only lookup would be ambiguous +with the typed `Widget.BindDataContext` overload (slot 39). + +Sections start collapsed for each visit and apply the same filtered bind after +native page selection. Runtime checks must cover initial folding, independent +section clicks and Back/reopen defaults; metadata and fixtures cannot establish +native presentation behavior. diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 2069b388a..3ebda8aad 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -669,6 +669,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 @@ -737,34 +739,29 @@ pinned_ships = "" # Subgroup: HUD # ------------- -# HUD visibility mode: auto, show, or hide -# "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item -hud_daily_goals = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_field_training = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_missions = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_outposts = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_q_trials = "auto" # Subgroup: Previews diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index dc626072b..187bf36a8 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -669,6 +669,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 @@ -737,34 +739,29 @@ pinned_ships = "" # Subgroup: HUD # ------------- -# HUD visibility mode: auto, show, or hide -# "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item -hud_daily_goals = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_field_training = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_missions = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_outposts = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_q_trials = "auto" # Subgroup: Vorschauen diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index e1aed8205..ab776c90b 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -669,6 +669,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 @@ -737,34 +739,29 @@ pinned_ships = "" # Subgroup: HUD # ------------- -# HUD visibility mode: auto, show, or hide -# "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item -hud_daily_goals = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_field_training = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_missions = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_outposts = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_q_trials = "auto" # Subgroup: Previews diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 562af2608..a1c33895f 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -669,6 +669,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 @@ -737,34 +739,29 @@ pinned_ships = "" # Subgroup: HUD # ------------- -# HUD visibility mode: auto, show, or hide -# "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item -hud_daily_goals = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_field_training = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_missions = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_outposts = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_q_trials = "auto" # Subgroup: Previews diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 8dcc031a6..7fa355f59 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -263,6 +263,10 @@ loadingtiphooks = true doubleclickassignshiphooks = true forbiddentechconfirmhooks = true audioeventhooks = true +# Install native Mod Settings hooks; disable only for compatibility troubleshooting. +nativesettingshooks = true +# Installs runtime config persistence independently of keyboard/native settings features. +runtimeconfighooks = true freeresizehooks = true game_version = true giftsbulkclaimhooks = true @@ -680,6 +684,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 @@ -757,34 +763,29 @@ pinned_ships = "" # Subgroup: HUD # ------------- -# HUD visibility mode: auto, show, or hide -# "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item -hud_daily_goals = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_field_training = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_missions = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_outposts = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_q_trials = "auto" # Subgroup: Previews diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 795c8f1c8..e48ba5e28 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -669,6 +669,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 @@ -737,34 +739,29 @@ pinned_ships = "" # Subgroup: HUD # ------------- -# HUD visibility mode: auto, show, or hide -# "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item -hud_daily_goals = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_field_training = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_missions = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_outposts = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_q_trials = "auto" # Subgroup: Previews diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 8bf856ea3..813cff6cf 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -669,6 +669,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 @@ -737,34 +739,29 @@ pinned_ships = "" # Subgroup: HUD # ------------- -# HUD visibility mode: auto, show, or hide -# "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item -hud_daily_goals = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_field_training = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_missions = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_outposts = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_q_trials = "auto" # Subgroup: Previews diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 719b54a1d..0ba32e86a 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -669,6 +669,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 @@ -737,34 +739,29 @@ pinned_ships = "" # Subgroup: HUD # ------------- -# HUD visibility mode: auto, show, or hide -# "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item -hud_daily_goals = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_field_training = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_missions = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_outposts = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_q_trials = "auto" # Subgroup: Previews diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 6e1d0ca2a..8c75b9c25 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -669,6 +669,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 @@ -737,34 +739,29 @@ pinned_ships = "" # Subgroup: HUD # ------------- -# HUD visibility mode: auto, show, or hide -# "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item -hud_daily_goals = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_field_training = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_missions = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_outposts = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_q_trials = "auto" # Subgroup: Previews diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index ca88ac0b1..fec963677 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -669,6 +669,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 @@ -737,34 +739,29 @@ pinned_ships = "" # Subgroup: HUD # ------------- -# HUD visibility mode: auto, show, or hide -# "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item -hud_daily_goals = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_field_training = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_missions = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_outposts = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_q_trials = "auto" # Subgroup: Previews diff --git a/mods/src/config.cc b/mods/src/config.cc index b69377fbb..6b0b40cd6 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() @@ -923,6 +924,8 @@ void Config::Load() get_config_or_default(config, parsed, "patches", "doubleclickassignshiphooks", DCP::doubleclickassignshiphooks, write_config); this->installForbiddenTechConfirmationHooks = get_config_or_default(config, parsed, "patches", "forbiddentechconfirmhooks", DCP::forbiddentechconfirmhooks, write_config); + this->installRuntimeConfigHooks = + get_config_or_default(config, parsed, "patches", "runtimeconfighooks", DCP::runtimeconfighooks, write_config); this->installAudioEventHooks = get_config_or_default(config, parsed, "patches", "audioeventhooks", DCP::audioeventhooks, write_config); this->installInstantCargoCounterHooks = @@ -1046,6 +1049,9 @@ 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->installNativeSettings = + get_config_or_default(config, parsed, "patches", "nativesettingshooks", DCP::nativesettingshooks, write_config); 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, @@ -1109,9 +1115,6 @@ void Config::Load() write_config)); this->mission_hud_buttons.emplace( "outposts", get_mission_hud_visibility(config, parsed, "hud_outposts", DCU::hud_outposts, write_config)); - this->mission_hud_buttons.emplace( - "daily_goals", - get_mission_hud_visibility(config, parsed, "hud_daily_goals", DCU::hud_daily_goals, write_config)); this->mission_hud_buttons.emplace( "missions", get_mission_hud_visibility(config, parsed, "hud_missions", DCU::hud_missions, write_config)); spdlog::debug(""); @@ -1403,9 +1406,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()); @@ -1418,7 +1428,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 2ab320ce8..273f9caf6 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -278,6 +278,8 @@ class Config final bool installForbiddenTechConfirmationHooks; bool installInstantWarpConfirmationHooks; bool installAudioEventHooks; + bool installNativeSettings; + bool installRuntimeConfigHooks; 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..e7e8685ad --- /dev/null +++ b/mods/src/config_save.cc @@ -0,0 +1,181 @@ +#include "config_save.h" + +#include +#include +#include +#include +#include +#include +#include + +#if _WIN32 +#include +#include +#include +#include +#pragma comment(lib, "advapi32.lib") +#else +#include +#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 + +namespace +{ +std::filesystem::path ResolveConfigDestination(const std::filesystem::path& path) +{ + // Follow dangling final links too, preserving the former ofstream behavior. + auto destination = std::filesystem::weakly_canonical(path); + unsigned links = 0; + while (std::filesystem::is_symlink(std::filesystem::symlink_status(destination))) { + if (++links > 40) + throw std::filesystem::filesystem_error("config symlink cycle", path, + std::make_error_code(std::errc::too_many_symbolic_link_levels)); + auto target = std::filesystem::read_symlink(destination); + destination = std::filesystem::weakly_canonical(target.is_absolute() ? target : destination.parent_path() / target); + } + return destination; +} +} // namespace + +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); + + const auto destination = ResolveConfigDestination(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)); + + // Configs may contain tokens. Protect staging at creation, before any bytes, + // even if the destination is private beneath a more permissive directory. + std::FILE* file = nullptr; +#if _WIN32 + PSECURITY_DESCRIPTOR security = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"D:P(A;;FA;;;OW)(A;;FA;;;SY)", SDDL_REVISION_1, &security, nullptr)) + throw std::system_error(GetLastError(), std::system_category(), "could not protect temporary config file"); + SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), security, FALSE}; + const auto handle = CreateFileW(temporary.c_str(), GENERIC_WRITE | READ_CONTROL, 0, &attributes, + CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr); + const auto create_error = GetLastError(); + LocalFree(security); + if (handle == INVALID_HANDLE_VALUE) + throw std::system_error(create_error, std::system_category(), "could not create temporary config file"); + const auto descriptor = _open_osfhandle(reinterpret_cast(handle), _O_WRONLY | _O_BINARY); + if (descriptor == -1) { + CloseHandle(handle); + } else { + file = _fdopen(descriptor, "wb"); + if (!file) + _close(descriptor); + } +#else + const auto descriptor = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0600); + if (descriptor == -1) + throw std::system_error(errno, std::generic_category(), "could not create temporary config file"); + file = ::fdopen(descriptor, "wb"); + if (!file) + ::close(descriptor); +#endif + if (!file) { + const auto error = errno; + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + throw std::system_error(error, std::generic_category(), "could not open temporary config stream"); + } + + 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 && (ResolveConfigDestination(path) != destination || ReadConfigText(destination) != *expected)) { + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + return false; + } +#if _WIN32 + // Let Windows retain the existing file's permissions and streams. A backup + // protects the old contents in ReplaceFile's documented partial-failure cases. + auto backup = temporary; + backup += ".bak"; + if (!ReplaceFileW(destination.c_str(), temporary.c_str(), backup.c_str(), 0, nullptr, nullptr)) { + auto error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND) { + // Missing-destination fallback: do not overwrite a file appearing before + // this move. The caller's earlier existence check is not a create-only transaction. + error = MoveFileExW(temporary.c_str(), destination.c_str(), 0) ? ERROR_SUCCESS : GetLastError(); + } + if (error != ERROR_SUCCESS) { + replacing = error == ERROR_UNABLE_TO_MOVE_REPLACEMENT || error == ERROR_UNABLE_TO_MOVE_REPLACEMENT_2; + throw std::filesystem::filesystem_error( + replacing ? "config replacement failed; retain temporary/backup for recovery" : "config replacement failed", + temporary, destination, std::error_code(error, std::system_category())); + } + } + std::error_code ignored; + std::filesystem::remove(backup, ignored); +#else + // Preserve ordinary permission bits when replacing an existing config. + if (std::filesystem::exists(destination)) { + std::filesystem::permissions(temporary, std::filesystem::status(destination).permissions()); + } + std::filesystem::rename(temporary, destination); +#endif + } catch (...) { + if (file) { + std::fclose(file); + } + std::error_code ignored; + if (!replacing) { + std::filesystem::remove(temporary, ignored); + } + throw; + } + return true; +} diff --git a/mods/src/config_save.h b/mods/src/config_save.h new file mode 100644 index 000000000..8e87aa8a3 --- /dev/null +++ b/mods/src/config_save.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Synchronous whole-document output for startup, not a runtime setting editor. +// Throws on failure; the caller owns reporting. Does not guarantee power-loss durability. +void SaveConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header = {}); + +std::string ReadConfigText(const std::filesystem::path& path); +// Preserves the supplied text. With expected text, false means an external edit +// was detected before replacement. This is not a filesystem compare-and-swap. +bool ReplaceConfigText(const std::filesystem::path& path, std::string_view text, + std::optional expected = std::nullopt); diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 10764b99f..48b494807 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -94,6 +94,8 @@ namespace Patches constexpr bool doubleclickassignshiphooks = true; constexpr bool forbiddentechconfirmhooks = true; constexpr bool audioeventhooks = true; + constexpr bool nativesettingshooks = true; + constexpr bool runtimeconfighooks = true; constexpr bool instantcargocounterhooks = true; constexpr bool cargoformathooks = true; // on by default: cargo number precision override constexpr bool officersorthooks = true; // restore Below Deck Ability sort option @@ -264,7 +266,6 @@ namespace UI constexpr bool cargo_format = true; constexpr bool officer_sort = true; constexpr const char* disabled_banner_types = ""; - constexpr const char* hud_daily_goals = "auto"; constexpr const char* hud_field_training = "auto"; constexpr const char* hud_missions = "auto"; constexpr const char* hud_outposts = "auto"; 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/mission_hud.h b/mods/src/patches/mission_hud.h new file mode 100644 index 000000000..8aed09d07 --- /dev/null +++ b/mods/src/patches/mission_hud.h @@ -0,0 +1,8 @@ +#pragma once +namespace mission_hud +{ +bool Available(); +bool CanChange(); +// Game-thread only. Reevaluate native visibility and apply current preferences. +void Refresh(); +} 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/fix_pan.cc b/mods/src/patches/parts/fix_pan.cc index 174e9da7d..8c2765846 100644 --- a/mods/src/patches/parts/fix_pan.cc +++ b/mods/src/patches/parts/fix_pan.cc @@ -1,5 +1,6 @@ #include "config.h" #include "errormsg.h" +#include "settings/camera_settings.h" #include @@ -14,6 +15,14 @@ #include #include +namespace +{ +bool pan_glide_hook_installed = false; +} + +bool mod_settings::PanGlideControlAvailable() +{ return pan_glide_hook_installed; } + TKTouch *TKTouch_populateWithPosition_Hook(auto original, TKTouch *_this, uintptr_t pos, TouchPhase phase) { auto r = original(_this, pos, phase); @@ -127,7 +136,7 @@ void InstallPanHooks() if (const auto ptr = navHelper.GetMethod("LateUpdate"); ptr == nullptr) { ErrorMsg::MissingMethod("NavigationPan", "LateUpdate"); } else { - SPUD_STATIC_DETOUR(ptr, NavigationPan_LateUpdate_Hook); + pan_glide_hook_installed = SPUD_STATIC_DETOUR(ptr, NavigationPan_LateUpdate_Hook); } } } diff --git a/mods/src/patches/parts/forbidden_tech_confirmation.cc b/mods/src/patches/parts/forbidden_tech_confirmation.cc index fdd617f26..64472ec40 100644 --- a/mods/src/patches/parts/forbidden_tech_confirmation.cc +++ b/mods/src/patches/parts/forbidden_tech_confirmation.cc @@ -1,9 +1,11 @@ -#include "errormsg.h" #include "config.h" +#include "errormsg.h" +#include "settings/forbidden_tech.h" #include -#include +#include #include +#include #include @@ -12,6 +14,8 @@ namespace constexpr int32_t ConfirmButtonResult = 1; const FieldInfo* on_selection_field = nullptr; +bool installed = false; +bool attempted = false; bool IsForbiddenTechConfirmation(const Il2CppDelegate* callback) { @@ -22,13 +26,13 @@ bool IsForbiddenTechConfirmation(const Il2CppDelegate* callback) const auto* target_class = callback->target->klass; const auto* owner_class = target_class == nullptr ? nullptr : target_class->declaringType; - return owner_class != nullptr && strcmp(owner_class->name, "ForbiddenTechManager") == 0 && - strcmp(callback->method->name, "b__0") == 0; + return owner_class != nullptr && strcmp(owner_class->name, "ForbiddenTechManager") == 0 + && strcmp(callback->method->name, "b__0") == 0; } bool ConfirmForbiddenTechUpgrade(Il2CppDelegate* callback) { - if (!Config::Get().auto_confirm_ft_upgrade || !IsForbiddenTechConfirmation(callback)) { + if (!installed || !Config::Get().auto_confirm_ft_upgrade || !IsForbiddenTechConfirmation(callback)) { return false; } @@ -69,8 +73,14 @@ void MessageBox_ShowWithCallback_Hook(auto original, void* context, Il2CppDelega } } // namespace +bool ForbiddenTechControlsAvailable() +{ return installed; } + void InstallForbiddenTechConfirmationHooks() { + if (attempted) + return; + attempted = true; auto message_box_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "MessageBox"); if (!message_box_helper.isValidHelper()) { ErrorMsg::MissingHelper("Digit.Client.UI", "MessageBox"); @@ -84,22 +94,40 @@ void InstallForbiddenTechConfirmationHooks() } on_selection_field = il2cpp_class_get_field_from_name(context_helper.get_cls(), "OnSelection"); - if (on_selection_field == nullptr) { + if (on_selection_field == nullptr || (on_selection_field->type->attrs & FIELD_ATTRIBUTE_STATIC) + || on_selection_field->type->byref + || (on_selection_field->type->type != IL2CPP_TYPE_CLASS + && on_selection_field->type->type != IL2CPP_TYPE_GENERICINST)) { spdlog::error("Unable to find field 'MessageBoxContext->OnSelection'"); return; } - const auto show = message_box_helper.GetMethod("Show", 1); - if (show == nullptr) { + const auto* show = message_box_helper.GetMethodInfo("Show", 1); + const auto* show_with_callback = message_box_helper.GetMethodInfo("Show", 2); + if (!show || !show->methodPointer || !show_with_callback || !show_with_callback->methodPointer) { ErrorMsg::MissingMethod("MessageBox", "Show"); - } else { - SPUD_STATIC_DETOUR(show, MessageBox_Show_Hook); + return; } - - const auto show_with_callback = message_box_helper.GetMethod("Show", 2); - if (show_with_callback == nullptr) { - ErrorMsg::MissingMethod("MessageBox", "Show"); - } else { - SPUD_STATIC_DETOUR(show_with_callback, MessageBox_ShowWithCallback_Hook); +#if defined(_WIN32) && defined(_M_X64) + for (auto* method : {show, show_with_callback}) { + bool valid = (method->flags & METHOD_ATTRIBUTE_STATIC) && !method->is_generic && !method->is_inflated + && !method->has_full_generic_sharing_signature && method->return_type + && method->return_type->type == IL2CPP_TYPE_VOID && !method->return_type->byref + && method->methodPointer; + for (int i = 0; valid && i < method->parameters_count; ++i) + valid = method->parameters[i] && !method->parameters[i]->byref + && (method->parameters[i]->type == IL2CPP_TYPE_CLASS + || method->parameters[i]->type == IL2CPP_TYPE_GENERICINST); + if (!valid || show->methodPointer == show_with_callback->methodPointer) { + spdlog::warn("Forbidden Tech confirmation unavailable: hook metadata"); + return; + } + } +#endif + if (!SPUD_STATIC_DETOUR(show->methodPointer, MessageBox_Show_Hook) + || !SPUD_STATIC_DETOUR(show_with_callback->methodPointer, MessageBox_ShowWithCallback_Hook)) { + spdlog::warn("Forbidden Tech confirmation unavailable: hook installation"); + return; } + installed = true; } diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index fb3129d63..0bdb029c6 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -1,4 +1,7 @@ #include "config.h" +#include "patches/runtime_config.h" +#include "settings/preview_settings.h" +#include "settings/warp_mode.h" #include @@ -68,8 +71,15 @@ #include #endif -static bool reset_focus_next_frame = false; -static int show_info_pending = 0; +static bool reset_focus_next_frame = false; +static int show_info_pending = 0; +static bool preview_shortcuts_ready = false; +static bool cargo_bind_ready = false, cargo_scan_ready = false; + +bool mod_settings::PreviewShortcutsAvailable() +{ return preview_shortcuts_ready; } +bool mod_settings::CargoPreviewsAvailable() +{ return cargo_bind_ready && cargo_scan_ready; } static int show_officer_presets_pending = 0; static int show_officer_swap_pending = 0; static int show_ship_swap_pending = 0; @@ -328,27 +338,6 @@ void GotoSection(SectionID sectionID, void* screen_data = nullptr); bool CanHideViewers(); bool DidHideViewers(); -void CycleAutoConfirmInstantWarp(Config& config) -{ - const char* state = nullptr; - switch (config.auto_confirm_instant_warp) { - case InstantWarpConfirmation::None: - config.auto_confirm_instant_warp = InstantWarpConfirmation::Warp; - state = "warp"; - break; - case InstantWarpConfirmation::Warp: - config.auto_confirm_instant_warp = InstantWarpConfirmation::Jump; - state = "jump"; - break; - case InstantWarpConfirmation::Jump: - config.auto_confirm_instant_warp = InstantWarpConfirmation::None; - state = "none"; - break; - } - - spdlog::info("Auto-confirm instant warp set to {}", state); -} - bool MoveOfficerCanvas(bool goLeft) { auto selectors = ObjectFinder::GetAll(); @@ -676,7 +665,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)) { @@ -766,6 +756,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) { @@ -980,21 +971,21 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) } else if (MapKey::IsPressed(GameFunction::UiViewerScaleDown)) { config->AdjustUiViewerScale(false); } else if (MapKey::IsDown(GameFunction::ToggleAutoConfirmInstantWarp)) { - CycleAutoConfirmInstantWarp(*config); + mod_settings::CycleWarpMode(); } else if (MapKey::IsDown(GameFunction::TogglePreviewLocate)) { - config->disable_preview_locate = !config->disable_preview_locate; + mod_settings::TogglePreviewSetting(mod_settings::PreviewOption::Locate); } else if (MapKey::IsDown(GameFunction::TogglePreviewRecall)) { - config->disable_preview_recall = !config->disable_preview_recall; + mod_settings::TogglePreviewSetting(mod_settings::PreviewOption::Recall); } else if (MapKey::IsDown(GameFunction::ToggleCargoDefault)) { - config->show_cargo_default = !config->show_cargo_default; + mod_settings::TogglePreviewSetting(mod_settings::PreviewOption::Cargo); } else if (MapKey::IsDown(GameFunction::ToggleCargoPlayer)) { - config->show_player_cargo = !config->show_player_cargo; + mod_settings::TogglePreviewSetting(mod_settings::PreviewOption::PlayerCargo); } else if (MapKey::IsDown(GameFunction::ToggleCargoStation)) { - config->show_station_cargo = !config->show_station_cargo; + mod_settings::TogglePreviewSetting(mod_settings::PreviewOption::StationCargo); } else if (MapKey::IsDown(GameFunction::ToggleCargoHostile)) { - config->show_hostile_cargo = !config->show_hostile_cargo; + mod_settings::TogglePreviewSetting(mod_settings::PreviewOption::HostileCargo); } else if (MapKey::IsDown(GameFunction::ToggleCargoArmada)) { - config->show_armada_cargo = !config->show_armada_cargo; + mod_settings::TogglePreviewSetting(mod_settings::PreviewOption::ArmadaCargo); } else if (MapKey::IsDown(GameFunction::LogLevelOff)) { // spdlog::log("Setting log level to OFF"); spdlog::set_level(spdlog::level::off); @@ -1584,9 +1575,8 @@ bool install_screen_manager_update_hook() if (!helper.isValidHelper()) { ErrorMsg::MissingHelper("UI", "ScreenManager"); } else if (auto update = helper.GetMethod("Update"); update) { - SPUD_STATIC_DETOUR(update, ScreenManager_Update_Hook); - installed = true; - return true; + installed = SPUD_STATIC_DETOUR(update, ScreenManager_Update_Hook); + return installed; } else { ErrorMsg::MissingMethod("ScreenManager", "Update"); } @@ -1737,7 +1727,7 @@ void InstallHotkeyHooks() InstallShortcutHintHooks(); - install_screen_manager_update_hook(); + preview_shortcuts_ready = install_screen_manager_update_hook(); #ifdef _MODDBG fleet_watch::InstallRuntimeProbe(); #endif @@ -1752,7 +1742,7 @@ void InstallHotkeyHooks() if (on_did_bind_context_ptr == nullptr) { ErrorMsg::MissingMethod("RewardsButtonWidget", "OnDidBindContext"); } else { - SPUD_STATIC_DETOUR(on_did_bind_context_ptr, OnDidBindContext_Hook); + cargo_bind_ready = SPUD_STATIC_DETOUR(on_did_bind_context_ptr, OnDidBindContext_Hook); } } @@ -1766,7 +1756,7 @@ void InstallHotkeyHooks() if (show_with_fleet_ptr == nullptr) { ErrorMsg::MissingMethod("PreScanTargetWidget", "ShowWithFleet"); } else { - SPUD_STATIC_DETOUR(show_with_fleet_ptr, ShowWithFleet_Hook); + cargo_scan_ready = SPUD_STATIC_DETOUR(show_with_fleet_ptr, ShowWithFleet_Hook); } } } diff --git a/mods/src/patches/parts/mission_hud_tweaks.cc b/mods/src/patches/parts/mission_hud_tweaks.cc index 512890be8..5cffb4877 100644 --- a/mods/src/patches/parts/mission_hud_tweaks.cc +++ b/mods/src/patches/parts/mission_hud_tweaks.cc @@ -1,7 +1,9 @@ #include "config.h" #include "errormsg.h" +#include "patches/mission_hud.h" #include +#include #include #include @@ -26,11 +28,10 @@ struct MissionHudButtonDefinition { using ComponentGetGameObjectFn = void* (*)(void*); using GameObjectSetActiveFn = void (*)(void*, bool); -std::array g_button_definitions{{ +std::array g_button_definitions{{ {"q_trials", "_challengesButton"}, {"field_training", "_achievementsButton"}, {"outposts", "_outpostsButton"}, - {"daily_goals", "_dailyGoalsButton"}, {"missions", "_missionsButton"}, }}; @@ -38,6 +39,74 @@ std::vector g_configured_buttons; ComponentGetGameObjectFn g_get_game_object = nullptr; GameObjectSetActiveFn g_set_active = nullptr; +using ObjectAliveFn = bool (*)(void*); +using ActiveSelfFn = bool (*)(void*); +using RefreshFn = void (*)(void*); +ObjectAliveFn g_alive = nullptr; +ActiveSelfFn g_active_self = nullptr; +ActiveSelfFn g_enabled = nullptr; +RefreshFn g_refresh_achievements = nullptr; +RefreshFn g_refresh_outposts = nullptr; +bool g_available = false; +bool g_refreshing = false; +struct HudInstance { + Il2CppGCHandle controller = nullptr; + Il2CppGCHandle missions = nullptr; + bool missions_default = true; + bool missions_overridden = false; +}; +std::vector g_instances; + +bool Alive(void* object) { return object && g_alive && g_alive(object); } +void* ButtonObject(void* controller, const MissionHudButtonDefinition& button) +{ + if (!Alive(controller) || !button.field_valid) return nullptr; + auto* component = *reinterpret_cast(reinterpret_cast(controller) + button.field_offset); + return Alive(component) ? g_get_game_object(component) : nullptr; +} +bool Track(void* controller) +{ + if (!Alive(controller)) return false; + if (g_refreshing) return true; + for (auto it = g_instances.begin(); it != g_instances.end();) { + auto* target = il2cpp_gchandle_get_target(it->controller); + if (!Alive(target)) { + il2cpp_gchandle_free(it->controller); + if (it->missions) il2cpp_gchandle_free(it->missions); + it = g_instances.erase(it); + } else { + if (target == controller) { + auto* missions = ButtonObject(controller, g_button_definitions[3]); + if (!Alive(missions)) return false; + if (il2cpp_gchandle_get_target(it->missions) != missions) { + const auto replacement = il2cpp_gchandle_new_weakref(static_cast(missions), false); + if (!replacement) return false; + il2cpp_gchandle_free(it->missions); + it->missions = replacement; + it->missions_overridden = false; + it->missions_default = g_active_self(missions); + } else if (!it->missions_overridden) { + it->missions_default = g_active_self(missions); + } + return true; + } + ++it; + } + } + auto* missions = ButtonObject(controller, g_button_definitions[3]); + if (!Alive(missions)) return false; + const auto owner = il2cpp_gchandle_new_weakref(static_cast(controller), false); + const auto button = il2cpp_gchandle_new_weakref(static_cast(missions), false); + if (owner && button) { + g_instances.push_back({owner, button, g_active_self(missions)}); + return true; + } else { + if (owner) il2cpp_gchandle_free(owner); + if (button) il2cpp_gchandle_free(button); + } + return false; +} + std::string_view to_string(MissionHudVisibility visibility) { switch (visibility) { @@ -78,12 +147,20 @@ std::string ConfiguredButtonModes() return modes; } -bool ResolveButtonFields(IL2CppClassHelper& controller_helper) +bool ResolveButtonFields(IL2CppClassHelper& controller_helper, Il2CppClass* component_class) { auto valid_count = 0; - for (auto* button : g_configured_buttons) { + for (auto& definition : g_button_definitions) { + auto* button = &definition; auto field = controller_helper.GetField(button->field_name); - if (!field.isValidHelper()) { + auto* info = field.get_info(); + const auto* type = info ? info->type : nullptr; + auto* field_class = type ? il2cpp_class_from_type(type) : nullptr; + if (!info || !type || type->byref || (il2cpp_field_get_flags(info) & FIELD_ATTRIBUTE_STATIC) + || (type->type != IL2CPP_TYPE_CLASS && type->type != IL2CPP_TYPE_GENERICINST) + || !field_class || !il2cpp_class_is_assignable_from(component_class, field_class) + || info->offset < sizeof(Il2CppObject) + || info->offset + sizeof(void*) > il2cpp_class_instance_size(controller_helper.get_cls())) { spdlog::error("MissionHudTweaks: unable to find MissionsHudViewController field '{}'", button->field_name); continue; } @@ -94,38 +171,77 @@ bool ResolveButtonFields(IL2CppClassHelper& controller_helper) spdlog::info("MissionHudTweaks: mapped {} -> {}", button->canonical_name, button->field_name); } - return valid_count > 0; + return valid_count == g_button_definitions.size(); } void ApplyButtonVisibility(void* controller, const MissionHudButtonDefinition& button) { - if (!controller || !button.field_valid || !g_get_game_object || !g_set_active) { - return; + if (!g_available || button.visibility == MissionHudVisibility::Auto) return; + if (auto* game_object = ButtonObject(controller, button); Alive(game_object)) { + const bool desired = button.visibility == MissionHudVisibility::Always; + if (g_active_self(game_object) != desired) g_set_active(game_object, desired); } +} +} // namespace - auto* component = *reinterpret_cast(reinterpret_cast(controller) + button.field_offset); - if (!component) { - return; +// Visibility is now refreshed through several independent paths. Keep the game's +// setup/notification work, then apply only explicitly configured overrides. +void ApplyConfiguredButtonVisibility(void* controller) +{ + if (!g_available) return; + for (auto& button : g_button_definitions) + button.visibility = Config::Get().MissionHudButtonVisibility(button.canonical_name); + if (!Track(controller)) return; + for (auto& instance : g_instances) { + if (il2cpp_gchandle_get_target(instance.controller) != controller) continue; + if (g_button_definitions[3].visibility == MissionHudVisibility::Auto) { + if (instance.missions_overridden) { + auto* missions = il2cpp_gchandle_get_target(instance.missions); + const bool baseline = instance.missions_default; + instance.missions_overridden = false; + if (Alive(missions) && ButtonObject(controller, g_button_definitions[3]) == missions + && g_active_self(missions) != baseline) g_set_active(missions, baseline); + } + } else { + instance.missions_overridden = true; + } + break; } - - if (auto* game_object = g_get_game_object(component)) { - g_set_active(game_object, button.visibility == MissionHudVisibility::Always); + for (const auto* button : g_configured_buttons) { + ApplyButtonVisibility(controller, *button); } } -} // namespace -int32_t MissionsHudViewController_UpdateButtons_Hook(auto original, void* controller) +void MissionsHudViewController_OnEnable_Hook(auto original, void* controller) { - const auto result = original(controller); + original(controller); + ApplyConfiguredButtonVisibility(controller); +} - for (auto* button : g_configured_buttons) { - button->visibility = Config::Get().MissionHudButtonVisibility(button->canonical_name); - if (button->visibility != MissionHudVisibility::Auto) { - ApplyButtonVisibility(controller, *button); - } - } +void MissionsHudViewController_SetupAchievementsButton_Hook(auto original, void* controller) +{ + original(controller); + ApplyConfiguredButtonVisibility(controller); +} + +void MissionsHudViewController_SetupChallengesButton_Hook(auto original, void* controller, bool replace_with_outposts) +{ + original(controller, replace_with_outposts); + ApplyConfiguredButtonVisibility(controller); +} + +void MissionsHudViewController_SetupOutpostsButton_Hook(auto original, void* controller, bool show_outposts) +{ + original(controller, show_outposts); + ApplyConfiguredButtonVisibility(controller); +} - return result; +void MissionsHudViewController_HandleOutpostsAndChallengesHUD_Hook(auto original, void* controller) +{ + // In planetary view this method hides challenges directly, bypassing its setup + // method. Reapply after the whole operation, including the planetary branch. + original(controller); + ApplyConfiguredButtonVisibility(controller); } void InstallMissionHudTweaksHooks() @@ -138,17 +254,17 @@ void InstallMissionHudTweaksHooks() return; } - if (!ResolveButtonFields(controller_helper)) { - return; - } - auto component_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Component"); if (!component_helper.isValidHelper()) { ErrorMsg::MissingHelper("UnityEngine", "Component"); return; } - g_get_game_object = reinterpret_cast(component_helper.GetMethod("get_gameObject")); + if (!ResolveButtonFields(controller_helper, component_helper.get_cls())) + return; + const auto* get_game_object = method_contract::Resolve( + component_helper.get_cls(), "get_gameObject", false, "UnityEngine.GameObject", {}); + g_get_game_object = reinterpret_cast(method_contract::Pointer(get_game_object)); if (!g_get_game_object) { ErrorMsg::MissingMethod("Component", "get_gameObject"); return; @@ -160,22 +276,108 @@ void InstallMissionHudTweaksHooks() return; } - g_set_active = reinterpret_cast(game_object_helper.GetMethod("SetActive", 1)); + const auto* set_active = method_contract::Resolve( + game_object_helper.get_cls(), "SetActive", false, "System.Void", {"System.Boolean"}); + g_set_active = reinterpret_cast(method_contract::Pointer(set_active)); if (!g_set_active) { ErrorMsg::MissingMethod("GameObject", "SetActive"); return; } - auto update_buttons = controller_helper.GetMethod("UpdateButtons", 0); - if (!update_buttons) { - ErrorMsg::MissingMethod("MissionsHudViewController", "UpdateButtons"); + auto object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Object"); + const auto* alive = method_contract::Resolve( + object_helper.get_cls(), "op_Implicit", true, "System.Boolean", {"UnityEngine.Object"}); + const auto* active_self = method_contract::Resolve( + game_object_helper.get_cls(), "get_activeSelf", false, "System.Boolean", {}); + g_alive = reinterpret_cast(method_contract::Pointer(alive)); + g_active_self = reinterpret_cast(method_contract::Pointer(active_self)); + auto behaviour_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Behaviour"); + const auto* is_enabled = method_contract::Resolve( + behaviour_helper.get_cls(), "get_isActiveAndEnabled", false, "System.Boolean", {}); + g_enabled = reinterpret_cast(method_contract::Pointer(is_enabled)); + if (!g_alive || !g_active_self || !g_enabled) { + spdlog::error("MissionHudTweaks: Unity object lifetime/visibility methods missing"); + return; + } + + // Resolve the complete surface before installing anything. Avoid the tiny + // planetary/outpost event wrappers: these substantive methods own the work. + const auto* on_enable = method_contract::Resolve( + controller_helper.get_cls(), "OnEnable", false, "System.Void", {}); + const auto* achievements = method_contract::Resolve( + controller_helper.get_cls(), "SetupAchievementsButton", false, "System.Void", {}); + const auto* challenges = method_contract::Resolve( + controller_helper.get_cls(), "SetupChallengesButton", false, "System.Void", {"System.Boolean"}); + const auto* outposts = method_contract::Resolve( + controller_helper.get_cls(), "SetupOutpostsButton", false, "System.Void", {"System.Boolean"}); + const auto* combined = method_contract::Resolve( + controller_helper.get_cls(), "HandleOutpostsAndChallengesHUD", false, "System.Void", {}); + if (!on_enable || !achievements || !challenges || !outposts || !combined) { + spdlog::error("MissionHudTweaks: current HUD lifecycle/setup methods are missing; overrides disabled"); + return; + } + + const std::array targets{on_enable, achievements, challenges, outposts, combined}; + for (std::size_t i = 0; i < targets.size(); ++i) { + if (targets[i]->has_full_generic_sharing_signature) + return; + for (std::size_t j = 0; j < i; ++j) + if (targets[i]->methodPointer == targets[j]->methodPointer) { + spdlog::error("MissionHudTweaks: lifecycle/setup targets overlap; overrides disabled"); + return; + } + } + if (get_game_object->has_full_generic_sharing_signature || set_active->has_full_generic_sharing_signature + || alive->has_full_generic_sharing_signature || active_self->has_full_generic_sharing_signature + || is_enabled->has_full_generic_sharing_signature) return; + g_refresh_achievements = reinterpret_cast(achievements->methodPointer); + g_refresh_outposts = reinterpret_cast(combined->methodPointer); + const auto modes = ConfiguredButtonModes(); + if (!modes.empty()) + spdlog::info("MissionHudTweaks: applying {}", modes); + const bool enabled = SPUD_STATIC_DETOUR(on_enable->methodPointer, MissionsHudViewController_OnEnable_Hook); + const bool achievement_hook = SPUD_STATIC_DETOUR(achievements->methodPointer, MissionsHudViewController_SetupAchievementsButton_Hook); + const bool challenge_hook = SPUD_STATIC_DETOUR(challenges->methodPointer, MissionsHudViewController_SetupChallengesButton_Hook); + const bool outpost_hook = SPUD_STATIC_DETOUR(outposts->methodPointer, MissionsHudViewController_SetupOutpostsButton_Hook); + const bool combined_hook = SPUD_STATIC_DETOUR(combined->methodPointer, MissionsHudViewController_HandleOutpostsAndChallengesHUD_Hook); + g_available = enabled && achievement_hook && challenge_hook && outpost_hook && combined_hook; + if (g_available) { + spdlog::info("MissionHudTweaks: installed current HUD lifecycle/setup hooks"); + } else { + // Successfully installed detours still call through, but must not partially + // enforce preferences when another refresh path could undo them. + g_configured_buttons.clear(); + spdlog::error("MissionHudTweaks: hook installation incomplete; overrides disabled"); } +} - const auto configured_modes = ConfiguredButtonModes(); - if (!configured_modes.empty()) { - spdlog::info("MissionHudTweaks: applying {}", configured_modes); +namespace mission_hud +{ +bool Available() { return g_available; } +bool CanChange() { return g_available && !g_refreshing; } +void Refresh() +{ + if (!g_available || g_refreshing) return; + g_configured_buttons = LoadConfiguredButtons(); + struct Guard { + Guard() { g_refreshing = true; } + ~Guard() { g_refreshing = false; } + } guard; + // Weak handles are not ownership. Recheck Unity's native lifetime after every + // managed call; refresh callbacks cannot modify the tracking collection. + for (const auto& instance : g_instances) { + auto current = [&]() -> void* { + auto* target = il2cpp_gchandle_get_target(instance.controller); + return Alive(target) && g_enabled(target) ? target : nullptr; + }; + auto* controller = current(); + if (!controller) continue; + g_refresh_achievements(controller); + if (!(controller = current())) continue; + g_refresh_outposts(controller); + if (!(controller = current())) continue; + ApplyConfiguredButtonVisibility(controller); } - SPUD_STATIC_DETOUR(update_buttons, MissionsHudViewController_UpdateButtons_Hook); - spdlog::info("MissionHudTweaks: installed MissionsHudViewController.UpdateButtons hook"); } +} // namespace mission_hud diff --git a/mods/src/patches/parts/mod_settings.cc b/mods/src/patches/parts/mod_settings.cc new file mode 100644 index 000000000..34092298d --- /dev/null +++ b/mods/src/patches/parts/mod_settings.cc @@ -0,0 +1,24 @@ +#include "settings/native/page_navigation.h" +#include "settings/native/value_widgets.h" +#include + +void InstallNativeSettings() +{ + // Resolve managed signatures and distinct targets before installing native UI hooks. +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + using namespace mod_settings::native; + try { + if (!InstallCoreValueWidgets()) + return; + try { + InstallPages(); + } catch (...) { + DisablePages(); + 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..c88edef09 --- /dev/null +++ b/mods/src/patches/parts/runtime_config.cc @@ -0,0 +1,301 @@ +#include "runtime_config_keys.h" +#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" +#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); + for (const auto& [section, key] : config_edit::persisted_settings) { + std::optional value; + auto node = loaded[section][key]; + if (node.is_boolean()) + value = node.value().value(); + else if (node.is_string()) + value = node.value().value(); + else if (node.is_integer()) + value = node.value().value(); + else if (node.is_floating_point()) + value = node.value().value(); + writer->Register(section, key, std::move(value)); + } + } 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; + } + 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/parts/runtime_config_keys.h b/mods/src/patches/parts/runtime_config_keys.h new file mode 100644 index 000000000..cbe95ccb1 --- /dev/null +++ b/mods/src/patches/parts/runtime_config_keys.h @@ -0,0 +1,27 @@ +#pragma once +#include + +namespace config_edit +{ +// Settings accepted by the runtime writer. Configure and its contract fixture +// share this list so new settings cannot silently skip registration. +inline constexpr std::pair persisted_settings[]{ + {"graphics", "zoom_label_player_detail"}, + {"graphics", "zoom_label_non_player_detail"}, + {"graphics", "zoom_label_player_threshold"}, + {"graphics", "zoom_label_non_player_threshold"}, + {"graphics", "keyboard_zoom_speed"}, + {"graphics", "system_pan_momentum_falloff"}, + {"ui", "auto_confirm_ft_upgrade"}, + {"ui", "hud_q_trials"}, + {"ui", "hud_field_training"}, + {"ui", "hud_outposts"}, + {"ui", "hud_missions"}, + {"ui", "disable_preview_locate"}, + {"ui", "disable_preview_recall"}, + {"ui", "show_cargo_default"}, + {"ui", "show_player_cargo"}, + {"ui", "show_station_cargo"}, + {"ui", "show_hostile_cargo"}, + {"ui", "show_armada_cargo"}}; +} // namespace config_edit diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index 6291c32a5..25b2453fd 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -1,5 +1,8 @@ #include "config.h" #include "errormsg.h" +#include "settings/camera_settings.h" +#include "settings/fleet_labels.h" +#include #include @@ -15,11 +18,17 @@ #include #include +#include #include #include namespace { +bool keyboard_zoom_hook_installed = false; +bool fleet_lod_hook_installed = false; +#if __APPLE__ +bool fleet_zoom_hooks_validated = false; +#endif std::unordered_map fleet_label_widgets; bool fleet_label_hooks_installed = false; uintptr_t active_system_zoom_id = 0; @@ -30,13 +39,6 @@ bool threshold_state_val bool player_threshold_state_expanded = false; bool non_player_threshold_state_expanded = false; -bool FleetLabelProfilesEnabled() -{ - const auto &config = Config::Get(); - return config.zoom_label_player.detail != FleetLabelDetail::Native - || config.zoom_label_non_player.detail != FleetLabelDetail::Native; -} - bool FleetLabelThresholdEnabled() { const auto &config = Config::Get(); @@ -196,6 +198,15 @@ void UpdateFleetLabelThreshold() } } // namespace +bool FleetLabelControlsAvailable() +{ return fleet_label_hooks_installed; } + +void RefreshFleetLabelControls() +{ + threshold_state_valid = false; + ApplyAllFleetLabelDetails(); +} + vec3 GetMouseWorldPos(void *cam, vec3 *pos) { static auto class_helper = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.Client.Core", "MathUtils"); @@ -450,18 +461,23 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) void NavigationLOD_UpdateLOD_Hook(auto original, NavigationLOD *_this, ZoomLevels level) { const auto *profile = FleetLabelProfileFor(_this); - const auto effective_level = profile != nullptr ? FleetLabelZoomLevel(level, *profile) : level; - if (profile != nullptr) { + const auto effective_level = profile != nullptr ? FleetLabelZoomLevel(level, *profile) + : level; + if (profile != nullptr || effective_level != level) { _this->SetTargetLevel(effective_level); } original(_this, effective_level); - if (profile != nullptr) { + if (profile != nullptr || effective_level != level) { _this->SetTargetLevel(effective_level); } } void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget *_this) { + if (!fleet_label_hooks_installed) { + original(_this); + return; + } original(_this); if (!_this) { return; @@ -485,6 +501,10 @@ void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget *_ void NavigationFleetWidget_OnDisable_Hook(auto original, NavigationFleetWidget *_this) { + if (!fleet_label_hooks_installed) { + original(_this); + return; + } if (_this) { auto *lod = _this->_lod; fleet_label_widgets.erase(lod); @@ -497,6 +517,10 @@ void NavigationFleetWidget_OnDisable_Hook(auto original, NavigationFleetWidget * void NavigationFleetWidget_OnDidBindContext_Hook(auto original, NavigationFleetWidget *_this) { + if (!fleet_label_hooks_installed) { + original(_this); + return; + } original(_this); if (_this == nullptr) { return; @@ -511,6 +535,10 @@ void NavigationFleetWidget_OnDidBindContext_Hook(auto original, NavigationFleetW void NavigationFleetWidget_OnAboutToReleaseContext_Hook(auto original, NavigationFleetWidget *_this) { + if (!fleet_label_hooks_installed) { + original(_this); + return; + } if (_this != nullptr) { auto *lod = _this->_lod; if (lod != nullptr) { @@ -591,6 +619,9 @@ void *PlanetViewUtils_get_FlatRenderable_Hook(auto original, PlanetViewUtils *_t return fr; } +bool mod_settings::KeyboardZoomControlAvailable() +{ return keyboard_zoom_hook_installed; } + void InstallZoomHooks() { auto navigation_zoom_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationZoom"); @@ -602,7 +633,30 @@ void InstallZoomHooks() auto *normalized_zoom_property = navigation_zoom_class != nullptr ? il2cpp_class_get_property_from_name(navigation_zoom_class, "NormalizedZoom") : nullptr; - if (FleetLabelProfilesEnabled()) { + bool fleet_widget_hooks_ready = false; +#if __APPLE__ + // Validate the shared LOD and per-frame hooks before installing fleet callbacks. + auto fleet_lod = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationLOD"); + const auto* fleet_lod_method = method_contract::Resolve( + fleet_lod.get_cls(), "UpdateLOD", false, "System.Void", {"Digit.Prime.Navigation.ZoomLevels"}); + const auto* fleet_update = method_contract::Resolve( + navigation_zoom_class, "Update", false, "System.Void", {}); + auto* fleet_target = fleet_lod.get_cls() + ? il2cpp_class_get_field_from_name(fleet_lod.get_cls(), "k__BackingField") : nullptr; + auto* target_class = fleet_target ? il2cpp_class_from_type(fleet_target->type) : nullptr; + const auto* target_type = target_class && il2cpp_class_is_enum(target_class) + ? il2cpp_class_enum_basetype(target_class) : nullptr; + fleet_zoom_hooks_validated = fleet_lod_method && fleet_update + && fleet_target && !(fleet_target->type->attrs & FIELD_ATTRIBUTE_STATIC) + && target_type && target_type->type == IL2CPP_TYPE_I4 + && zoom_level_field && !(zoom_level_field->type->attrs & FIELD_ATTRIBUTE_STATIC) + && method_contract::Type(zoom_level_field->type, "Digit.Prime.Navigation.ZoomLevels") + && normalized_zoom_property + && method_contract::Resolve(navigation_zoom_class, "get_NormalizedZoom", false, "System.Single", {}); +#endif + // Install supported callbacks independently of feature values. ZoomHooks is + // the installation switch; each callback reads current label settings. + { auto lod_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationLOD"); auto fleet_widget_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationFleetWidget"); @@ -615,6 +669,17 @@ void InstallZoomHooks() fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnDidBindContext") : nullptr; auto ptr_on_about_to_release_context = fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnAboutToReleaseContext") : nullptr; +#if __APPLE__ + ptr_update_lod = method_contract::Pointer(fleet_lod_method); + ptr_on_enable = method_contract::Pointer(method_contract::Resolve( + fleet_widget_helper.get_cls(), "OnEnable", false, "System.Void", {})); + ptr_on_disable = method_contract::Pointer(method_contract::Resolve( + fleet_widget_helper.get_cls(), "OnDisable", false, "System.Void", {})); + ptr_on_did_bind_context = method_contract::Pointer(method_contract::Resolve( + fleet_widget_helper.get_cls(), "OnDidBindContext", false, "System.Void", {})); + ptr_on_about_to_release_context = method_contract::Pointer(method_contract::Resolve( + fleet_widget_helper.get_cls(), "OnAboutToReleaseContext", false, "System.Void", {})); +#endif auto *lod_class = lod_helper.get_cls(); auto *target_level_field = lod_class != nullptr ? il2cpp_class_get_field_from_name(lod_class, "k__BackingField") : nullptr; @@ -679,25 +744,44 @@ void InstallZoomHooks() } } - const auto fleet_label_dependencies_valid = + auto fleet_label_dependencies_valid = lod_helper.isValidHelper() && ptr_update_lod != nullptr && target_level_field != nullptr && fleet_widget_helper.isValidHelper() && ptr_on_enable != nullptr && ptr_on_disable != nullptr && ptr_on_did_bind_context != nullptr && ptr_on_about_to_release_context != nullptr && lod_field != nullptr && context_field != nullptr && fleet_data_helper.isValidHelper() && fleet_type_property != nullptr && fleet_type_getter != nullptr && navigation_zoom_helper.isValidHelper() && ptr_update != nullptr && zoom_level_field != nullptr && normalized_zoom_property != nullptr; +#if __APPLE__ + fleet_label_dependencies_valid = fleet_label_dependencies_valid && fleet_zoom_hooks_validated + && !(lod_field->type->attrs & FIELD_ATTRIBUTE_STATIC) + && il2cpp_class_from_type(lod_field->type) == lod_class + && !(context_field->type->attrs & FIELD_ATTRIBUTE_STATIC) + && il2cpp_class_from_type(context_field->type) == fleet_data_class + && !(fleet_type_getter->flags & METHOD_ATTRIBUTE_STATIC) && !fleet_type_getter->parameters_count + && method_contract::Type(fleet_type_getter->return_type, "Digit.PrimeServer.Models.DeployedFleetType"); +#endif +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + const std::array targets{ptr_update_lod, ptr_on_enable, ptr_on_disable, ptr_on_did_bind_context, + ptr_on_about_to_release_context}; + for (std::size_t i = 0; i < targets.size(); ++i) { + fleet_label_dependencies_valid &= targets[i] != nullptr; + for (std::size_t j = 0; j < i; ++j) + fleet_label_dependencies_valid &= targets[i] != targets[j]; + } +#endif if (fleet_label_dependencies_valid) { - fleet_label_hooks_installed = true; - SPUD_STATIC_DETOUR(ptr_update_lod, NavigationLOD_UpdateLOD_Hook); - SPUD_STATIC_DETOUR(ptr_on_enable, NavigationFleetWidget_OnEnable_Hook); - SPUD_STATIC_DETOUR(ptr_on_disable, NavigationFleetWidget_OnDisable_Hook); - SPUD_STATIC_DETOUR(ptr_on_did_bind_context, NavigationFleetWidget_OnDidBindContext_Hook); - SPUD_STATIC_DETOUR(ptr_on_about_to_release_context, NavigationFleetWidget_OnAboutToReleaseContext_Hook); + fleet_lod_hook_installed = SPUD_STATIC_DETOUR(ptr_update_lod, NavigationLOD_UpdateLOD_Hook); + const bool enabled = SPUD_STATIC_DETOUR(ptr_on_enable, NavigationFleetWidget_OnEnable_Hook); + const bool disabled = SPUD_STATIC_DETOUR(ptr_on_disable, NavigationFleetWidget_OnDisable_Hook); + const bool bound = SPUD_STATIC_DETOUR(ptr_on_did_bind_context, NavigationFleetWidget_OnDidBindContext_Hook); + const bool released = SPUD_STATIC_DETOUR(ptr_on_about_to_release_context, NavigationFleetWidget_OnAboutToReleaseContext_Hook); + fleet_widget_hooks_ready = fleet_lod_hook_installed && enabled && disabled && bound && released; } else { spdlog::error("Fleet label detail hooks were not installed; using native fleet labels"); } } + { auto pv_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "PlanetViewUtils"); if (pv_helper.isValidHelper()) { @@ -725,7 +809,7 @@ void InstallZoomHooks() if (ptr_update == nullptr) { ErrorMsg::MissingMethod("NavigationZoom", "Update"); } else { - SPUD_STATIC_DETOUR(ptr_update, NavigationZoom_Update_Hook); + keyboard_zoom_hook_installed = SPUD_STATIC_DETOUR(ptr_update, NavigationZoom_Update_Hook); } #if _WIN32 @@ -744,4 +828,8 @@ void InstallZoomHooks() SPUD_STATIC_DETOUR(ptr_set_view_parameters, NavigationZoom_SetViewParameters_Hook); } } + // Widget callbacks remain pass-through until their shared per-frame owner is ready. + fleet_label_hooks_installed = fleet_widget_hooks_ready && keyboard_zoom_hook_installed; + spdlog::info("Fleet label detail hooks ready={}", fleet_label_hooks_installed); + } diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index d4e50a9d9..68da1349b 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -1,4 +1,5 @@ #include "patches.h" +#include "runtime_config.h" #include "file.h" #include "version.h" @@ -49,6 +50,7 @@ void InstallDoubleClickAssignShipHooks(); void InstallInstantWarpConfirmationHooks(); void InstallForbiddenTechConfirmationHooks(); void InstallAudioEventHooks(); +void InstallNativeSettings(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -152,6 +154,8 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"InstantWarpConfirm", {InstallInstantWarpConfirmationHooks, &cfg.installInstantWarpConfirmationHooks}}, {"ForbiddenTechConfirm", {InstallForbiddenTechConfirmationHooks, &cfg.installForbiddenTechConfirmationHooks}}, {"AudioEvents", {InstallAudioEventHooks, &cfg.installAudioEventHooks}}, + {"ModConfirmationSettings", {InstallNativeSettings, &cfg.installNativeSettings}}, + {"RuntimeConfigHooks", {runtime_config::Install, &cfg.installRuntimeConfigHooks}}, }; 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/camera_settings.cc b/mods/src/settings/camera_settings.cc new file mode 100644 index 000000000..ea96e26d4 --- /dev/null +++ b/mods/src/settings/camera_settings.cc @@ -0,0 +1,52 @@ +#include "camera_settings.h" +#include "config.h" +#include "patches/runtime_config.h" + +namespace mod_settings +{ +SliderSetting& KeyboardZoomSpeedSetting() +{ + // A convenient editing range, not a new TOML constraint. The slider's checked + // reader leaves out-of-range player-authored values untouched and unavailable. + static SliderSetting setting({"community_mod.graphics.keyboard_zoom_speed", "Keyboard zoom speed", + [] { + return KeyboardZoomControlAvailable() + ? ValueReadResult::Known(Config::Get().keyboard_zoom_speed, 1) + : ValueReadResult{}; + }, + [](float value, std::uint64_t generation) { + if (generation != 1 || !KeyboardZoomControlAvailable()) + return ApplyResult::Rejected; + Config::Get().keyboard_zoom_speed = value; + runtime_config::SaveSetting("graphics", "keyboard_zoom_speed", + std::round(static_cast(value)), + std::chrono::milliseconds(150)); + return ApplyResult::Applied; + }}, + 0, 1000, 25, KeyboardZoomControlAvailable, SliderLabel::Value, 0); + return setting; +} + +SliderSetting& PanGlideSetting() +{ + // This is the retained motion per existing pan update. Stop short of 1.0, + // which would retain all momentum indefinitely; do not change the pan formula. + static SliderSetting setting({"community_mod.graphics.system_pan_momentum_falloff", "Pan glide (higher = longer)", + [] { + return PanGlideControlAvailable() ? ValueReadResult::Known( + Config::Get().system_pan_momentum_falloff, 1) + : ValueReadResult{}; + }, + [](float value, std::uint64_t generation) { + if (generation != 1 || !PanGlideControlAvailable()) + return ApplyResult::Rejected; + Config::Get().system_pan_momentum_falloff = value; + runtime_config::SaveSetting("graphics", "system_pan_momentum_falloff", + std::round(static_cast(value) * 100.0) / 100.0, + std::chrono::milliseconds(150)); + return ApplyResult::Applied; + }}, + 0, 0.99f, 0.01f, PanGlideControlAvailable); + return setting; +} +} // namespace mod_settings diff --git a/mods/src/settings/camera_settings.h b/mods/src/settings/camera_settings.h new file mode 100644 index 000000000..8906438e2 --- /dev/null +++ b/mods/src/settings/camera_settings.h @@ -0,0 +1,10 @@ +#pragma once +#include "slider_setting.h" + +namespace mod_settings +{ +SliderSetting& KeyboardZoomSpeedSetting(); +SliderSetting& PanGlideSetting(); +bool KeyboardZoomControlAvailable(); +bool PanGlideControlAvailable(); +} // namespace mod_settings 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/fleet_labels.cc b/mods/src/settings/fleet_labels.cc new file mode 100644 index 000000000..8b78f4ed1 --- /dev/null +++ b/mods/src/settings/fleet_labels.cc @@ -0,0 +1,81 @@ +#include "fleet_labels.h" +#include "config.h" +#include "patches/runtime_config.h" +#include +namespace mod_settings +{ +namespace +{ + FleetLabelProfile& Profile(bool player) + { return player ? Config::Get().zoom_label_player : Config::Get().zoom_label_non_player; } + ValueDefinition Detail(bool player) + { + return {player ? "community_mod.labels.player.detail" : "community_mod.labels.other.detail", "Label detail", + [player] { + return FleetLabelControlsAvailable() + ? ValueReadResult::Known(static_cast(Profile(player).detail), 1) + : ValueReadResult{}; + }, + [player](int value, std::uint64_t generation) { + if (generation != 1 || !FleetLabelControlsAvailable() || value < 0 || value > 3) + return ApplyResult::Rejected; + Profile(player).detail = static_cast(value); + constexpr const char* names[]{"native", "expanded", "compact", "threshold"}; + runtime_config::SaveSetting("graphics", + player ? "zoom_label_player_detail" : "zoom_label_non_player_detail", + std::string(names[value])); + RefreshFleetLabelControls(); + return ApplyResult::Applied; + }}; + } + ValueDefinition Threshold(bool player) + { + return {player ? "community_mod.labels.player.threshold" : "community_mod.labels.other.threshold", + "Detail zoom threshold", + [player] { + return FleetLabelControlsAvailable() ? ValueReadResult::Known(Profile(player).zoom_threshold, 1) + : ValueReadResult{}; + }, + [player](float value, std::uint64_t generation) { + if (generation != 1 || !FleetLabelControlsAvailable()) + return ApplyResult::Rejected; + Profile(player).zoom_threshold = value; + // Keep the TOML decimal at the slider's whole percentage, not float noise. + runtime_config::SaveSetting( + "graphics", player ? "zoom_label_player_threshold" : "zoom_label_non_player_threshold", + std::round(static_cast(value) * 100.0) / 100.0, std::chrono::milliseconds(150)); + RefreshFleetLabelControls(); + return ApplyResult::Applied; + }}; + } +} // namespace +ChoiceSetting& FleetLabelDetailSetting(bool player) +{ + static ChoiceSetting players(Detail(true), {"Native", "Expanded", "Compact", "Threshold"}); + static ChoiceSetting others(Detail(false), {"Native", "Expanded", "Compact", "Threshold"}); + return player ? players : others; +} +SliderSetting& FleetLabelThresholdSetting(bool player) +{ + static SliderSetting players(Threshold(true), 0, 1, 0.01f, + [] { return Profile(true).detail == FleetLabelDetail::Threshold; }, + SliderLabel::Percentage, 2, "Select Threshold"); + static SliderSetting others(Threshold(false), 0, 1, 0.01f, + [] { return Profile(false).detail == FleetLabelDetail::Threshold; }, + SliderLabel::Percentage, 2, "Select Threshold"); + return player ? players : others; +} +std::string FleetLabelSummary(bool player) +{ + auto& setting = FleetLabelDetailSetting(player); + const auto state = setting.state().Observe().state; + if (!state.known()) + return "Unavailable"; + auto text = setting.labels().at(*state.value); + if (*state.value == static_cast(FleetLabelDetail::Threshold)) { + const auto threshold = FleetLabelThresholdSetting(player).state().Observe().state; + text += threshold.known() ? std::format(" · {:.2f}", *threshold.value) : " · Unavailable"; + } + return text; +} +} // namespace mod_settings diff --git a/mods/src/settings/fleet_labels.h b/mods/src/settings/fleet_labels.h new file mode 100644 index 000000000..654ab0729 --- /dev/null +++ b/mods/src/settings/fleet_labels.h @@ -0,0 +1,12 @@ +#pragma once +#include "choice_setting.h" +#include "slider_setting.h" +namespace mod_settings +{ +ChoiceSetting& FleetLabelDetailSetting(bool player); +SliderSetting& FleetLabelThresholdSetting(bool player); +std::string FleetLabelSummary(bool player); +} // namespace mod_settings +// Existing zoom adapter owns native fleet labels and reports installation. +bool FleetLabelControlsAvailable(); +void RefreshFleetLabelControls(); diff --git a/mods/src/settings/forbidden_tech.cc b/mods/src/settings/forbidden_tech.cc new file mode 100644 index 000000000..e4997c288 --- /dev/null +++ b/mods/src/settings/forbidden_tech.cc @@ -0,0 +1,24 @@ +#include "forbidden_tech.h" +#include "config.h" +#include "patches/runtime_config.h" + +namespace mod_settings +{ +BooleanSetting& ForbiddenTechConfirmationSetting() +{ + static BooleanSetting setting({"community_mod.ft_confirmation", "[MOD] Confirm Forbidden Tech upgrades", + [] { + return ForbiddenTechControlsAvailable() + ? ReadResult::Known(!Config::Get().auto_confirm_ft_upgrade, 1) + : ReadResult{}; + }, + [](bool confirm, std::uint64_t generation) { + if (generation != 1 || !ForbiddenTechControlsAvailable()) + return ApplyResult::Rejected; + Config::Get().auto_confirm_ft_upgrade = !confirm; + runtime_config::SaveSetting("ui", "auto_confirm_ft_upgrade", !confirm); + return ApplyResult::Applied; + }}); + return setting; +} +} // namespace mod_settings diff --git a/mods/src/settings/forbidden_tech.h b/mods/src/settings/forbidden_tech.h new file mode 100644 index 000000000..9d93795cc --- /dev/null +++ b/mods/src/settings/forbidden_tech.h @@ -0,0 +1,7 @@ +#pragma once +#include "boolean_settings.h" +namespace mod_settings +{ +BooleanSetting& ForbiddenTechConfirmationSetting(); +} +bool ForbiddenTechControlsAvailable(); diff --git a/mods/src/settings/mission_hud.cc b/mods/src/settings/mission_hud.cc new file mode 100644 index 000000000..e46013a45 --- /dev/null +++ b/mods/src/settings/mission_hud.cc @@ -0,0 +1,37 @@ +#include "mission_hud.h" +#include "config.h" +#include "patches/mission_hud.h" +#include "patches/runtime_config.h" +#include + +namespace mod_settings +{ +namespace +{ +ValueDefinition Visibility(const char* name, const char* key, const char* label) +{ + return {std::string("community_mod.hud.") + name, label, + [name] { return mission_hud::Available() ? ValueReadResult::Known(static_cast(Config::Get().MissionHudButtonVisibility(name)), 1) : ValueReadResult{}; }, + [name, key](int value, std::uint64_t generation) { + if (generation != 1 || !mission_hud::CanChange() || value < 0 || value > 2) + return ApplyResult::Rejected; + Config::Get().mission_hud_buttons[std::string(name)] = static_cast(value); + mission_hud::Refresh(); + constexpr const char* modes[]{"auto", "always", "never"}; + runtime_config::SaveSetting("ui", key, std::string(modes[value])); + return ApplyResult::Applied; + }}; +} +} // namespace + +ChoiceSetting& MissionHudSetting(MissionHudOption option) +{ + static std::array settings{ + ChoiceSetting(Visibility("q_trials", "hud_q_trials", "Q's Trials"), {"Auto", "Always", "Never"}), + ChoiceSetting(Visibility("field_training", "hud_field_training", "Field Training"), {"Auto", "Always", "Never"}), + ChoiceSetting(Visibility("outposts", "hud_outposts", "Outposts"), {"Auto", "Always", "Never"}), + ChoiceSetting(Visibility("missions", "hud_missions", "Missions"), {"Auto", "Always", "Never"})}; + static_assert(settings.size() == static_cast(MissionHudOption::Count)); + return settings.at(static_cast(option)); +} +} // namespace mod_settings diff --git a/mods/src/settings/mission_hud.h b/mods/src/settings/mission_hud.h new file mode 100644 index 000000000..6adcb1c43 --- /dev/null +++ b/mods/src/settings/mission_hud.h @@ -0,0 +1,8 @@ +#pragma once +#include "choice_setting.h" + +namespace mod_settings +{ +enum class MissionHudOption { Trials, FieldTraining, Outposts, Missions, Count }; +ChoiceSetting& MissionHudSetting(MissionHudOption option); +} // 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..e0bbc642a --- /dev/null +++ b/mods/src/settings/mod_pages.cc @@ -0,0 +1,66 @@ +#include "mod_pages.h" +#include "mission_hud.h" +#include "camera_settings.h" +#include "fleet_labels.h" +#include "preview_settings.h" +#include "warp_mode.h" + +namespace mod_settings +{ +PageCatalog& ModPages() +{ + static PageCatalog catalog("community_mod.settings", "Mod Settings"); + return catalog; +} +void RegisterModPages() +{ + auto& catalog = ModPages(); + // Group by player tasks. Stable identities still map to existing TOML keys; + // a presentation move does not migrate configuration. + if (KeyboardZoomControlAvailable() || PanGlideControlAvailable()) { + catalog.AddPage("community_mod.graphics.camera", "Camera", "community_mod.settings"); + if (KeyboardZoomControlAvailable()) + catalog.AddSlider("community_mod.graphics.camera", KeyboardZoomSpeedSetting()); + if (PanGlideControlAvailable()) + catalog.AddSlider("community_mod.graphics.camera", PanGlideSetting()); + } + catalog.AddPage("community_mod.navigation", "Map & Travel", "community_mod.settings"); + catalog.AddHeading("community_mod.navigation", "community_mod.navigation.warp", "Instant warp mode"); + catalog.AddChoice("community_mod.navigation", WarpModeSetting()); + catalog.AddPage("community_mod.previews", "Previews & Cargo", "community_mod.settings"); + if (PreviewShortcutsAvailable()) { + for (auto option : {PreviewOption::Locate, PreviewOption::Recall}) + catalog.AddBoolean("community_mod.previews", PreviewSetting(option)); + } + if (CargoPreviewsAvailable()) { + catalog.AddBoolean("community_mod.previews", PreviewSetting(PreviewOption::Cargo)); + // Only presentation depends on the master; target choices remain saved. + catalog.AddHeading("community_mod.previews", "community_mod.ui.cargo_targets", "Target types", false, [] { + const auto state = PreviewSetting(PreviewOption::Cargo).Observe().state; + return state.known() && *state.value; + }); + for (auto option : {PreviewOption::PlayerCargo, PreviewOption::StationCargo, PreviewOption::HostileCargo, + PreviewOption::ArmadaCargo}) + catalog.AddBoolean("community_mod.previews", PreviewSetting(option)); + } + catalog.AddPage("community_mod.hud", "HUD Buttons", "community_mod.settings"); + for (auto option : {MissionHudOption::Trials, MissionHudOption::FieldTraining, + MissionHudOption::Outposts, MissionHudOption::Missions}) { + auto& setting = MissionHudSetting(option); + catalog.AddHeading("community_mod.hud", setting.state().id() + ".section", setting.state().label(), true, {}, + [option] { + auto& choice = MissionHudSetting(option); + const auto state = choice.state().Observe().state; + return state.known() ? choice.labels().at(*state.value) : std::string("Unavailable"); + }); + catalog.AddChoice("community_mod.hud", setting); + } + catalog.AddPage("community_mod.labels", "Fleet Labels", "community_mod.settings"); + for (bool player : {true, false}) { + catalog.AddHeading("community_mod.labels", player ? "community_mod.labels.player" : "community_mod.labels.other", + player ? "Player" : "Non-player", true, {}, [player] { return FleetLabelSummary(player); }); + catalog.AddChoice("community_mod.labels", FleetLabelDetailSetting(player)); + catalog.AddSlider("community_mod.labels", FleetLabelThresholdSetting(player)); + } +} +} // namespace mod_settings diff --git a/mods/src/settings/mod_pages.h b/mods/src/settings/mod_pages.h new file mode 100644 index 000000000..f7db55e07 --- /dev/null +++ b/mods/src/settings/mod_pages.h @@ -0,0 +1,10 @@ +#pragma once +#include "page_catalog.h" + +namespace mod_settings +{ +// Register intentional feature adapters before native settings installation. +// The empty catalog installs no extra navigation hooks and shows no empty menu. +PageCatalog& ModPages(); +void RegisterModPages(); +} // namespace mod_settings diff --git a/mods/src/settings/native/action_widgets.cc b/mods/src/settings/native/action_widgets.cc new file mode 100644 index 000000000..b552caca1 --- /dev/null +++ b/mods/src/settings/native/action_widgets.cc @@ -0,0 +1,396 @@ +#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 +#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; + 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. + // Resolve current addresses through managed metadata. + for (auto* target : {action.refresh, action.release}) { + if (!Instance(target, 0, IL2CPP_TYPE_VOID) + || action.refresh->methodPointer == action.release->methodPointer) + throw std::runtime_error("settings command hook metadata or shared target"); + 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..ed47e5bc0 --- /dev/null +++ b/mods/src/settings/native/interop.cc @@ -0,0 +1,150 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "interop.h" +#include +#include "settings/page_catalog.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 Reference(const Il2CppType* type) +{ + return Type(type, IL2CPP_TYPE_CLASS) || Type(type, IL2CPP_TYPE_GENERICINST) || Type(type, IL2CPP_TYPE_OBJECT) + || Type(type, IL2CPP_TYPE_STRING); +} + +bool Instance(const MethodInfo* method, int count, int result) +{ + return method && method->methodPointer && method->invoker_method && !(method->flags & METHOD_ATTRIBUTE_STATIC) + && method->parameters_count == count && Type(method->return_type, result) + && !method->has_full_generic_sharing_signature; +} + +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"); + Il2CppObject* result = nullptr; + if (!Il2CppRuntime::TryInvoke(method, object, args, &result)) + 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 ? IL2CppClassHelper(object->klass).GetMethodInfo(name, count) : nullptr, object, args); } +bool Boolean(Il2CppObject* boxed) +{ + bool value = false; + if (!Il2CppRuntime::TryBoolean(boxed, value)) + throw std::runtime_error("settings boolean result"); + return value; +} +bool Equals(Il2CppObject* value, const char* ascii) +{ + if (!value || !Type(il2cpp_class_get_type(value->klass), IL2CPP_TYPE_STRING)) + return false; + auto* text = reinterpret_cast(value); + const auto length = std::strlen(ascii); + if (il2cpp_string_length(text) != length) + return false; + auto* chars = il2cpp_string_chars(text); + for (std::size_t i = 0; i < length; ++i) + if (chars[i] != static_cast(ascii[i])) + return false; + return true; +} +Il2CppObject* Target(Il2CppGCHandle handle) +{ return handle ? il2cpp_gchandle_get_target(handle) : nullptr; } +void Free(Il2CppGCHandle& handle) +{ + if (handle) + il2cpp_gchandle_free(handle); + handle = nullptr; +} + +// Cosmetic changes belong to the bound row. Restore before native refresh or +void SetActive(Il2CppObject* object, bool value) +{ + void* args[] = {&value}; + Call(object, "SetActive", 1, args); +} +Il2CppObject* MakeDelegate(Il2CppClass* cls, Il2CppObject* director, const MethodInfo* method) +{ + const auto* ctor = cls ? il2cpp_class_get_method_from_name(cls, ".ctor", 2) : nullptr; + if (!Instance(ctor, 2, IL2CPP_TYPE_VOID) || !Reference(ctor->parameters[0]) + || !Type(ctor->parameters[1], IL2CPP_TYPE_I) || !method) + throw std::runtime_error("settings delegate constructor"); + const auto* invoke = il2cpp_class_get_method_from_name(cls, "Invoke", method->parameters_count); + auto* parent = il2cpp_class_get_parent(cls); + if (!parent || std::strcmp(il2cpp_class_get_name(parent), "MulticastDelegate") != 0 + || std::strcmp(il2cpp_class_get_namespace(parent), "System") != 0 || !invoke || invoke->return_type->byref + || (invoke->flags & METHOD_ATTRIBUTE_STATIC) + || il2cpp_class_from_type(invoke->return_type) != il2cpp_class_from_type(method->return_type) + || !il2cpp_class_is_assignable_from(method->klass, director->klass)) + throw std::runtime_error("settings delegate signature"); + for (int i = 0; i < method->parameters_count; ++i) + if (invoke->parameters[i]->byref + || il2cpp_class_from_type(invoke->parameters[i]) != il2cpp_class_from_type(method->parameters[i])) + throw std::runtime_error("settings delegate parameter"); + Root object(il2cpp_object_new(cls)); + void* args[] = {director, &method}; + Invoke(ctor, object.get(), args); + auto* delegate = reinterpret_cast(object.get()); + if (delegate->target != director || delegate->invoke_impl_this != director || delegate->method != method) + throw std::runtime_error("settings closed delegate"); + delegate->method_ptr = method->methodPointer; + delegate->invoke_impl = method->methodPointer; + return object.get(); +} + +int Count(Il2CppObject* list) +{ + Root value(Call(list, "get_Count")); + if (!value.get() || !Type(il2cpp_class_get_type(value.get()->klass), IL2CPP_TYPE_I4)) + throw std::runtime_error("settings child count"); + const int count = *static_cast(il2cpp_object_unbox(value.get())); + if (count < 0 || count > PageCatalog::NativeChildLimit) + throw std::runtime_error("settings child bound"); + return count; +} +Il2CppObject* Item(Il2CppObject* list, int index) +{ + void* args[] = {&index}; + return Call(list, "get_Item", 1, args); +} +bool HasLabel(Il2CppObject* row, const char* id) +{ + Root label(Call(row, "get_LabelContext")); + return label.get() && Equals(Call(label.get(), "get_Identifier"), id); +} + +} // namespace mod_settings::native +#endif diff --git a/mods/src/settings/native/interop.h b/mods/src/settings/native/interop.h new file mode 100644 index 000000000..74763ea65 --- /dev/null +++ b/mods/src/settings/native/interop.h @@ -0,0 +1,49 @@ +#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); +} // 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..021982ca4 --- /dev/null +++ b/mods/src/settings/native/page_navigation.cc @@ -0,0 +1,640 @@ +#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 +#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() +{ + 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) +{ + 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; + 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(); + 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)) + throw std::runtime_error("settings page hook metadata"); + 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) + || targets[0]->methodPointer == targets[1]->methodPointer) + throw std::runtime_error("heading hook metadata"); + 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/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..20552cac2 --- /dev/null +++ b/mods/src/settings/native/value_widgets.cc @@ -0,0 +1,946 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "page_navigation.h" +#include "patches/parts/fc_confirmation_reset.h" +#include "settings/forbidden_tech.h" +#include "settings/native_boolean_callback.h" +#include "value_widget_record.h" +#include +#include +#include +#include +#include +#include + +namespace mod_settings::native +{ +namespace +{ + constexpr const char* CategoryKey = "game_settings_category_7"; + bool active = false, installing = false; + std::thread::id uiThread; + NativeCallback getter; + NativeCallback setter; + NativeCallback query, selectionGetter; + NativeCallback selectionSetter; + NativeCallback sliderGetter; + NativeCallback sliderSetter; + bool selectionActive = false, sliderActive = false; +} // namespace +bool SelectionActive() +{ return selectionActive; } +bool SliderActive() +{ return sliderActive; } +const MethodInfo* QueryMethod() +{ return query.method(); } +BooleanSetting* SettingFor(Il2CppObject* context) +{ + auto& fc = FleetCommanderConfirmationSetting(); + if (HasLabel(context, fc.id().c_str())) + return &fc; + auto& ft = ForbiddenTechConfirmationSetting(); + if (HasLabel(context, ft.id().c_str())) + return &ft; + for (const auto& page : Pages()) + for (auto* setting : page.Controls()) + if (HasLabel(context, setting->id().c_str())) + return setting; + return nullptr; +} +ValueWidgetMetadata& ToggleMeta() +{ + static ValueWidgetMetadata metadata; + return metadata; +} +ValueWidgetMetadata& SelectionMeta() +{ + static ValueWidgetMetadata metadata(true); + return metadata; +} +ValueWidgetMetadata& SliderMeta() +{ + static ValueWidgetMetadata metadata(false, true); + return metadata; +} +ValueWidgetMetadata& WidgetMeta(Il2CppObject* widget) +{ + if (sliderActive && widget && widget->klass == SliderMeta().widget.get_cls()) + return SliderMeta(); + return selectionActive && widget && widget->klass == SelectionMeta().widget.get_cls() ? SelectionMeta() + : ToggleMeta(); +} +std::pair ChoiceFor(Il2CppObject* context) +{ + for (const auto& page : Pages()) + for (auto* choice : page.Controls()) + for (int i = 0; i < static_cast(choice->labels().size()); ++i) + if (HasLabel(context, choice->item_id(i).c_str())) + return {choice, i}; + return {nullptr, 0}; +} + +SliderSetting* SliderFor(Il2CppObject* context) +{ + for (const auto& page : Pages()) + for (auto* setting : page.Controls()) + if (HasLabel(context, setting->state().id().c_str())) + return setting; + return nullptr; +} +std::deque& ValueViews() +{ + static std::deque views(8); + return views; +} +ValueWidget* renderingView = nullptr; +ValueWidget* bindingView = nullptr; +void Restore(ValueWidget& view) +{ + RestoreTint(view.tint); + if (view.disabled) { + if (auto* control = Target(view.selectionControl)) { + void* args[] = {&view.interactableBefore}; + Call(control, "set_interactable", 1, args); + } + view.disabled = false; + } + if (view.overridden) { + if (auto* label = Target(view.label)) + Call(label, "ClearTextOverride"); + view.overridden = false; + } + if (view.hidden) { + for (std::size_t i = 0; i < view.indicators.size(); ++i) + if (auto* object = Target(view.indicators[i])) + SetActive(object, view.activeBefore[i]); + view.hidden = false; + } +} +void Clear(ValueWidget& view) +{ + if (view.clearing) + return; + struct Scope { + ValueWidget& view; + explicit Scope(ValueWidget& view) + : view(view) + { view.clearing = true; } + ~Scope() + { view.clearing = false; } + } scope(view); + try { + Restore(view); + } catch (...) { + Warn(); + } + // 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()); + AddBooleanRow(director, context, category.get(), ForbiddenTechConfirmationSetting()); +} +void Render(ValueWidget& view, auto original, Il2CppObject* widget) +{ + if (view.rendering) + return; + Root boundContext(Target(view.context)); + struct Scope { + ValueWidget& view; + ValueWidget* previous; + NativeViewState::RenderScope suppress; + Scope(ValueWidget& view) + : view(view) + , previous(renderingView) + , suppress(*view.state) + { + view.rendering = true; + renderingView = &view; + } + ~Scope() + { + renderingView = previous; + view.rendering = false; + } + } scope(view); + Restore(view); + original(widget); + if (Target(view.widget) != widget || Target(view.context) != boundContext.get()) + return; + Root label(Target(view.label)); + std::string text = view.state->label(); + // The native row has limited label width: "Change not applied; try again" was + // visibly truncated after "; tr" alongside the FC label. Keep these suffixes + // short; recheck the full label at supported UI scales when changing wording. + if (!view.state->known()) + text += " — " + std::string(view.state->unavailableReason()); + else if (view.state->failed()) + text += " — Retry"; + else if (!view.state->enabled() && !view.state->disabledReason().empty()) + text += " — " + std::string(view.state->disabledReason()); + // 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(); + ForbiddenTechConfirmationSetting().InvalidateSession(); + for (const auto& page : Pages()) + for (auto* setting : page.Controls()) + setting->state().InvalidateSession(); + for (const auto& page : Pages()) + for (auto* choice : page.Controls()) + choice->state().InvalidateSession(); + for (const auto& page : Pages()) + for (auto* setting : page.Controls()) + if (setting != &FleetCommanderConfirmationSetting()) + setting->InvalidateSession(); + for (auto& view : ValueViews()) { + if (!view.state) + continue; + view.state->Invalidate(); + // Re-rendering during a native account transition can read the old account. + // Hide the indicators immediately; next explicit bind may establish readiness. + if (auto* widget = Target(view.widget)) { + try { + Render(view, [](Il2CppObject*) {}, widget); + } catch (...) { + HideUnsupported(widget); + } + } + } +} +void SessionBoundary(auto original, Il2CppObject* owner) +{ + if (OnUIThread()) { + try { + Invalidate(); + } catch (...) { + Warn(); + } + } + original(owner); +} +void ReloadHook(auto original, Il2CppObject* owner) +{ SessionBoundary(original, owner); } +void SessionHook(auto original, Il2CppObject* owner) +{ SessionBoundary(original, owner); } +void LoadHook(auto original, Il2CppObject* owner) +{ SessionBoundary(original, owner); } + +void PrepareValueViews(std::size_t count) +{ ValueViews().resize(std::max(ValueViews().size(), count)); } +bool ValueWidgetsBusy() +{ + for (const auto& view : ValueViews()) + if (view.requesting || view.rendering || view.binding || view.clearing) + return true; + return false; +} +void InstallChoiceAndSliderWidgets() +{ + auto& m = PageMeta(); + if (std::any_of(Pages().begin(), Pages().end(), + [](const auto& page) { return !page.template Controls().empty(); })) { + auto& selection = SelectionMeta(); + const auto* get = selection.director.GetMethodInfo("GetQualityOptionSelectedIndex", 0); + const auto* set = selection.director.GetMethodInfo("OnQualityOptionSelected", 1); + const auto* add = selection.context.GetMethodInfo("AddSelection", 6); + if (!Instance(get, 0, IL2CPP_TYPE_I4) || !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)) + throw std::runtime_error("selection hook metadata"); + 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)) + throw std::runtime_error("slider hook metadata"); + 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)) + throw std::runtime_error("settings hook metadata"); + 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"); + if (!ForbiddenTechConfirmationSetting().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..ab8759914 --- /dev/null +++ b/mods/src/settings/native/value_widgets.h @@ -0,0 +1,68 @@ +#pragma once + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "interop.h" +#include "settings/page_catalog.h" + +namespace mod_settings::native +{ +struct ValueWidgetMetadata { + bool selection, slider; + explicit ValueWidgetMetadata(bool selection = false, bool slider = false) + : selection(selection) + , slider(slider) + { + } + IL2CppClassHelper director = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "SettingsSectionDirector"); + IL2CppClassHelper widget = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", + slider ? "SliderOptionWidget" + : selection ? "SelectionItemOptionWidget" + : "ToggleOptionWidget"); + IL2CppClassHelper context = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "SettingsContext"); + IL2CppClassHelper row = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", + slider ? "SliderOptionContext" + : selection ? "SelectionItemOptionContext" + : "ToggleOptionContext"); + IL2CppClassHelper prefs = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.PersistentPrefs", "PersistentPrefsManager"); + const MethodInfo* addGeneral = director.GetMethodInfo("AddGeneralSettings", 1); + const MethodInfo* addToggle = context.GetMethodInfo("AddToggle", 4); + const MethodInfo* refresh = widget.GetMethodInfo("SetWidgetData", 0); + const MethodInfo* changed = widget.GetMethodInfo(slider ? "OnSliderValueChanged" : "OnToggleValueChanged", 1); + const MethodInfo* valueLabel = slider ? widget.GetMethodInfo("UpdateValueLabel", 1) : nullptr; + const MethodInfo* release = widget.GetMethodInfo("OnAboutToReleaseContext", 0); + const MethodInfo* reload = prefs.GetMethodInfo("RegisterEvents", 0); + const MethodInfo* session = prefs.GetMethodInfo("GameSessionStartedEventHandler", 0); + const MethodInfo* load = prefs.GetMethodInfo("LoadPersistentPrefsFromCloud", 0); + const MethodInfo* getContext = widget.GetMethodInfo("get_Context", 0); + const MethodInfo* querySetter = row.GetMethodInfo("set_QueryOptionState", 1); + FieldInfo* queryField = Field(row.get_cls(), "k__BackingField"); + FieldInfo* labelField = Field(widget.get_cls(), "_label"); + FieldInfo* toggleField = Field(widget.get_cls(), slider ? "_slider" : "_toggle"); + FieldInfo* stateField = Field(widget.get_cls(), slider ? "_valueLabel" : "_toggleStateAnimator"); +}; + +ValueWidgetMetadata& ToggleMeta(); +ValueWidgetMetadata& SelectionMeta(); +ValueWidgetMetadata& SliderMeta(); +ValueWidgetMetadata& WidgetMeta(Il2CppObject* widget); +bool OnUIThread(); +bool SelectionActive(); +bool SliderActive(); +const MethodInfo* QueryMethod(); +BooleanSetting* SettingFor(Il2CppObject* context); +std::pair ChoiceFor(Il2CppObject* context); +SliderSetting* SliderFor(Il2CppObject* context); +bool OwnsValueContext(Il2CppObject* context); +void AddBooleanRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, BooleanSetting& setting); +void AddChoiceRows(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, ChoiceSetting& setting); +void AddSliderRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, SliderSetting& setting); +void PrepareValueViews(std::size_t count); +bool ValueWidgetsBusy(); +void RefreshViews(); +void InstallChoiceAndSliderWidgets(); +bool InstallCoreValueWidgets(); // True only for this first successful installation. +} // namespace mod_settings::native + +#endif diff --git a/mods/src/settings/native_boolean_callback.h b/mods/src/settings/native_boolean_callback.h new file mode 100644 index 000000000..ab899d286 --- /dev/null +++ b/mods/src/settings/native_boolean_callback.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace mod_settings +{ +// Process-lifetime schema, owned by the registration, not a settings page. The +// donor supplies only matching reflection metadata; every executable path is +// replaced. Never modify a MethodInfo owned by IL2CPP. +template class NativeCallback +{ +public: + using Function = Result (*)(Il2CppObject*, Args..., const MethodInfo*); + NativeCallback() = default; + NativeCallback(const NativeCallback&) = delete; + NativeCallback& operator=(const NativeCallback&) = delete; + bool Initialize(const MethodInfo* schema, Function callback) + { + if (initialized_ || !schema || !callback || schema->is_generic || schema->is_inflated + || schema->has_full_generic_sharing_signature || (schema->flags & METHOD_ATTRIBUTE_STATIC) + || (schema->flags & METHOD_ATTRIBUTE_VIRTUAL) || schema->parameters_count != sizeof...(Args)) + return false; + if (!schema->return_type || schema->return_type->byref) + return false; + if constexpr (std::is_void_v) { + if (schema->return_type->type != IL2CPP_TYPE_VOID) + return false; + } else if constexpr (std::is_same_v) { + if (schema->return_type->type != IL2CPP_TYPE_BOOLEAN) + return false; + } else if constexpr (std::is_same_v) { + if (schema->return_type->type != IL2CPP_TYPE_R4) + return false; + } else if constexpr (std::is_same_v) { + if (schema->return_type->type != IL2CPP_TYPE_STRING) + return false; + } else { + static_assert(std::is_same_v, "Only boolean, void, Single and Int32 callbacks are supported"); + // Enum users additionally validate the Int32 backing type in the adapter. + if (schema->return_type->type != IL2CPP_TYPE_VALUETYPE && schema->return_type->type != IL2CPP_TYPE_I4) + return false; + } + static_assert(((std::is_same_v || std::is_same_v || std::is_same_v) && ...), + "Only boolean, Single and Int32 callback arguments are supported"); + constexpr std::array types{(std::is_same_v ? IL2CPP_TYPE_BOOLEAN + : std::is_same_v ? IL2CPP_TYPE_R4 + : IL2CPP_TYPE_I4)...}; + for (std::size_t i = 0; i < sizeof...(Args); ++i) + if (!schema->parameters || !schema->parameters[i] || schema->parameters[i]->byref + || schema->parameters[i]->type != types[i]) + return false; + method_ = *schema; + method_.methodPointer = reinterpret_cast(callback); + method_.virtualMethodPointer = method_.methodPointer; + method_.invoker_method = Invoke; + initialized_ = true; + return true; + } + const MethodInfo* method() const + { return initialized_ ? &method_ : nullptr; } + +private: + template + static void Call(const MethodInfo* method, void* object, void** args, void* result, std::index_sequence) + { + auto callback = reinterpret_cast(method->methodPointer); + if constexpr (std::is_void_v) + callback(static_cast(object), *static_cast(args[I])..., method); + else + *static_cast(result) = + callback(static_cast(object), *static_cast(args[I])..., method); + } + static void Invoke(Il2CppMethodPointer, const MethodInfo* method, void* object, void** args, void* result) + { Call(method, object, args, result, std::index_sequence_for{}); } + MethodInfo method_{}; + bool initialized_ = false; +}; +} // namespace mod_settings diff --git a/mods/src/settings/native_view_state.h b/mods/src/settings/native_view_state.h new file mode 100644 index 000000000..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/preview_settings.cc b/mods/src/settings/preview_settings.cc new file mode 100644 index 000000000..54eda7f1f --- /dev/null +++ b/mods/src/settings/preview_settings.cc @@ -0,0 +1,48 @@ +#include "preview_settings.h" +#include "config.h" +#include "patches/runtime_config.h" +#include + +namespace mod_settings +{ +namespace +{ + Definition PreviewDefinition(const char* key, const char* label, bool Config::* member, bool inverted = false) + { + return {std::string("community_mod.ui.") + key, label, + [member, inverted] { return ReadResult::Known((Config::Get().*member) != inverted, 1); }, + [key, member, inverted](bool desired, std::uint64_t generation) { + if (generation != 1) + return ApplyResult::Rejected; + const bool stored = desired != inverted; + Config::Get().*member = stored; + runtime_config::SaveSetting("ui", key, stored); + return ApplyResult::Applied; + }}; + } +} // namespace + +BooleanSetting& PreviewSetting(PreviewOption option) +{ + static std::array settings{ + BooleanSetting(PreviewDefinition("disable_preview_locate", "Allow Locate while a preview is open", + &Config::disable_preview_locate, true)), + BooleanSetting(PreviewDefinition("disable_preview_recall", "Allow Recall while a preview is open", + &Config::disable_preview_recall, true)), + BooleanSetting(PreviewDefinition("show_cargo_default", "Automatically open cargo", &Config::show_cargo_default)), + BooleanSetting(PreviewDefinition("show_player_cargo", "Player fleets", &Config::show_player_cargo)), + BooleanSetting(PreviewDefinition("show_station_cargo", "Stations", &Config::show_station_cargo)), + BooleanSetting(PreviewDefinition("show_hostile_cargo", "Hostiles", &Config::show_hostile_cargo)), + BooleanSetting(PreviewDefinition("show_armada_cargo", "Armada targets", &Config::show_armada_cargo))}; + static_assert(settings.size() == static_cast(PreviewOption::Count)); + return settings.at(static_cast(option)); +} + +void TogglePreviewSetting(PreviewOption option) +{ + auto& setting = PreviewSetting(option); + const auto before = setting.Observe(); + if (before.state.known()) + setting.SetFromUser(!*before.state.value, before); +} +} // namespace mod_settings diff --git a/mods/src/settings/preview_settings.h b/mods/src/settings/preview_settings.h new file mode 100644 index 000000000..2a081ca07 --- /dev/null +++ b/mods/src/settings/preview_settings.h @@ -0,0 +1,17 @@ +#pragma once +#include "boolean_settings.h" + +namespace mod_settings +{ +enum class PreviewOption { Locate, Recall, Cargo, PlayerCargo, StationCargo, HostileCargo, ArmadaCargo, Count }; + +// The UI and existing toggle shortcuts share these process-lifetime owners. +// Changes take effect on the next preview/action; no game action is invoked here. +BooleanSetting& PreviewSetting(PreviewOption option); +void TogglePreviewSetting(PreviewOption option); + +// Native page admission only. Shortcut behavior remains available independently +// of whether all the hooks needed to expose a complete page were installed. +bool PreviewShortcutsAvailable(); +bool CargoPreviewsAvailable(); +} // 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/warp_mode.cc b/mods/src/settings/warp_mode.cc new file mode 100644 index 000000000..5dc1b2235 --- /dev/null +++ b/mods/src/settings/warp_mode.cc @@ -0,0 +1,53 @@ +#include "warp_mode.h" +#include "config.h" +#include "patches/runtime_config.h" +#include + +namespace mod_settings +{ +ChoiceSetting& WarpModeSetting() +{ + static ChoiceSetting setting( + {"community_mod.warp_mode", "Instant warp mode", + [] { return ValueReadResult::Known(static_cast(Config::Get().auto_confirm_instant_warp), 1); }, + [](int value, std::uint64_t generation) { + if (generation != 1) + return ApplyResult::Rejected; + return SetWarpMode(static_cast(value)) ? ApplyResult::Applied : ApplyResult::Rejected; + }}, + {"Normal (ask)", "Warp", "Jump"}); + return setting; +} +bool SetWarpMode(InstantWarpConfirmation desired) +{ + const char* value = nullptr; + switch (desired) { + case InstantWarpConfirmation::None: + value = "none"; + break; + case InstantWarpConfirmation::Warp: + value = "warp"; + break; + case InstantWarpConfirmation::Jump: + value = "jump"; + break; + default: + return false; + } + auto& config = Config::Get(); + if (config.auto_confirm_instant_warp == desired) + return true; + config.auto_confirm_instant_warp = desired; + spdlog::info("Auto-confirm instant warp set to {}", value); + runtime_config::SaveWarpMode(value); + return true; +} + +void CycleWarpMode() +{ + auto& setting = WarpModeSetting().state(); + const auto snapshot = setting.Observe(); + if (snapshot.state.known()) + setting.SetFromUser((*snapshot.state.value + 1) % 3, snapshot); +} +} // namespace mod_settings diff --git a/mods/src/settings/warp_mode.h b/mods/src/settings/warp_mode.h new file mode 100644 index 000000000..175a540be --- /dev/null +++ b/mods/src/settings/warp_mode.h @@ -0,0 +1,14 @@ +#pragma once +#include "choice_setting.h" + +enum class InstantWarpConfirmation; + +namespace mod_settings +{ +// Shared mutation path for the shortcut and the forthcoming selection control. +// Changes live behavior immediately; persistence remains best-effort and reports +// failures through the existing runtime writer. Call on the game UI thread. +bool SetWarpMode(InstantWarpConfirmation desired); +void CycleWarpMode(); +ChoiceSetting& WarpModeSetting(); +} // namespace mod_settings diff --git a/mods/src/toml_editor.cc b/mods/src/toml_editor.cc new file mode 100644 index 000000000..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..3cf6898b3 --- /dev/null +++ b/tests/config_save_failure_test.cc @@ -0,0 +1,142 @@ +#include +#include +#include +#include +#if _WIN32 +#include +#include +#include +#include +#else +#include +#endif + +static std::filesystem::path staging; +static void CheckPrivateStaging(std::FILE* file) +{ +#if _WIN32 + PACL acl = nullptr; + PSECURITY_DESCRIPTOR security = nullptr; + const auto handle = file ? reinterpret_cast(_get_osfhandle(_fileno(file))) : nullptr; + const auto error = file ? GetSecurityInfo(handle, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, + nullptr, nullptr, &acl, nullptr, &security) + : GetNamedSecurityInfoW(const_cast(staging.c_str()), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, + nullptr, nullptr, &acl, nullptr, &security); + assert(error == ERROR_SUCCESS && acl && acl->AceCount == 2); + SECURITY_DESCRIPTOR_CONTROL control; + DWORD revision; + assert(GetSecurityDescriptorControl(security, &control, &revision) && (control & SE_DACL_PROTECTED)); + for (DWORD i = 0; i < acl->AceCount; ++i) { + void* entry = nullptr; + assert(GetAce(acl, i, &entry)); + const auto* ace = static_cast(entry); + assert(ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && !(ace->Header.AceFlags & INHERITED_ACE)); + wchar_t* sid = nullptr; + assert(ConvertSidToStringSidW(const_cast(&ace->SidStart), &sid)); + assert(std::wstring_view(sid) == L"S-1-3-4" || std::wstring_view(sid) == L"S-1-5-18"); + LocalFree(sid); + } + LocalFree(security); +#else + struct stat status; + assert((file ? ::fstat(fileno(file), &status) : ::stat(staging.c_str(), &status)) == 0); + assert((status.st_mode & 0777) == 0600); +#endif +} + +static bool failClose = false; +#if !_WIN32 +static bool retarget = false; +static std::filesystem::path retargetLink, retargetDestination; +#endif + +static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t count, std::FILE* file) +{ + for (const auto& entry : std::filesystem::directory_iterator(staging.parent_path())) + if (entry.path().filename().string().find(".tmp-") != std::string::npos) + staging = entry.path(); + CheckPrivateStaging(file); + bool complete = failClose; +#if !_WIN32 + complete |= retarget; +#endif + const auto written = std::fwrite(data, size, complete ? count : count / 2, file); + CheckPrivateStaging(file); + if (complete) + return written; + errno = ENOSPC; + return written; +} + +static int FailedClose(std::FILE* file) +{ + std::fclose(file); + CheckPrivateStaging(nullptr); +#if !_WIN32 + if (retarget) { + std::filesystem::remove(retargetLink); + std::filesystem::create_symlink(retargetDestination, retargetLink); + return 0; + } +#endif + 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"; + staging = path; + const std::string original = "# keep this exactly\nenabled = false\n"; + { + std::ofstream out(path, std::ios::binary); + out << original; + } +#if !_WIN32 + ::umask(0022); + std::filesystem::permissions(path, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write); +#endif + 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); + } + } +#if !_WIN32 + const auto other = root / "other.toml"; + { std::ofstream out(other); out << original; } + retargetLink = root / "selected.toml"; + retargetDestination = other.filename(); + std::filesystem::create_symlink(path.filename(), retargetLink); + staging = path; + retarget = true; + assert(!ReplaceConfigText(retargetLink, "enabled = true\n", original)); + assert(ReadConfigText(path) == original && ReadConfigText(other) == original); + assert(std::filesystem::read_symlink(retargetLink) == retargetDestination); + for (const auto& entry : std::filesystem::directory_iterator(root)) + assert(entry.path().filename().string().find(".tmp-") == std::string::npos); +#endif + 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..c4f713548 --- /dev/null +++ b/tests/config_save_test.cc @@ -0,0 +1,82 @@ +#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); + for (bool relative : {false, true}) { + const auto target = root / (relative ? "relative-target.toml" : "absolute-target.toml"); + const auto dangling = root / (relative ? "relative-link.toml" : "absolute-link.toml"); + std::filesystem::create_symlink(relative ? target.filename() : std::filesystem::absolute(target), dangling); + SaveConfigDocument(config, dangling); + assert(std::filesystem::is_symlink(dangling)); + assert(toml::parse_file(target.string())["enabled"].value() == false); + } +#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/hud_settings_persistence_test.cc b/tests/hud_settings_persistence_test.cc new file mode 100644 index 000000000..215613d1d --- /dev/null +++ b/tests/hud_settings_persistence_test.cc @@ -0,0 +1,56 @@ +#include "runtime_config_writer.h" +#include "patches/parts/runtime_config_keys.h" +#include +#include +#include +#include +#include + +namespace +{ +void Check(bool value) +{ + if (!value) throw std::runtime_error("HUD persistence fixture failed"); +} +} // namespace + +int main(int argc, char** argv) +{ + Check(argc == 2); + const std::filesystem::path directory(argv[1]); + std::filesystem::create_directories(directory); + const auto path = directory / "hud.toml"; + { + std::ofstream output(path); + output << "# keep this comment\n[ui]\nunrelated = true\n"; + for (const char* key : {"hud_q_trials", "hud_field_training", "hud_outposts", "hud_missions"}) + output << key << " = 'auto'\n"; + } + const auto initial = toml::parse_file(path.string()); + config_edit::RuntimeConfigWriter writer(path, std::nullopt); + for (const auto& [section, key] : config_edit::persisted_settings) { + const auto value = initial[section][key].value(); + Check(writer.Register(section, key, value ? std::optional(*value) : std::nullopt)); + } + for (const char* mode : {"always", "never", "auto"}) { + for (const char* key : {"hud_q_trials", "hud_field_training", "hud_outposts", "hud_missions"}) { + const auto revision = writer.Submit("ui", key, std::string(mode)); + Check(revision != 0); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (writer.LastCompletion().revision != revision || writer.HasWork()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(!writer.HasFailures()); + const auto loaded = toml::parse_file(path.string()); + Check(loaded["ui"][key].value() == mode); + Check(loaded["ui"]["unrelated"].value() == true); + } + } + writer.Stop(false); + while (!writer.PollStopped()) std::this_thread::yield(); + std::ifstream input(path); + std::string first_line; + std::getline(input, first_line); + Check(first_line == "# keep this comment"); +} diff --git a/tests/native_boolean_callback_test.cc b/tests/native_boolean_callback_test.cc new file mode 100644 index 000000000..9406db417 --- /dev/null +++ b/tests/native_boolean_callback_test.cc @@ -0,0 +1,93 @@ +#include "settings/native_boolean_callback.h" +#include +#include +#include + +namespace +{ +int donorCalls = 0, calls = 0; +bool Donor(Il2CppObject*, const MethodInfo*) +{ + ++donorCalls; + return false; +} +bool Getter(Il2CppObject*, const MethodInfo*) +{ + ++calls; + return true; +} +void Setter(Il2CppObject*, bool value, const MethodInfo*) +{ calls += value ? 10 : 20; } +Il2CppObject* invokedTarget = nullptr; +void Command(Il2CppObject* target, const MethodInfo*) +{ invokedTarget = target; } +Il2CppString text{}; +Il2CppString* TextGetter(Il2CppObject*, const MethodInfo*) +{ return &text; } +} // namespace +int main() +{ + Il2CppType boolType{}, voidType{}; + boolType.type = IL2CPP_TYPE_BOOLEAN; + voidType.type = IL2CPP_TYPE_VOID; + MethodInfo schema{}; + schema.return_type = &boolType; + schema.methodPointer = reinterpret_cast(Donor); + schema.virtualMethodPointer = schema.methodPointer; + mod_settings::NativeCallback get; + assert(get.Initialize(&schema, Getter)); + assert(!get.Initialize(&schema, Getter)); + assert(schema.methodPointer == reinterpret_cast(Donor)); + auto* owned = get.method(); + auto direct = reinterpret_cast(owned->methodPointer); + assert(direct(nullptr, owned)); + auto virt = reinterpret_cast(owned->virtualMethodPointer); + assert(virt(nullptr, owned)); + bool value = false; + owned->invoker_method(schema.methodPointer, owned, nullptr, nullptr, &value); + assert(value && calls == 3 && donorCalls == 0); + schema.return_type = &voidType; + const Il2CppType* parameters[] = {&boolType}; + schema.parameters_count = 1; + schema.parameters = parameters; + mod_settings::NativeCallback set; + assert(set.Initialize(&schema, Setter)); + void* args[] = {&value}; + owned = set.method(); + owned->invoker_method(nullptr, owned, nullptr, args, nullptr); + assert(calls == 13 && donorCalls == 0); + mod_settings::NativeCallback invalid; + assert(!invalid.Initialize(&schema, Getter)); + schema.parameters_count = 0; + schema.parameters = nullptr; + schema.flags = METHOD_ATTRIBUTE_SPECIAL_NAME | METHOD_ATTRIBUTE_RT_SPECIAL_NAME; + mod_settings::NativeCallback command; + assert(command.Initialize(&schema, Command)); + Il2CppObject token{}; + command.method()->invoker_method(nullptr, command.method(), &token, nullptr, nullptr); + assert(invokedTarget == &token); + invokedTarget = nullptr; + reinterpret_cast(command.method()->methodPointer)(&token, command.method()); + assert(invokedTarget == &token && donorCalls == 0); + schema.return_type = &boolType; + schema.flags = METHOD_ATTRIBUTE_VIRTUAL; + assert(!invalid.Initialize(&schema, Getter)); + schema.flags = 0; + schema.is_inflated = true; + assert(!invalid.Initialize(&schema, Getter)); + Il2CppType stringType{}; + stringType.type = IL2CPP_TYPE_STRING; + MethodInfo stringSchema{}; + stringSchema.return_type = &stringType; + mod_settings::NativeCallback stringGet; + assert(stringGet.Initialize(&stringSchema, TextGetter)); + auto* stringMethod = stringGet.method(); + Il2CppString* result = nullptr; + stringMethod->invoker_method(nullptr, stringMethod, nullptr, nullptr, &result); + assert(result == &text); + assert(reinterpret_cast(stringMethod->methodPointer)(nullptr, stringMethod) == &text); + assert(reinterpret_cast(stringMethod->virtualMethodPointer)(nullptr, stringMethod) == &text); + mod_settings::NativeCallback wrongString; + assert(!wrongString.Initialize(&schema, TextGetter)); + std::cout << "PASS owned callback: direct, virtual and runtime invoker paths; donor remains untouched\n"; +} diff --git a/tests/page_catalog_test.cc b/tests/page_catalog_test.cc new file mode 100644 index 000000000..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..035e5023e --- /dev/null +++ b/tests/run-config-save.ps1 @@ -0,0 +1,113 @@ +[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 /Imods/src "/I$TomlInclude" ` + tests/hud_settings_persistence_test.cc mods/src/runtime_config_writer.cc mods/src/toml_editor.cc mods/src/config_save.cc ` + /Febuild/config-save-test/hud-test.exe /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'HUD persistence test compilation failed.' } + & ./build/config-save-test/hud-test.exe (Join-Path $fixtureRoot 'hud') + if ($LASTEXITCODE -ne 0) { throw 'HUD persistence 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..c77c8d144 --- /dev/null +++ b/tests/run-config-save.sh @@ -0,0 +1,36 @@ +#!/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" + +clang++ -std=c++23 -pthread -I mods/src -I "$toml_include" \ + tests/hud_settings_persistence_test.cc mods/src/runtime_config_writer.cc \ + mods/src/toml_editor.cc mods/src/config_save.cc -o "$test_root/hud-test" +"$test_root/hud-test" "$test_root/hud" 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..b8c1dd32e --- /dev/null +++ b/tests/runtime_config_test.cc @@ -0,0 +1,138 @@ +#define CONFIG_RUNTIME_TEST "runtime_config_fixture.h" +#include "../mods/src/patches/parts/runtime_config.cc" +#include +#include +#include +#include +#include + +namespace +{ +config_edit::RuntimeConfigWriter fixture; +unsigned resumes = 0; +void Reset() +{ + fixture = {}; + writer = &fixture; + available = true; + owner = CurrentThreadToken(); + forcing = false; + persistence_unavailable = false; + reported_save_failure = false; + 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) +{ + for (const auto& [section, key] : config_edit::persisted_settings) { + assert(std::count_if(std::begin(config_edit::persisted_settings), std::end(config_edit::persisted_settings), + [&](const auto& entry) { + return std::string_view(entry.first) == section && std::string_view(entry.second) == key; + }) == 1); + } + 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"; +}