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..0ef4454aa --- /dev/null +++ b/docs/MOD_SETTINGS.md @@ -0,0 +1,121 @@ +# 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]` | +| Shortcuts | Binding editors grouped by action, with current-binding summaries | `[shortcuts]` | + +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). + +## Editor lifetime and native views + +Change and Add stay drafts until popup Confirm or Use anyway publishes the complete +action list. Remove, Restore and Undo publish immediately; there is no page-level +Apply step. Restore uses canonical defaults; `NONE` means unbound. Scrollable overlap +warnings are advisory and never remove another action's binding. +Uncategorized gives newly registered actions an editor before presentation +metadata is supplied; it does not discover arbitrary TOML values. See +[shortcut contracts and extension guidance](MOD_SHORTCUT_SETTINGS.md). + +The page owns cancellation through `Page::leave`. Navigation, controller +destruction and session invalidation end the visit. Recycling a row, conditional +filtering and section folding preserve the draft. Escape and focus loss cancel +recording; input stays owned until a focused sample sees all keys released. +The existing ScreenManager dispatcher samples keys only during capture. + +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 or global localization hook. +Platform guards, managed signature checks and hook ownership checks remain part +of installation. Windows and macOS use the same settings adapter, while +layout-aware capture is Windows-only and macOS uses physical keys. +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. + +Run the keyboard layout, chord and dispatch XMake fixtures for input changes. +Native editor checks include scrolling/folding, Back, held-key focus changes, +overlap inspection, live dispatch and restart persistence on an identified build. + +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..118013514 --- /dev/null +++ b/docs/MOD_SETTINGS_CONTROLS.md @@ -0,0 +1,168 @@ +# 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]` | +| Mod Settings > Shortcuts | Rebind registered 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. +The shortcut editor shares that foundation and its existing dispatcher. 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. + +## 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. + +The delivered shortcut editor reuses the parser and binding map, with Escape to +cancel capture, overlap feedback and explicit Remove. Capture owns keyboard input +through release; display labels are never serialized as key identities. See +[shortcut contracts](MOD_SHORTCUT_SETTINGS.md) for current publication and undo +behavior before extending the editor. + +## 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..f65a5366e --- /dev/null +++ b/docs/MOD_SETTINGS_NATIVE_ADAPTER.md @@ -0,0 +1,55 @@ +# 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. Owns page-departure callbacks and coordinates optional widget installation. | +| `settings/native/action_widgets.*` | Command identity, indexed presentation rows, visibility, invocation and release guards. Installs button-widget hooks for shortcut commands and the 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 polling hook, save worker or persistence path. Shortcut capture uses the + existing ScreenManager dispatcher; its callback returns immediately when idle. +- Feature-owned page-departure callbacks cancel editor visits. Row recycling + only releases presentation; it must not discard drafts during scrolling. +- 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. +Shortcut-specific checks are in the +[current contract](MOD_SETTINGS.md) and [editor guide](MOD_SHORTCUT_SETTINGS.md). diff --git a/docs/MOD_SETTINGS_NAVIGATION.md b/docs/MOD_SETTINGS_NAVIGATION.md new file mode 100644 index 000000000..378da934e --- /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 Shortcuts, 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/MOD_SHORTCUT_SETTINGS.md b/docs/MOD_SHORTCUT_SETTINGS.md new file mode 100644 index 000000000..1a1ae353f --- /dev/null +++ b/docs/MOD_SHORTCUT_SETTINGS.md @@ -0,0 +1,168 @@ +# Mod shortcut editor + +See [current settings architecture](MOD_SETTINGS.md) for common ownership, +navigation and failure-notice contracts. + +This editor builds on the native settings controls and keyboard-layout foundations. +It reuses the existing MapKey parser, binding list, layout mapper and TOML writer. + +The UI uses Settings > Mod Settings > Shortcuts, grouped by the action's effect: +Game Screens, Previews & Cargo, Interface Controls, Fleet Controls, Map & Travel, +Camera, Chat, Client and Diagnostics. These categories are direct children of +Shortcuts. Uncategorized appears immediately before Diagnostics when needed. +Categories are alphabetized with Diagnostics kept last; actions within +each category are alphabetized by their human labels. +Game Screens opens inventory, artifacts and other panels; Previews & Cargo changes +preview behavior; Interface Controls adjusts sizes, shortcut hints and search focus. +Moving between map views and cycling instant warp belongs to Map & Travel. Explicit presentation +metadata gives each action a human label without changing its config identity. +It is available in the Windows and macOS native settings adapters +when mod hotkeys are installed and Scopely hotkey mode is off. Only actions +registered by the existing configuration loader are listed. Their original +gameplay contexts and feature enablement still apply. +The Unity focus query must resolve before the editor is registered; otherwise +the editor is omitted and a warning is logged, without taking keyboard input. +Shortcut-hint editing is omitted if its adapter was not installed at startup +(including an initial `NONE` binding); the editor does not claim a live change +for a startup-disabled feature. + +## Adding an action + +Register the new action through the existing shortcut configuration loader and +implement its gameplay behavior as usual. The editor discovers registered actions +once at startup, including actions whose binding is `NONE`. It does not scan TOML +for unknown keys or infer new setting types from values. + +An entry in `ShortcutCatalog` supplies an optional human label and impact group. +Without that entry, the action gets its complete binding editor in Uncategorized, +with a fallback label derived from its canonical key: `toggle_new_feature` becomes +`Toggle new feature`. Existing empty-page pruning hides the group when unused. +Debug builds log a missing-override warning during registration; there is no +recurring discovery or warning during gameplay. + +Adding the presentation override later changes the label and location only. +The action, TOML key, bindings, default and existing availability gates remain +authoritative. Missing overrides are allowed; duplicate actions, invalid groups +and blank explicit labels still fail compile-time validation. Overlap warnings +use the same label resolver as the editor, including for uncategorized actions. + +This fallback creates shortcut editors only. A separate numeric-input widget is +follow-up work: registered numeric settings must define validation, precision and +live read/write behavior before being exposed. A TOML number alone is insufficient +to choose a range or control. + +## Behavior contract + +- Each binding has its own Change button, followed by Add shortcut, explicit + Remove buttons and Restore default last. There is no More options dropdown or + page-level Apply step. Inactive controls leave no gaps. +- Change and Add open a popup with the current binding, a captured draft, + scrollable overlap warnings, Cancel, Record again and Confirm (or Use anyway). + Edit one action and one binding at a time; preserve its other alternatives. +- Adding or replacing with a binding already on that action shows `Already bound` + and leaves Confirm disabled, without publishing or saving. Compare parsed key + and modifier groups, ignoring modifier order, repeated groups and alias spelling; + generic and sided modifiers remain distinct. Opening a page never cleans up TOML. +- Capture is a draft. Confirm publishes the complete action list once; Cancel, + Escape, leaving the page and losing focus discard the draft without saving. + Escape closes only the popup, without also navigating back through settings. +- Remove saves immediately, including existing duplicate entries. Removing the + last binding stores `NONE`. Restore immediately replaces the complete list with + its registered defaults and is hidden only when that complete list already matches. +- Successful Remove/Restore shows an amber notice at the top with Undo. Undo + restores the exact previous list, including ordering and duplicates, then quietly + clears the notice. Only the latest change is undoable, until another edit or page + departure; Undo refuses to overwrite a newer binding list. +- Overlaps are advisory: Use anyway keeps both actions' bindings. Never silently + remove another action's binding. Confirm rechecks warnings before publication. +- Capture and the popup own keyboard input through key release. The initiating + click and previously held keys cannot become a binding or leak into gameplay. + Native settings Back polling and EventSystem keyboard navigation are suspended + while the popup owns input; mouse buttons remain usable. Their prior states are + restored after dismissal and release. +- Store canonical `[shortcuts]` identities, not labels. Keep existing aliases + readable; a new canonical edit takes precedence without deleting an alias. +- Replacing an action prepares the complete list and its shortcut hint first, + then publishes them together on the game thread. Layout registration must + include newly introduced keys. Existing parsing/default fallback is unchanged. +- Read back the actual active binding list. A stale editor draft must not replace + a newer list. File conflicts/failures retain the live edit, show a quiet notice and go to the log, + consistently with the existing settings writer. +- The game's rebinding popup uses Unity InputActions and Scopely's persistence. + Reuse native UI only where mod ownership can be maintained; do not edit native + InputAction assets or cloud shortcut preferences to represent a mod binding. + +## Delivery checks + +First verify atomic action replacement, alternative preservation, fresh hints and +invalid/unbound behavior in the existing MapKey fixture. Then exercise the native +editor, capture ownership, Escape/focus/held-key cancellation, advisory conflicts, +live dispatch, immediate Remove/Restore, Undo, reopen and restart persistence on an +identified artifact. Exercise Enter capture separately from Enter confirmation. + +## Capture and presentation limits + +New recordings use either-side Ctrl/Alt/Shift/Win/Command modifiers. Command +is stored and displayed as `CMD-`, matching the existing parser and gameplay +input checks; Windows keys retain `WIN-`. When input reports both families for +a Command press, capture prefers `CMD-` without requiring a duplicate Windows +event. Existing sided +bindings and alternative ordering are retained until explicitly replaced. Escape +is reserved for cancelling the popup; existing Escape bindings remain readable. +Enter and keypad Enter can be recorded. Once a preview is ready, a fresh Enter +press confirms it; the press captured as a binding cannot also confirm that draft. +OS shortcuts can still be handled by the OS; this is not a global keyboard hook. +Layout capture supports the same character set as the existing mapper and rejects +ambiguous/unavailable characters rather than storing the wrong physical key. + +Overlap warnings list other mod actions whose resolved physical key and modifier +rules can match together. Bare `I` rejects modifiers, so it does not overlap +`SHIFT-I`. Explicit modifiers are minimum requirements: `SHIFT-I` and `CTRL-I` +can both match while Ctrl+Shift+I is held. Layout-required Shift and sided modifiers +are included. Contexts may still make an overlap intentional; this does not audit +Scopely or OS shortcuts. Gameplay dispatch rules remain unchanged. +Warnings appear together in the popup's scrollable area and change Confirm to +`Use anyway`. They disappear when the popup closes. Enter and keypad Enter also +warn that game dialogs may confirm, and about direct ship assignment when that +feature is enabled. These direct handlers are outside configurable MapKey overlap +checks; the warnings do not claim a complete audit of native or OS shortcuts. +Force close client is identified as such. Native shortcut variants explain that +they invoke the game's own shortcut behavior rather than direct screen navigation. + +The command row reuses ButtonAndTextOptionWidget. Its unique closed delegate +target is a plain managed Object owned by that native context. The cloned Object +constructor supplies only the `void()` instance callback schema; every executable +entry point is replaced, and no constructor/game method is called by the command. +Only a currently bound, visible, enabled row with that target may invoke it. +Release restores local text, button visibility and interactability overrides. +Rebinding the visible list preserves the draft; leaving the editor cancels it. +The page owns the visit callback; recycling the Add row cannot discard a draft. +An unfocused empty key sample cannot end input ownership: refocus and release +are required before gameplay shortcuts resume. + +Action definitions can produce indexed rows and hide individual presentations. +Indices are presentation identities, not persistent binding IDs: a draft still +compares the complete observed list before publication. The native page owns rows +up to its largest binding count and reuses them after removals/additions. Refresh +adds missing contexts, filters surplus/hidden rows and follows catalog order. +The native adapter's existing 128-child sanity bound still applies. Two rows per +binding plus up to eight fixed rows leave room for 60 bindings per action in the editor. +It rejects further UI additions before publishing. Existing longer TOML lists +stay live and saved in full: the UI explains that it shows the first 60, permits +replacement/removal, and exposes subsequent bindings as earlier ones are removed. +Opening an oversized list never rewrites it or removes unrelated settings pages. +Row refreshes run on settings actions and capture transitions. The popup uses the +existing frame dispatcher for focus, lifetime and confirmation checks only while +open, plus navigation restoration after dismissal; it does no idle key scan. +An unchanged visible list is not rebound. + +The reused command-widget detours resolve managed signatures and require distinct targets. +On client build261 (GameAssembly SHA256 +487af4bb9c697c353be9714359a97dddcece5dab872622a6c498a27bbfc44f40), +ButtonAndTextOptionWidget.SetWidgetData spans CFD5F0..CFD8A5 (693 bytes), and +OnAboutToReleaseContext spans CFD430..CFD53F (271 bytes). Both were checked against +the PE unwind table and disassembly; SPUD reserves 24 bytes. These are historical +measurements. Windows and macOS resolve the same methods from runtime metadata. Capture +on macOS stays in physical keyboard mode; `keyboard_layout_mode = "layout"` remains +Windows-only and reports `platform_unsupported` elsewhere. No additional ScreenManager detour is +installed: recording uses its existing dispatcher and does no idle input scan. 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/keyboard-letter-layout.md b/docs/keyboard-letter-layout.md new file mode 100644 index 000000000..a7ff0849b --- /dev/null +++ b/docs/keyboard-letter-layout.md @@ -0,0 +1,62 @@ +# Layout-aware shortcuts (Windows x64) + +The default `[control].keyboard_layout_mode = "physical"` preserves existing behavior. +Set it to `"layout"` and restart to interpret printable shortcut characters using +Windows' active keyboard layout. Subsequent layout changes apply live through +Unity device notifications. Release held keys before using the new layout. + +For a German layout: + +| Configured chord | Keys on a German keyboard | +| --- | --- | +| `/` | Shift+7 | +| `CTRL-'` | Ctrl+Shift+# | +| `ALT-^` | Alt+^ | +| `SHIFT-^` | Shift+^ | +| `Z` | Z (US physical Y position) | + +Remove old Y/Z workarounds when opting in. Uppercase letter tokens name keys; +they do not request Shift. Shift needed to produce punctuation is added to the +explicit configured modifiers. Side-specific modifiers retain their requirements. +Existing explicitly modified bindings accept extra modifiers; avoid overlapping +chords, since action dispatch order determines which matching action wins. + +**German users enabling experimental shortcuts:** the upstream defaults +`show_alliance_help = "SHIFT-'"` and `show_alliance_armada = "CTRL-'"` overlap +because German apostrophe already requires Shift. Help is checked first and +captures the Armada chord. Use the tested remap below in the existing sections: + +```toml +[control] +keyboard_layout_mode = "layout" +enable_experimental = true + +[shortcuts] +show_alliance = "ALT-^" +show_alliance_help = "SHIFT-^" +show_alliance_armada = "CTRL-'" +``` + +This keeps upstream modifier matching and shortcut defaults unchanged. + +Hints and runtime shortcut values retain configured TOML text. The internal +physical mapping is not a replacement hint. Named controls (function keys, arrows, +Space, mouse and numpad keys), hardcoded controls and Scopely shortcuts are unchanged. +Alliance Help and Armada retain the upstream `enable_experimental = true` requirement. + +The resolver uses Windows character-to-key translation and supported scan-code +positions. Unshifted dead keys use a non-composing fallback: no following Space +is needed for a shortcut, and lookup does not consume pending text accents. +Characters requiring inferred Ctrl/Alt/AltGr, unsupported positions and missing +characters are disabled individually. Explicit configured Ctrl/Alt still work. + +Mapping is cached per requested character and refreshed only at initialization or +a device notification. Held keys are suppressed through a layout transition until +released. Notification failure disables layout bindings until restart; there is no +polling or silent physical fallback. The runtime `[keyboard_mapping]` section gives +layout, generation and status; logs identify unresolved characters. + +Layout mode is currently supported on Windows x64. Other platforms retain default +physical behavior; explicitly requesting layout mode disables printable bindings +and reports `platform_unsupported`. The experimental macOS adapter and prototype +preview/diagnostic settings are not part of this implementation. 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..fb4cc525c 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -97,6 +97,11 @@ queue_enabled = true # Sæt denne til sand, hvis du foretrækker at bruge Scopelys genvejstaster use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- @@ -669,6 +674,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index dc626072b..d04dd211e 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -97,6 +97,11 @@ queue_enabled = true # Auf true setzen, um die Hotkeys von Scopely zu verwenden use_scopely_hotkeys = false +# Windows x64: "physical" behält Tastenpositionen bei; "layout" folgt dem aktiven Layout. +# Erforderliches Shift wird ergänzt; Ctrl/AltGr-Zeichen bleiben nicht unterstützt. +# Layoutwechsel gelten live; gehaltene Tasten vorher loslassen. Siehe docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Optionen # ------------------ @@ -669,6 +674,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index e1aed8205..9cf025b55 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -97,6 +97,11 @@ queue_enabled = true # If you prefer to use Scopely's hotkeys set this to true use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- @@ -669,6 +674,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 562af2608..fd1c51134 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -97,6 +97,11 @@ queue_enabled = true # If you prefer to use Scopely's hotkeys set this to true use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- @@ -669,6 +674,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 8dcc031a6..a225c55b3 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -97,6 +97,11 @@ queue_enabled = true # If you prefer to use Scopely's hotkeys set this to true use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- @@ -263,6 +268,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 +689,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 795c8f1c8..c332ad02d 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -97,6 +97,11 @@ queue_enabled = true # Si prefieres usar los atajos de Scopely, establece este valor en verdadero use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- @@ -669,6 +674,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 8bf856ea3..940090273 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -97,6 +97,11 @@ queue_enabled = true # Si vous préférez utiliser les raccourcis de Scopely mettez ça sur activer use_scopely_hotkeys = false +# Windows x64 : "physical" conserve les positions ; "layout" suit la disposition active. +# Maj nécessaire est ajouté ; les caractères nécessitant Ctrl/AltGr ne sont pas pris en charge. +# Les changements sont appliqués en direct ; relâchez les touches. Voir docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- @@ -669,6 +674,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 719b54a1d..30e574ae8 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -97,6 +97,11 @@ queue_enabled = true # Als je de Scopely sneltoetsen prefereerd over die van de mod, schakel dit dan aan use_scopely_hotkeys = false +# Windows x64: "physical" behoudt toetsposities; "layout" volgt de actieve indeling. +# Vereiste Shift wordt toegevoegd; tekens met Ctrl/AltGr worden niet ondersteund. +# Wisselen werkt direct; laat ingedrukte toetsen los. Zie docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- @@ -669,6 +674,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 6e1d0ca2a..7174bf332 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -97,6 +97,11 @@ queue_enabled = true # Включите этот параметр, если предпочитаете горячие клавиши Scopely use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- @@ -669,6 +674,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index ca88ac0b1..23d6b5cfb 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -97,6 +97,11 @@ queue_enabled = true # If you prefer Daq lo' Scopely's hotkeys cher this Daq teH use_scopely_hotkeys = false +# Windows x64: "physical" preserves positions; "layout" follows the active layout. +# Required Shift is inferred; characters requiring Ctrl/AltGr are unsupported. +# Layout changes apply live; release held keys. See docs/keyboard-letter-layout.md. +keyboard_layout_mode = "physical" + # Subgroup: Options # ----------------- @@ -669,6 +674,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/mods/src/config.cc b/mods/src/config.cc index b69377fbb..2dc7e6a0f 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1,6 +1,9 @@ #include "config.h" +#include "config_save.h" +#include "patches/runtime_config.h" #include "file.h" #include "patches/mapkey.h" +#include "patches/keyboard_layout.h" #include "prime/KeyCode.h" #include "ship_name_match.h" #include "str_utils.h" @@ -93,10 +96,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 +121,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() @@ -667,6 +669,7 @@ void set_shortcut_noop(toml::node_view sectionTable, toml::node_view void parse_config_shortcut_value(toml::table& new_config, std::string_view item, GameFunction gameFunction, std::string_view default_value, const ShortcutConfigValue& shortcut_value) { + MapKey::RegisterAction(gameFunction, item, default_value); auto section = "shortcuts"; auto source = "shortcuts_source"; @@ -703,6 +706,7 @@ void parse_config_shortcut_value(toml::table& new_config, std::string_view item, if (mapKey.Key != KeyCode::None) { keyAdded = true; + keyboard_layout::RegisterShortcut(mapKey.Key); MapKey::AddMappedKey(gameFunction, std::move(mapKey)); } else if (!wantedKey.empty()) { spdlog::warn("Invalid shortcut token [shortcuts].{} token='{}' value='{}'; ignoring token.", @@ -923,6 +927,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 = @@ -942,6 +948,14 @@ void Config::Load() get_config_or_default(config, parsed, "control", "hotkeys_extended", DCC::hotkeys_extended, write_config); this->use_scopely_hotkeys = get_config_or_default(config, parsed, "control", "use_scopely_hotkeys", DCC::use_scopely_hotkeys, write_config); + this->keyboard_layout_mode = get_config_or_default( + config, parsed, "control", "keyboard_layout_mode", std::string(DCC::keyboard_layout_mode), write_config); + if (this->keyboard_layout_mode != "physical" && this->keyboard_layout_mode != "layout") { + spdlog::warn("Invalid keyboard_layout_mode '{}'; using physical", this->keyboard_layout_mode); + this->keyboard_layout_mode = "physical"; + parsed["control"].as_table()->insert_or_assign("keyboard_layout_mode", this->keyboard_layout_mode); + } + keyboard_layout::Configure(this->keyboard_layout_mode); this->select_timer = get_config_or_default(config, parsed, "control", "select_timer", DCC::select_timer, write_config); this->enable_experimental = @@ -1046,6 +1060,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, @@ -1403,9 +1420,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 +1442,12 @@ void Config::Load() std::filesystem::remove(FILE_DEF_PARSED); } - Config::Save(parsed, File::Vars()); + keyboard_layout::InitializeDiagnostics(parsed); + 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..0d929cf47 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -184,6 +184,7 @@ class Config final bool hotkeys_enabled; bool hotkeys_extended; bool use_scopely_hotkeys; + std::string keyboard_layout_mode; bool use_presets_as_default; bool enable_experimental; float default_system_zoom; @@ -278,6 +279,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..26a46bd51 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -25,6 +25,7 @@ namespace Control constexpr bool hotkeys_enabled = true; constexpr bool hotkeys_extended = true; constexpr bool use_scopely_hotkeys = false; + constexpr const char* keyboard_layout_mode = "physical"; constexpr bool queue_enabled = true; constexpr auto select_timer = 500; } // namespace Control @@ -94,6 +95,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 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/key.cc b/mods/src/patches/key.cc index 2708cb858..2b11afcc8 100644 --- a/mods/src/patches/key.cc +++ b/mods/src/patches/key.cc @@ -77,6 +77,9 @@ const std::unordered_map Key::mappedKeys = { {"MOUSE6", KeyCode::Mouse6}, {"SPACE", KeyCode::Space}, {"MINUS", KeyCode::Minus}, + {"EQUAL", KeyCode::Equals}, + // '-' separates modifiers and '|' separates alternative bindings in config. + {"PIPE", KeyCode::Pipe}, {"_", KeyCode::Underscore}, {",", KeyCode::Comma}, {";", KeyCode::Semicolon}, @@ -227,6 +230,8 @@ void Key::ClaimDirectionalInput(KeyCode key) bool Key::IsDirectionalInputClaimed() { + if (shortcutCaptureActive || shortcutPopupActive) + return true; EnsureCurrentFrame(); auto claimed = false; @@ -291,6 +296,9 @@ void Key::EnsureCurrentFrame() } bool Key::Down(KeyCode key) +{ return !shortcutCaptureActive && !shortcutPopupActive && RawDown(key); } + +bool Key::RawDown(KeyCode key) { EnsureCurrentFrame(); @@ -305,6 +313,9 @@ bool Key::Down(KeyCode key) } bool Key::Pressed(KeyCode key) +{ return !shortcutCaptureActive && !shortcutPopupActive && RawPressed(key); } + +bool Key::RawPressed(KeyCode key) { EnsureCurrentFrame(); @@ -318,6 +329,23 @@ bool Key::Pressed(KeyCode key) return cacheKeyPressed[(int)key] == 1; } +std::string Key::Token(KeyCode key) +{ + // Pick a stable accepted token, with separator-safe spellings. Sorting the + // aliases makes the result independent of unordered_map iteration order. + if (key == KeyCode::Minus) + return "MINUS"; + if (key == KeyCode::Equals) + return "EQUAL"; + if (key == KeyCode::Pipe) + return "PIPE"; + std::string result; + for (const auto& [token, value] : mappedKeys) + if (value == key && (result.empty() || token < result)) + result = token; + return result; +} + bool Key::IsInputFocused() { EnsureCurrentFrame(); diff --git a/mods/src/patches/key.h b/mods/src/patches/key.h index 81211c2e0..05cd879ec 100644 --- a/mods/src/patches/key.h +++ b/mods/src/patches/key.h @@ -28,6 +28,13 @@ class Key static bool Pressed(KeyCode key); static bool Down(KeyCode key); + // Capture alone uses raw cached input. Every ordinary Key consumer observes + // the same suppression, including continuous zoom/pan and console shortcuts. + static bool RawPressed(KeyCode key); + static bool RawDown(KeyCode key); + inline static bool shortcutCaptureActive = false; // Game thread only. + inline static bool shortcutPopupActive = false; // Includes preview, not just capture. + static std::string Token(KeyCode key); static void ClaimDirectionalInput(KeyCode key); static bool IsDirectionalInputClaimed(); diff --git a/mods/src/patches/keyboard_layout.cc b/mods/src/patches/keyboard_layout.cc new file mode 100644 index 000000000..528535393 --- /dev/null +++ b/mods/src/patches/keyboard_layout.cc @@ -0,0 +1,217 @@ +#include "keyboard_layout.h" +#include "config.h" +#include "file.h" +#include "key.h" +#include "keyboard_layout_mapping.h" +#include "keyboard_layout_notifications.h" +#include "keyboard_layout_windows.h" +#include "str_utils.h" +#include +#include + +namespace keyboard_layout +{ +namespace +{ + bool enabled = false, initialized = false, failed = false, vars_ready = false; + RefreshState refresh; + BindingState bindings; + std::array requested{}, required_shift{}; + toml::table vars_snapshot; + std::string layout_name, status = "physical", reason = "configured_physical"; + unsigned generation = 0; + const MethodInfo * current_method = nullptr, *layout_method = nullptr; + int (*frame_count)() = nullptr; + + void WriteDiagnostics(toml::table& vars) + { + vars.insert_or_assign( + "keyboard_mapping", + toml::table{{"layout", layout_name}, {"status", status}, {"reason", reason}, {"generation", generation}}); + } + + void Publish() + { + ++generation; + if (vars_ready) { + // Diagnostics are best effort: a checked save must not unwind an input callback. + try { + WriteDiagnostics(vars_snapshot); + Config::Save(vars_snapshot, File::Vars()); + } + catch (const std::exception& error) { + static bool reported = false; + if (!reported) { + reported = true; + spdlog::warn("[KeyboardLayout] Could not save diagnostics: {}", error.what()); + } + } + } + spdlog::info("[KeyboardLayout] status={} layout='{}' generation={} reason={}", status, layout_name, generation, + reason); + } + + void Unavailable(std::string_view why) + { + bindings.Clear(); + required_shift = {}; + layout_name.clear(); + if (status == "unavailable" && reason == why) + return; + status = "unavailable"; + reason = why; + Publish(); + } + + void Disable(std::string_view why) + { + failed = true; + notifications::Stop(); + Unavailable(why); + } + + Il2CppObject* Invoke(const MethodInfo* method, void* self = nullptr) + { + Il2CppException* exception = nullptr; + auto* result = il2cpp_runtime_invoke(method, self, nullptr, &exception); + if (exception) { + Disable("unity_invocation_failed"); + return nullptr; + } + return result; + } + + void Initialize() + { + initialized = true; + const auto subscription = notifications::Start(refresh); + if (subscription != notifications::Result::Started) { + Disable(subscription == notifications::Result::Unsupported ? "platform_unsupported" + : "notification_subscription_failed"); + return; + } + auto keyboard = il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "Keyboard"); + current_method = keyboard.GetMethodInfo("get_current", 0); + layout_method = keyboard.GetMethodInfo("get_keyboardLayout", 0); + frame_count = il2cpp_resolve_icall_typed("UnityEngine.Time::get_frameCount()"); + if (!current_method || !layout_method || !frame_count) + Disable("missing_unity_api"); + } + + void Update() + { + if (!initialized) + Initialize(); + if (failed || !refresh.Consume()) + return; + auto* keyboard = Invoke(current_method); + if (!keyboard) { + if (!failed) + Unavailable("keyboard_absent"); + return; + } + auto* layout = reinterpret_cast(Invoke(layout_method, keyboard)); + if (!layout || !layout->length) { + if (!failed) + Unavailable("layout_unavailable"); + return; + } + layout_name = to_string(layout); + LayoutKeys keys{}; + std::array shift{}; + bool complete = true; + for (std::size_t index = 0; index < requested.size(); ++index) { + if (!requested[index]) + continue; +#if _WIN32 + const auto chord = ResolveWindowsChord(static_cast(index), layout_name); + keys[index] = chord.key; + shift[index] = chord.shift; +#endif + if (keys[index] == KeyCode::None) { + complete = false; + spdlog::warn("[KeyboardLayout] unresolved character '{}' ({}) on layout '{}'; binding disabled", + static_cast(index), index, layout_name); + } + } + bindings.Replace(keys, Key::Pressed, frame_count()); + required_shift = shift; + status = complete ? "resolved" : "partial"; + reason = complete ? "layout_lookup" : "unresolved_keys_disabled"; + Publish(); + } +} // namespace + +void Configure(std::string_view mode) +{ + enabled = mode == "layout"; + status = enabled ? "pending" : "physical"; + reason = enabled ? "awaiting_game_input" : "configured_physical"; +} + +void RegisterShortcut(KeyCode key) +{ + if (enabled && IsLayoutKey(key) && !requested[static_cast(key)]) { + requested[static_cast(key)] = true; + refresh.Invalidate(); // A runtime rebind can introduce a previously unused character. + } +} + +void InitializeDiagnostics(toml::table& vars) +{ + WriteDiagnostics(vars); + if (enabled) { + vars_snapshot = vars; + vars_ready = true; + } +} + +ResolvedChord ResolveChord(KeyCode configured) +{ + if (!enabled || !IsLayoutKey(configured)) + return {configured, false}; + Update(); + return {bindings.Resolve(configured, frame_count, Key::Pressed), required_shift[static_cast(configured)]}; +} + +ResolvedChord DescribeChord(KeyCode configured) +{ + if (!enabled || !IsLayoutKey(configured)) + return {configured, false}; + Update(); +#if _WIN32 + return ResolveWindowsChord(static_cast(configured), layout_name); +#else + return {}; +#endif +} + +KeyCode CaptureIdentity(KeyCode physical, bool shift) +{ + if (!enabled || !IsLayoutKey(physical)) + return physical; + Update(); + // Prefer the unshifted character and preserve the user's explicit modifier. + // Where a layout has no supported unshifted character, accept a shifted one + // only if Shift was held. Never call text composition APIs during capture. + for (bool inferred : {false, true}) { + if (inferred && !shift) + break; + KeyCode found = KeyCode::None; + for (int i = 0; i < static_cast(LayoutKeyCount); ++i) { + auto key = static_cast(i); + if (!IsLayoutKey(key) || Key::Token(key).empty()) + continue; + const auto chord = DescribeChord(key); + if (chord.key == physical && chord.shift == inferred) { + if (found != KeyCode::None) + return KeyCode::None; + found = key; + } + } + if (found != KeyCode::None) + return found; + } + return KeyCode::None; +} +} // namespace keyboard_layout diff --git a/mods/src/patches/keyboard_layout.h b/mods/src/patches/keyboard_layout.h new file mode 100644 index 000000000..d6b707a3f --- /dev/null +++ b/mods/src/patches/keyboard_layout.h @@ -0,0 +1,18 @@ +#pragma once +#include "keyboard_layout_mapping.h" +#include +#include + +namespace keyboard_layout +{ +// Config-time calls do not access Unity or change configured shortcut text. +void Configure(std::string_view mode); +void RegisterShortcut(KeyCode key); +void InitializeDiagnostics(toml::table& vars); +// Game thread only; refresh on device notifications, never by polling. +ResolvedChord ResolveChord(KeyCode configured); +// Editor-only queries, without held-key suppression. Capture is converted back +// to the configured character identity; layout ambiguity is rejected. +ResolvedChord DescribeChord(KeyCode configured); +KeyCode CaptureIdentity(KeyCode physical, bool shift); +} // namespace keyboard_layout diff --git a/mods/src/patches/keyboard_layout_mapping.h b/mods/src/patches/keyboard_layout_mapping.h new file mode 100644 index 000000000..5fcd6a850 --- /dev/null +++ b/mods/src/patches/keyboard_layout_mapping.h @@ -0,0 +1,138 @@ +#pragma once + +#include + +#include + +namespace keyboard_layout +{ +struct ResolvedChord { + KeyCode key = KeyCode::None; + bool shift = false; +}; +// Printable keys accepted by Key::Parse. Named controls (including Space and +// explicit numpad keys) keep their identity, independent of display-name lookup. +constexpr bool IsLayoutKey(KeyCode key) +{ + return (key >= KeyCode::Exclaim && key <= KeyCode::At) + || (key >= KeyCode::LeftBracket && key <= KeyCode::Tilde); +} + +constexpr std::size_t LayoutKeyCount = static_cast(KeyCode::Tilde) + 1; + +// Unity.InputSystem.Key and legacy UnityEngine.KeyCode are different enums. +// Explicit US-reference positions, including punctuation (French M is at US ';'). +constexpr KeyCode ToLegacyKey(int input_system_key) +{ + constexpr std::array keys = { + KeyCode::None, + KeyCode::Space, + KeyCode::Return, + KeyCode::Tab, + KeyCode::BackQuote, + KeyCode::Quote, + KeyCode::Semicolon, + KeyCode::Comma, + KeyCode::Period, + KeyCode::Slash, + KeyCode::Backslash, + KeyCode::LeftBracket, + KeyCode::RightBracket, + KeyCode::Minus, + KeyCode::Equals, + KeyCode::A, + KeyCode::B, + KeyCode::C, + KeyCode::D, + KeyCode::E, + KeyCode::F, + KeyCode::G, + KeyCode::H, + KeyCode::I, + KeyCode::J, + KeyCode::K, + KeyCode::L, + KeyCode::M, + KeyCode::N, + KeyCode::O, + KeyCode::P, + KeyCode::Q, + KeyCode::R, + KeyCode::S, + KeyCode::T, + KeyCode::U, + KeyCode::V, + KeyCode::W, + KeyCode::X, + KeyCode::Y, + KeyCode::Z, + KeyCode::Alpha1, + KeyCode::Alpha2, + KeyCode::Alpha3, + KeyCode::Alpha4, + KeyCode::Alpha5, + KeyCode::Alpha6, + KeyCode::Alpha7, + KeyCode::Alpha8, + KeyCode::Alpha9, + KeyCode::Alpha0, + }; + return input_system_key > 0 && input_system_key < static_cast(keys.size()) ? keys[input_system_key] + : KeyCode::None; +} + +using LayoutKeys = std::array; + +// Physical input caches remain physical. Only action bindings are translated. +// Suppress the transition frame and held keys until release, so a layout change +// cannot turn an existing hold into a different action. +class BindingState +{ +public: + void Clear() + { + keys_ = {}; + blocked_.fill(false); + transition_ = false; + } + + template void Replace(const LayoutKeys& keys, Held held, int frame) + { + keys_ = keys; + transition_ = true; + transition_frame_ = frame; + blocked_.fill(false); + for (auto key : keys_) { + if (key != KeyCode::None) + blocked_[static_cast(key)] = held(key); + } + } + + template KeyCode Resolve(KeyCode configured, Frame frame, Held held) + { + if (!IsLayoutKey(configured)) + return configured; + const auto key = keys_[static_cast(configured)]; + if (key == KeyCode::None) + return key; + if (transition_) { + if (frame() == transition_frame_) + return KeyCode::None; + transition_ = false; + } + auto& blocked = blocked_[static_cast(key)]; + if (blocked) { + if (held(key)) + return KeyCode::None; + blocked = false; + } + return key; + } + +private: + LayoutKeys keys_{}; + std::array(KeyCode::Max)> blocked_{}; + bool transition_ = false; + int transition_frame_ = -1; +}; +} // namespace keyboard_layout diff --git a/mods/src/patches/keyboard_layout_notifications.cc b/mods/src/patches/keyboard_layout_notifications.cc new file mode 100644 index 000000000..1c2244db4 --- /dev/null +++ b/mods/src/patches/keyboard_layout_notifications.cc @@ -0,0 +1,154 @@ +#include "keyboard_layout_notifications.h" + +// Use the tested Windows x64 delegate ABI. +#if defined(_WIN32) && (defined(_M_X64) || defined(__x86_64__)) +#include "il2cpp/il2cpp_helper.h" + +namespace keyboard_layout::notifications +{ +namespace +{ + struct State { + MethodInfo native_method{}; // Private copy, never patch the game's metadata. + const MethodInfo * add = nullptr, *remove = nullptr; + Il2CppGCHandle root = 0; + RefreshState* refresh = nullptr; + std::atomic accepting{false}; + bool attempted = false, subscribed = false, active = false; + }; + + State& Get() + { + // Retain metadata even if removal fails. There is no supported live DLL unload. + static auto* state = new State; + return *state; + } + + void Changed(void*, int32_t change, const MethodInfo*) noexcept + { + auto& state = Get(); + if (!state.accepting.load()) + return; + // Added/Removed/Disconnected/Reconnected/Enabled/Disabled (0..5), + // or ConfigurationChanged (7). Any device can change which keyboard is current; + // conservatively invalidate without polling or touching Unity in the callback. + if ((change >= 0 && change <= 5) || change == 7) + state.refresh->Invalidate(); + } + + bool Invoke(const MethodInfo* method, void* self, void** args) + { + Il2CppException* exception = nullptr; + il2cpp_runtime_invoke(method, self, args, &exception); + return exception == nullptr; + } + + bool Create() + { + auto& state = Get(); + auto input = il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputSystem"); + auto actions = il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputActionState"); + state.add = input.GetMethodInfo("add_onDeviceChange", 1); + state.remove = input.GetMethodInfo("remove_onDeviceChange", 1); + const auto* signature = actions.GetMethodInfo("OnDeviceChange", 2); + if (!state.add || !state.remove || !signature) + return false; + const auto* delegate_type = il2cpp_method_get_param(state.add, 0); + if (!delegate_type || il2cpp_type_is_byref(delegate_type) + || !il2cpp_type_equals(delegate_type, il2cpp_method_get_param(state.remove, 0))) + return false; + auto* delegate_class = il2cpp_class_from_type(delegate_type); + if (!delegate_class) + return false; + const auto* ctor = il2cpp_class_get_method_from_name(delegate_class, ".ctor", 2); + const auto* invoke = il2cpp_class_get_method_from_name(delegate_class, "Invoke", 2); + if (!ctor || !ctor->methodPointer || !ctor->invoker_method || !invoke || !(signature->flags & 0x0010) + || signature->is_generic || signature->is_inflated || signature->parameters_count != 2 + || il2cpp_type_get_type(signature->return_type) != IL2CPP_TYPE_VOID + || il2cpp_type_get_type(invoke->return_type) != IL2CPP_TYPE_VOID) + return false; + // Validate complete by-value shapes as well as class identity before borrowing metadata. + for (unsigned i = 0; i < 2; ++i) { + const auto* param = il2cpp_method_get_param(signature, i); + const auto* target = il2cpp_method_get_param(invoke, i); + if (!param || !target || il2cpp_type_is_byref(param) || il2cpp_type_is_byref(target) + || !il2cpp_type_equals(param, target) + || il2cpp_type_get_type(param) != (i == 0 ? IL2CPP_TYPE_CLASS : IL2CPP_TYPE_VALUETYPE)) + return false; + if (i == 1) { + auto* enum_class = il2cpp_class_from_type(param); + if (!enum_class || !il2cpp_class_is_enum(enum_class)) + return false; + const auto* base = il2cpp_class_enum_basetype(enum_class); + if (!base || il2cpp_type_get_type(base) != IL2CPP_TYPE_I4) + return false; + } + } + auto* delegate = il2cpp_object_new(delegate_class); + if (!delegate) + return false; + state.root = il2cpp_gchandle_new(delegate, true); + if (!state.root) + return false; + state.native_method = *signature; + state.native_method.methodPointer = reinterpret_cast(&Changed); + state.native_method.virtualMethodPointer = state.native_method.methodPointer; + const MethodInfo* entry = &state.native_method; + void* ctor_args[]{nullptr, &entry}; + // Follow libil2cpp Type::InvokeDelegateConstructor: the client's generated + // invoker handles its platform's constructor ABI. Do not cast the constructor + // to a Windows-specific native signature. runtime_invoke's special delegate + // constructor path rejects the static null target on the researched client. + try { + ctor->invoker_method(ctor->methodPointer, ctor, delegate, ctor_args, nullptr); + } catch (...) { + return false; + } + auto* typed = reinterpret_cast(delegate); + if (typed->method != entry || typed->method_ptr != state.native_method.methodPointer) + return false; + state.accepting = true; + state.subscribed = true; // An exception need not mean the event was untouched. + void* args[]{delegate}; + return Invoke(state.add, nullptr, args); + } +} // namespace + +Result Start(RefreshState& refresh) +{ + auto& state = Get(); + if (!state.attempted) { + state.attempted = true; + state.refresh = &refresh; + state.active = Create(); + if (!state.active) + Stop(); + } + return state.active ? Result::Started : Result::Failed; +} + +void Stop() +{ + auto& state = Get(); + state.accepting = false; + state.active = false; + if (state.subscribed) { + auto* delegate = il2cpp_gchandle_get_target(state.root); + void* args[]{delegate}; + if (!delegate || !Invoke(state.remove, nullptr, args)) + return; // Preserve the root and metadata if listener removal is uncertain. + state.subscribed = false; + } + if (state.root) + il2cpp_gchandle_free(state.root); + state.root = 0; +} +} // namespace keyboard_layout::notifications +#else +namespace keyboard_layout::notifications +{ +Result Start(RefreshState&) +{ return Result::Unsupported; } +void Stop() {} +} // namespace keyboard_layout::notifications +#endif diff --git a/mods/src/patches/keyboard_layout_notifications.h b/mods/src/patches/keyboard_layout_notifications.h new file mode 100644 index 000000000..d5015e0a3 --- /dev/null +++ b/mods/src/patches/keyboard_layout_notifications.h @@ -0,0 +1,13 @@ +#pragma once + +#include "keyboard_layout_refresh.h" +#include + +namespace keyboard_layout::notifications +{ +// Game-thread calls only. The observer and refresh state must live until process exit. +enum class Result { Started, Unsupported, Failed }; +// Unsupported platforms or subscription failure disable layout mode; never poll. +Result Start(RefreshState& refresh); +void Stop(); +} // namespace keyboard_layout::notifications diff --git a/mods/src/patches/keyboard_layout_refresh.h b/mods/src/patches/keyboard_layout_refresh.h new file mode 100644 index 000000000..261f3ada5 --- /dev/null +++ b/mods/src/patches/keyboard_layout_refresh.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace keyboard_layout +{ +// Shared with the notification callback; all other mapping state stays on the game thread. +class RefreshState +{ +public: + void Invalidate() noexcept + { dirty_.store(true); } + + bool Consume() + { + // Quiet queries do not need a read-modify-write, a frame clock, or Unity calls. + return dirty_.load() && dirty_.exchange(false); + } + +private: + std::atomic dirty_{true}; +}; +} // namespace keyboard_layout diff --git a/mods/src/patches/keyboard_layout_windows.h b/mods/src/patches/keyboard_layout_windows.h new file mode 100644 index 000000000..7e626c752 --- /dev/null +++ b/mods/src/patches/keyboard_layout_windows.h @@ -0,0 +1,70 @@ +#pragma once + +#if _WIN32 +#include "keyboard_layout_mapping.h" + +#include +#include + +namespace keyboard_layout +{ +inline constexpr unsigned kWindowsLayoutScans[]{ + 0x29, 0x28, 0x27, 0x33, 0x34, 0x35, 0x2b, 0x1a, 0x1b, 0x0c, 0x0d, 0x1e, 0x30, 0x2e, 0x20, 0x12, + 0x21, 0x22, 0x23, 0x17, 0x24, 0x25, 0x26, 0x32, 0x31, 0x18, 0x19, 0x10, 0x13, 0x1f, 0x14, 0x16, + 0x2f, 0x11, 0x2d, 0x15, 0x2c, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, +}; + +// Scan codes for the same supported US-reference positions as ToLegacyKey(4..50). +// These are physical positions, not a German (or other language) character table. +inline KeyCode FindUnshiftedDeadKey(char character, HKL layout) +{ + if (!layout) + return KeyCode::None; + KeyCode result = KeyCode::None; + for (unsigned index = 0; index < std::size(kWindowsLayoutScans); ++index) { + const auto vk = MapVirtualKeyExW(kWindowsLayoutScans[index], MAPVK_VSC_TO_VK_EX, layout); + const auto value = vk ? MapVirtualKeyExW(vk, MAPVK_VK_TO_CHAR, layout) : 0; + // MAPVK_VK_TO_CHAR marks dead keys with its top bit. Unlike ToUnicodeEx, + // this query does not perform text composition or consume a pending accent. + if ((value & 0x80000000u) && (value & 0xffffu) == static_cast(character)) { + if (result != KeyCode::None) + return KeyCode::None; // Ambiguous positions must not choose an arbitrary key. + result = ToLegacyKey(4 + index); + } + } + return result; +} + +inline ResolvedChord FindWindowsChord(char character, HKL layout) +{ + if (!layout) + return {}; + // Uppercase config tokens name letters, not uppercase text. + if (character >= 'A' && character <= 'Z') + character += 'a' - 'A'; + const auto translated = VkKeyScanExW(static_cast(character), layout); + if (translated == -1) + return {FindUnshiftedDeadKey(character, layout), false}; + const auto modifiers = (static_cast(translated) >> 8) & 0xff; + // Inferring Ctrl/Alt (including AltGr) needs a separate modifier policy. + if (modifiers & ~1u) + return {}; + const auto scan = MapVirtualKeyExW(static_cast(translated) & 0xff, MAPVK_VK_TO_VSC_EX, layout); + for (unsigned index = 0; index < std::size(kWindowsLayoutScans); ++index) { + if (scan == kWindowsLayoutScans[index]) + return {ToLegacyKey(4 + index), (modifiers & 1) != 0}; + } + return {}; +} + +inline ResolvedChord ResolveWindowsChord(char character, std::string_view unity_layout) +{ + const auto layout = GetKeyboardLayout(0); + char name[KL_NAMELENGTH]{}; + if (!layout || !GetKeyboardLayoutNameA(name) || unity_layout != name) + return {}; + const auto chord = FindWindowsChord(character, layout); + return GetKeyboardLayout(0) == layout ? chord : ResolvedChord{}; +} +} // namespace keyboard_layout +#endif diff --git a/mods/src/patches/mapkey.cc b/mods/src/patches/mapkey.cc index a3234eeba..8ac38743d 100644 --- a/mods/src/patches/mapkey.cc +++ b/mods/src/patches/mapkey.cc @@ -1,10 +1,12 @@ #include "mapkey.h" #include "gamefunctions.h" #include "key.h" +#include "keyboard_layout.h" #include "modifierkey.h" #include "str_utils.h" #include +#include #include #include #include @@ -61,6 +63,7 @@ constexpr auto kCompactShortcutTokenMappings = std::to_array(gameFunction)) = {std::string(key), std::string(defaultBinding)}; } + +const MapKey::ActionDefinition& MapKey::Definition(GameFunction gameFunction) +{ return definitions.at(static_cast(gameFunction)); } + +const std::vector& MapKey::Bindings(GameFunction gameFunction) +{ return mappedKeys.at(static_cast(gameFunction)); } + +bool MapKey::ReplaceBindings(GameFunction gameFunction, std::vector bindings) +{ + if (gameFunction < 0 || gameFunction >= GameFunction::Max) + return false; + for (const auto& binding : bindings) + if (binding.Key == KeyCode::None) + return false; + if (!bindings.empty()) + bindings.front().shortcutHint = CompactShortcutForHint(bindings.front().Shortcuts); + for (const auto& binding : bindings) + keyboard_layout::RegisterShortcut(binding.Key); + mappedKeys[gameFunction].swap(bindings); + return true; +} + // Movement reads the direction itself; claiming it would suppress its own pan // on the next frame. Other modified actions retain ownership until key release. static bool IsMovementAction(GameFunction action) @@ -214,11 +241,13 @@ bool MapKey::IsPressed(GameFunction gameFunction) { const auto &mapKeys = MapKey::mappedKeys[(int)gameFunction]; for (const MapKey &mapKey : mapKeys) { - if (mapKey.Key != KeyCode::None) { - if (Key::Pressed(mapKey.Key)) { - if (MapKey::HasCorrectModifiers(mapKey)) { - if (mapKey.hasModifiers && !IsMovementAction(gameFunction)) { - Key::ClaimDirectionalInput(mapKey.Key); + const auto chord = keyboard_layout::ResolveChord(mapKey.Key); + const auto key = chord.key; + if (key != KeyCode::None) { + if (Key::Pressed(key)) { + if (MapKey::HasCorrectModifiers(mapKey, chord.shift)) { + if ((mapKey.hasModifiers || chord.shift) && !IsMovementAction(gameFunction)) { + Key::ClaimDirectionalInput(key); } return true; } @@ -233,11 +262,13 @@ bool MapKey::IsDown(GameFunction gameFunction) { const auto &mapKeys = MapKey::mappedKeys[(int)gameFunction]; for (const MapKey &mapKey : mapKeys) { - if (mapKey.Key != KeyCode::None) { - if (Key::Down(mapKey.Key)) { - if (MapKey::HasCorrectModifiers(mapKey)) { - if (mapKey.hasModifiers && !IsMovementAction(gameFunction)) { - Key::ClaimDirectionalInput(mapKey.Key); + const auto chord = keyboard_layout::ResolveChord(mapKey.Key); + const auto key = chord.key; + if (key != KeyCode::None) { + if (Key::Down(key)) { + if (MapKey::HasCorrectModifiers(mapKey, chord.shift)) { + if ((mapKey.hasModifiers || chord.shift) && !IsMovementAction(gameFunction)) { + Key::ClaimDirectionalInput(key); } return true; } @@ -248,13 +279,28 @@ bool MapKey::IsDown(GameFunction gameFunction) return false; } -bool MapKey::HasCorrectModifiers(const MapKey& mapKey) +bool MapKey::HasCorrectModifiers(const MapKey& mapKey, bool requiredShift) { + if (requiredShift && !Key::Pressed(KeyCode::LeftShift) && !Key::Pressed(KeyCode::RightShift)) + return false; auto result = false; std::string section = "non set"; if (!mapKey.hasModifiers) { section = "no modifiers"; result = !Key::IsModified(); + if (requiredShift) { + // Shift is part of typing this character. Other modifiers still prevent + // an unmodified action from stealing Ctrl/Alt/Command shortcuts. + result = true; + for (auto key : {KeyCode::LeftControl, KeyCode::RightControl, KeyCode::LeftAlt, KeyCode::RightAlt, + KeyCode::AltGr, KeyCode::LeftCommand, KeyCode::RightCommand, + KeyCode::LeftWindows, KeyCode::RightWindows}) { + if (Key::Pressed(key)) { + result = false; + break; + } + } + } } else { result = true; for (const ModifierKey& modifier : mapKey.Modifiers) { @@ -275,6 +321,43 @@ bool MapKey::HasCorrectModifiers(const MapKey& mapKey) return result; } +bool MapKey::SameBinding(const MapKey& first, const MapKey& second) +{ + const auto containsAll = [](const auto& first, const auto& second) { + return std::all_of(first.begin(), first.end(), [&](const auto& modifier) { + return std::find(second.begin(), second.end(), modifier) != second.end(); + }); + }; + return first.Key != KeyCode::None && first.Key == second.Key && containsAll(first.Modifiers, second.Modifiers) + && containsAll(second.Modifiers, first.Modifiers); +} + +bool MapKey::MayOverlap(const MapKey& first, const MapKey& second) +{ + const auto a = keyboard_layout::DescribeChord(first.Key); + const auto b = keyboard_layout::DescribeChord(second.Key); + if (a.key == KeyCode::None || a.key != b.key) + return false; + + // Keep this aligned with HasCorrectModifiers: explicit modifiers are minimum + // requirements, so holding their union can satisfy both (including both sides). + if (first.hasModifiers && second.hasModifiers) + return true; + if (!first.hasModifiers && !second.hasModifiers) + return a.shift == b.shift; + + // Bare bindings reject modifiers, except Shift required to type a character. + // Thus plain I cannot overlap SHIFT-I; a layout's bare '/' may overlap SHIFT-7. + const auto& modified = first.hasModifiers ? first : second; + const auto bare = first.hasModifiers ? b : a; + if (!bare.shift) + return false; + for (const auto& modifier : modified.Modifiers) + if (!modifier.Contains(KeyCode::LeftShift) && !modifier.Contains(KeyCode::RightShift)) + return false; + return true; +} + std::string MapKey::GetParsedValues() const { std::string output = ""; @@ -289,3 +372,4 @@ std::string MapKey::GetParsedValues() const } std::array, (int)GameFunction::Max> MapKey::mappedKeys = {}; +std::array MapKey::definitions = {}; diff --git a/mods/src/patches/mapkey.h b/mods/src/patches/mapkey.h index b5d73bedf..d7f6d95db 100644 --- a/mods/src/patches/mapkey.h +++ b/mods/src/patches/mapkey.h @@ -12,14 +12,30 @@ class MapKey { public: + struct ActionDefinition { + std::string key, defaultBinding; + }; MapKey(); static MapKey Parse(std::string_view key); static void AddMappedKey(GameFunction gameFunction, MapKey mappedKey); - static bool HasBinding(GameFunction gameFunction); - static bool IsPressed(GameFunction gameFunction); - static bool IsDown(GameFunction gameFunction); - static bool HasCorrectModifiers(const MapKey& mapKey); + // Config registers canonical storage identities, including unbound actions. + static void RegisterAction(GameFunction gameFunction, std::string_view key, std::string_view defaultBinding); + static const ActionDefinition& Definition(GameFunction gameFunction); + static const std::vector& Bindings(GameFunction gameFunction); + // UI-thread replacement of one complete action. Prepare before publishing so + // a failed allocation cannot leave a partially edited binding list. + static bool ReplaceBindings(GameFunction gameFunction, std::vector bindings); + static bool HasBinding(GameFunction gameFunction); + static bool IsPressed(GameFunction gameFunction); + static bool IsDown(GameFunction gameFunction); + static bool HasCorrectModifiers(const MapKey& mapKey, bool requiredShift = false); + // Whether some held modifier combination can match both bindings under the + // current layout. Editor-only advisory; action contexts are not considered. + static bool MayOverlap(const MapKey& first, const MapKey& second); + // Configured identity, independent of layout: modifier order and aliases do + // not create another binding, but generic and sided modifiers stay distinct. + static bool SameBinding(const MapKey& first, const MapKey& second); static std::string GetShortcuts(GameFunction gameFunction); static std::string GetShortcutHint(GameFunction gameFunction); @@ -35,6 +51,7 @@ class MapKey private: static std::array, (int)GameFunction::Max> mappedKeys; + static std::array definitions; bool hasModifiers; std::string shortcutHint; diff --git a/mods/src/patches/modifierkey.cc b/mods/src/patches/modifierkey.cc index d9205193e..bb0793fe9 100644 --- a/mods/src/patches/modifierkey.cc +++ b/mods/src/patches/modifierkey.cc @@ -3,6 +3,7 @@ #include "key.h" #include "modifierkey.h" +#include #include #include #include @@ -12,6 +13,9 @@ bool ModifierKey::HasModifiers() const return this->hasModifier; } +bool ModifierKey::operator==(const ModifierKey& other) const +{ return std::is_permutation(Modifiers.begin(), Modifiers.end(), other.Modifiers.begin(), other.Modifiers.end()); } + ModifierKey::ModifierKey() { this->hasModifier = false; diff --git a/mods/src/patches/modifierkey.h b/mods/src/patches/modifierkey.h index a74587bca..2c4fd4455 100644 --- a/mods/src/patches/modifierkey.h +++ b/mods/src/patches/modifierkey.h @@ -21,6 +21,7 @@ struct ModifierKey bool IsPressed() const; bool IsDown() const; bool HasModifiers() const; + bool operator==(const ModifierKey& other) const; std::string GetParsedValues() const; diff --git a/mods/src/patches/parts/fc_confirmation_reset.cc b/mods/src/patches/parts/fc_confirmation_reset.cc new file mode 100644 index 000000000..3eae8a7e5 --- /dev/null +++ b/mods/src/patches/parts/fc_confirmation_reset.cc @@ -0,0 +1,165 @@ +#include "fc_confirmation_reset.h" +#include "settings/boolean_settings.h" +#include +#include + +namespace +{ +using namespace mod_settings; +constexpr const char* PreferenceKey = "options/hide_fcaa_use_confirmation"; + +bool ReferenceField(const FieldInfo* field) +{ + if (!field || field->type->byref) + return false; + const auto type = field->type->type; + return type == IL2CPP_TYPE_CLASS || type == IL2CPP_TYPE_GENERICINST || type == IL2CPP_TYPE_OBJECT; +} +Il2CppObject* ExistingSingleton(IL2CppClassHelper& helper) +{ + auto parent = helper.GetParent("MonoSingleton`1"); + auto* cls = parent.get_cls(); + auto* instanceField = cls ? il2cpp_class_get_field_from_name(cls, "s_instance") : nullptr; + auto* initializedField = cls ? il2cpp_class_get_field_from_name(cls, "s_initialized") : nullptr; + if (!ReferenceField(instanceField) || !(instanceField->type->attrs & FIELD_ATTRIBUTE_STATIC) || !initializedField + || initializedField->type->type != IL2CPP_TYPE_BOOLEAN + || !(initializedField->type->attrs & FIELD_ATTRIBUTE_STATIC)) + return nullptr; + bool initialized = false; + il2cpp_field_static_get_value(initializedField, &initialized); + if (!initialized) + return nullptr; + Il2CppObject* instance = nullptr; + il2cpp_field_static_get_value(instanceField, &instance); + return instance && il2cpp_class_is_assignable_from(helper.get_cls(), instance->klass) ? instance : nullptr; +} +bool BoolField(const FieldInfo* field) +{ + return field && field->type->type == IL2CPP_TYPE_BOOLEAN && !field->type->byref + && !(field->type->attrs & FIELD_ATTRIBUTE_STATIC); +} +bool InstanceMethod(const MethodInfo* method, int count, int result) +{ + return method && method->parameters_count == count && method->return_type->type == result + && !method->return_type->byref && !(method->flags & METHOD_ATTRIBUTE_STATIC); +} + +struct Binding { + IL2CppClassHelper prefs = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.PersistentPrefs", "PersistentPrefsManager"); + IL2CppClassHelper fc = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.FleetCommander", "FleetCommanderManager"); + const MethodInfo* get = prefs.GetMethodInfo("GetBool", 3); + const MethodInfo* set = fc.GetMethodInfo("set_HideFleetCommanderAbilityUseConfirmationHidden", 1); + FieldInfo* loaded = + prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_isCloudFileLoaded") : nullptr; + FieldInfo* loading = prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_isLoading") : nullptr; + FieldInfo* saved = prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_savedData") : nullptr; + // Two bounded weak roots track storage replacement without retaining account data. + // Cleared on invalidation while IL2CPP is live, not in a process-exit destructor. + Il2CppGCHandle owner = nullptr, data = nullptr, key = nullptr; + std::uint64_t generation = 1; + bool supported() const + { + return BoolField(loaded) && BoolField(loading) && ReferenceField(saved) + && !(saved->type->attrs & FIELD_ATTRIBUTE_STATIC) && InstanceMethod(get, 3, IL2CPP_TYPE_BOOLEAN) + && get->parameters[0]->type == IL2CPP_TYPE_STRING && !get->parameters[0]->byref + && get->parameters[1]->type == IL2CPP_TYPE_BOOLEAN && !get->parameters[1]->byref + && get->parameters[2]->type == IL2CPP_TYPE_BOOLEAN && !get->parameters[2]->byref + && InstanceMethod(set, 1, IL2CPP_TYPE_VOID) && set->parameters[0]->type == IL2CPP_TYPE_BOOLEAN + && !set->parameters[0]->byref; + } + void invalidate() + { + if (owner) + il2cpp_gchandle_free(owner); + if (data) + il2cpp_gchandle_free(data); + owner = data = nullptr; + ++generation; + } + Il2CppObject* ready() + { + auto* p = ExistingSingleton(prefs); + bool isLoaded = false, isLoading = true; + Il2CppObject* state = nullptr; + if (p) { + il2cpp_field_get_value(p, loaded, &isLoaded); + il2cpp_field_get_value(p, loading, &isLoading); + il2cpp_field_get_value(p, saved, &state); + } + if (!p || !state || !isLoaded || isLoading) { + if (owner || data) + invalidate(); + return nullptr; + } + if (!owner || !data || il2cpp_gchandle_get_target(owner) != p || il2cpp_gchandle_get_target(data) != state) { + invalidate(); + owner = il2cpp_gchandle_new_weakref(p, false); + data = il2cpp_gchandle_new_weakref(state, false); + if (!owner || !data) { + invalidate(); + return nullptr; + } + } + return p; + } +}; +Binding& Backend() +{ + static Binding binding; + return binding; +} + +ReadResult ReadConfirmation() +{ + auto& b = Backend(); + if (!b.supported()) + return {Availability::Unsupported}; + auto* prefs = b.ready(); + if (!prefs) + return {}; + if (!b.key) + b.key = il2cpp_gchandle_new(reinterpret_cast(il2cpp_string_new(PreferenceKey)), false); + if (!b.key) + return {}; + // FC's native property uses GetBool(key,false,true). Display reads must not insert + // a missing preference: preserve its default but explicitly pass shouldAddKey=false. + bool defaultHidden = false, addKey = false; + void* args[] = {il2cpp_gchandle_get_target(b.key), &defaultHidden, &addKey}; + Il2CppException* exception = nullptr; + auto* value = il2cpp_runtime_invoke(b.get, prefs, args, &exception); + if (exception || !value) + return {}; + return ReadResult::Known(!*static_cast(il2cpp_object_unbox(value)), b.generation); +} +ApplyResult WriteConfirmation(bool enabled, std::uint64_t expectedGeneration) +{ + auto& b = Backend(); + if (!b.supported() || !b.ready() || b.generation != expectedGeneration) + return ApplyResult::Rejected; + auto* manager = ExistingSingleton(b.fc); + if (!manager) + return ApplyResult::Rejected; + bool hidden = !enabled; + void* args[] = {&hidden}; + Il2CppException* exception = nullptr; + il2cpp_runtime_invoke(b.set, manager, args, &exception); + return exception ? ApplyResult::Unverified : ApplyResult::Applied; +} +} // namespace + +mod_settings::BooleanSetting& FleetCommanderConfirmationSetting() +{ + static mod_settings::BooleanSetting setting({"community_mod.fc_ability_confirmation", + "[MOD] Confirm Fleet Commander abilities", ReadConfirmation, + WriteConfirmation}); + return setting; +} + +void InvalidateFleetCommanderConfirmationSession() +{ + // The native UI calls this before preference-session boundaries. + FleetCommanderConfirmationSetting().InvalidateSession(); + Backend().invalidate(); +} diff --git a/mods/src/patches/parts/fc_confirmation_reset.h b/mods/src/patches/parts/fc_confirmation_reset.h new file mode 100644 index 000000000..237e204a3 --- /dev/null +++ b/mods/src/patches/parts/fc_confirmation_reset.h @@ -0,0 +1,9 @@ +#pragma once + +namespace mod_settings +{ +class BooleanSetting; +} +// Game-thread preference adapter for the native confirmation settings UI. +mod_settings::BooleanSetting& FleetCommanderConfirmationSetting(); +void InvalidateFleetCommanderConfirmationSession(); diff --git a/mods/src/patches/parts/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..4e5a686c7 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -1,4 +1,8 @@ #include "config.h" +#include "patches/runtime_config.h" +#include "settings/preview_settings.h" +#include "settings/shortcut_settings.h" +#include "settings/warp_mode.h" #include @@ -68,8 +72,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; @@ -98,6 +109,8 @@ static ptrdiff_t shortcut_hint_text_localizer_offset = 0; static bool shortcut_hint_fields_ready = false; static bool initialize_actions_hook_ready = false; static bool shortcut_hints_ready = false; +bool mod_settings::ShortcutHintControlAvailable() +{ return shortcut_hints_ready; } bool SetNativeShortcutHintsVisible(bool visible) { @@ -328,27 +341,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(); @@ -580,7 +572,12 @@ bool PressOfficerPresetsButton() void ScreenManager_Update_Hook(auto original, ScreenManager* _this) { + const bool shortcutOwnedInput = Key::shortcutCaptureActive || Key::shortcutPopupActive; dispatch_screen_manager_update_callbacks(); + // Capture owns the key through release, including the native shortcut path. + // UI mouse navigation continues through EventSystem. + if (shortcutOwnedInput || Key::shortcutCaptureActive || Key::shortcutPopupActive) + return; if (!Config::Get().installHotkeyHooks) { return original(_this); } @@ -676,7 +673,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 +764,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 +979,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 +1583,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 +1735,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 +1750,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 +1764,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/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..4da43fbb1 --- /dev/null +++ b/mods/src/patches/parts/runtime_config.cc @@ -0,0 +1,311 @@ +#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 "patches/mapkey.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)); + } + for (int i = 0; i < GameFunction::Max; ++i) { + const auto& definition = MapKey::Definition(static_cast(i)); + if (definition.key.empty()) + continue; + std::optional value; + if (auto text = loaded["shortcuts"][definition.key].value()) + value = *text; + writer->Register("shortcuts", definition.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..40ccf9d09 --- /dev/null +++ b/mods/src/patches/parts/runtime_config_keys.h @@ -0,0 +1,23 @@ +#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", "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/mod_pages.cc b/mods/src/settings/mod_pages.cc new file mode 100644 index 000000000..058673f71 --- /dev/null +++ b/mods/src/settings/mod_pages.cc @@ -0,0 +1,55 @@ +#include "mod_pages.h" +#include "camera_settings.h" +#include "fleet_labels.h" +#include "preview_settings.h" +#include "shortcut_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(); + RegisterShortcutPages(catalog); + // 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.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..db6c1ccf8 --- /dev/null +++ b/mods/src/settings/native/action_widgets.cc @@ -0,0 +1,410 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "action_widgets.h" +#include "page_navigation.h" +#include "row_style.h" +#include "settings/native_boolean_callback.h" +#include "settings/shortcut_settings.h" +#include "settings/shortcut_popup.h" +#include +#include +#include + +namespace mod_settings::native +{ +namespace +{ + NativeCallback actionCallback; + NativeCallback actionGetter; + bool actionsActive = false; + Il2CppObject* invokingWidget = nullptr; +} // namespace +Il2CppObject* InvokingActionWidget() +{ return invokingWidget; } +bool ActionsActive() +{ return actionsActive; } +struct ActionMetadata { + IL2CppClassHelper widget = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "ButtonAndTextOptionWidget"); + IL2CppClassHelper row = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "ButtonAndTextOptionContext"); + const MethodInfo* add = ToggleMeta().context.GetMethodInfo("AddButtonAndText", 6); + const MethodInfo* refresh = widget.GetMethodInfo("SetWidgetData", 0); + const MethodInfo* release = widget.GetMethodInfo("OnAboutToReleaseContext", 0); + const MethodInfo* getContext = widget.GetMethodInfo("get_Context", 0); + FieldInfo* label = Field(widget.get_cls(), "_label"); + FieldInfo* buttonLabel = Field(widget.get_cls(), "_buttonLabel"); + FieldInfo* valueLabel = Field(widget.get_cls(), "_valueLabel"); + FieldInfo* button = Field(widget.get_cls(), "_button"); +}; +ActionMetadata& ActionMeta() +{ + static ActionMetadata metadata; + return metadata; +} + +Il2CppObject* ActionToken(Il2CppObject* context) +{ + if (!context || context->klass != ActionMeta().row.get_cls()) + return nullptr; + Root safe(Call(context, "get_Callback")); + if (!safe.get()) + return nullptr; + Root callbacks(ReadField(safe.get(), Field(safe.get()->klass, "_callbacks"))); + if (Count(callbacks.get()) != 1) + return nullptr; + auto* callback = reinterpret_cast(Item(callbacks.get(), 0)); + return callback && callback->method == actionCallback.method() + && callback->method_ptr == actionCallback.method()->methodPointer + ? callback->target + : nullptr; +} +ActionRow ActionFor(Il2CppObject* context) +{ + if (!ActionToken(context)) + return {}; + Root parent(Call(context, "get_Parent")); + const auto* page = PageFor(parent.get()); + if (!page) + return {}; + Root label(Call(context, "get_LabelContext")); + Root identifier(Call(label.get(), "get_Identifier")); + if (!identifier.get() || !Type(il2cpp_class_get_type(identifier.get()->klass), IL2CPP_TYPE_STRING)) + return {}; + auto* text = reinterpret_cast(identifier.get()); + std::string id; + for (int i = 0; i < il2cpp_string_length(text); ++i) { + const auto character = il2cpp_string_chars(text)[i]; + if (character > 127) + return {}; + id += static_cast(character); + } + for (auto* action : page->Controls()) + if (const auto index = action->item_index(id)) + return {action, *index}; + return {}; +} +struct ActionView { + Il2CppGCHandle widget = nullptr, context = nullptr, token = nullptr; + std::array labels{}; + Il2CppGCHandle button = nullptr; + Il2CppGCHandle buttonObject = nullptr; + ActionSetting* action = nullptr; + std::size_t index = 0; + bool buttonHidden = false, buttonBefore = false; + bool rendering = false, invoking = false; +}; +std::deque actionViews; +void ClearAction(ActionView& view) +{ + view.action = nullptr; + for (auto& handle : view.labels) { + if (auto* label = Target(handle)) + ClearRowText(label); + Free(handle); + } + try { + if (auto* button = Target(view.button)) + Call(button, "ClearInteractable"); + if (view.buttonHidden) + SetActive(Target(view.buttonObject), view.buttonBefore); + } catch (...) { + Warn(); + } + 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()) + { + struct Invocation { + Il2CppObject* previous; + explicit Invocation(Il2CppObject* widget) : previous(invokingWidget) { invokingWidget = widget; } + ~Invocation() { invokingWidget = previous; } + } invocation(widget.get()); + action->invoke(index); + } + return; + } + } catch (...) { + Warn("settings command unavailable"); + } +} +void ActionRefreshHook(auto original, Il2CppObject* widget) +{ + if (OnUIThread()) + for (std::size_t i = 0, count = actionViews.size(); i < count; ++i) { + auto& view = actionViews[i]; + if (Target(view.widget) == widget) { + // Pooling/refresh only releases the widget. The page owns its editor. + ClearAction(view); + } + } + original(widget); + if (!OnUIThread() || !actionsActive || !PagesActive()) + return; + ActionView* tracked = nullptr; + try { + Root context(Invoke(ActionMeta().getContext, widget)); + const auto action = ActionFor(context.get()); + if (!action.first) + return; + for (auto& view : actionViews) + if (!view.action && !view.rendering && !view.invoking) { + tracked = &view; + break; + } + if (!tracked) { + actionViews.emplace_back(); + tracked = &actionViews.back(); + } + auto& view = *tracked; + view.action = action.first; + view.index = action.second; + auto weak = [](Il2CppObject* object) { + const auto handle = object ? il2cpp_gchandle_new_weakref(object, false) : nullptr; + if (!handle) + throw std::runtime_error("settings command weak root"); + return handle; + }; + view.widget = weak(widget); + view.context = weak(context.get()); + view.token = weak(ActionToken(context.get())); + const std::array fields{ActionMeta().label, ActionMeta().buttonLabel, ActionMeta().valueLabel}; + for (std::size_t i = 0; i < fields.size(); ++i) + view.labels[i] = weak(ReadField(widget, fields[i])); + view.button = weak(ReadField(widget, ActionMeta().button)); + Root buttonObject(Call(Target(view.button), "get_gameObject")); + view.buttonObject = weak(buttonObject.get()); + RenderAction(view); + } catch (...) { + if (tracked) + ClearAction(*tracked); + Warn("settings command binding unavailable"); + } +} +void ActionReleaseHook(auto original, Il2CppObject* widget) +{ + if (OnUIThread()) + for (std::size_t i = 0, count = actionViews.size(); i < count; ++i) { + auto& view = actionViews[i]; + if (Target(view.widget) == widget) + ClearAction(view); + } + original(widget); +} +void AddActionRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, ActionSetting& action, + std::size_t index) +{ + if (!actionsActive) + return; + const auto* add = ActionMeta().add; + Root children(Call(parent, "get_Children")); + const int before = Count(children.get()); + if (before == PageCatalog::NativeChildLimit) + throw std::runtime_error("settings command capacity"); + Root label(reinterpret_cast(il2cpp_string_new(action.item_id(index).c_str()))); + Root empty(reinterpret_cast(il2cpp_string_new(""))); + // A fresh plain Object is the closed callback's identity, owned by this native + // context. Old pooled-row events cannot invoke a later context's command. + Root token(il2cpp_object_new(il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object"))); + Root callback(MakeDelegate(il2cpp_class_from_type(add->parameters[3]), token.get(), actionCallback.method())); + Root get(MakeDelegate(il2cpp_class_from_type(add->parameters[4]), director, actionGetter.method())); + void* args[] = {parent, label.get(), empty.get(), callback.get(), get.get(), empty.get()}; + Invoke(add, context, args); + if (Count(children.get()) != before + 1) + throw std::runtime_error("settings command insertion"); + Root row(Item(children.get(), before)); + if (ActionFor(row.get()) != ActionRow{&action, index} || ActionToken(row.get()) != token.get()) + throw std::runtime_error("settings command identity"); +} +void SyncActionRows(Il2CppObject* controller, Il2CppObject* context, const PageCatalog::Page& page) +{ + if (!actionsActive) + return; + auto actions = page.Controls(); + if (actions.empty()) + return; + Root children(Call(context, "get_Children")); + const auto size = Count(children.get()); + std::vector existing, missing; + for (int i = 0; i < size; ++i) + if (const auto row = ActionFor(Item(children.get(), i)); row.first) + existing.push_back(row); + for (auto* action : actions) + for (std::size_t index = 0, count = action->Count(); index < count; ++index) { + const ActionRow row{action, index}; + if (std::find(existing.begin(), existing.end(), row) != existing.end()) + continue; + // The adapter already bounds every native child list at 128. Check the + // full addition before mutating it, including rows retained after removal. + if (size + missing.size() == PageCatalog::NativeChildLimit) + throw std::runtime_error("settings command capacity"); + missing.push_back(row); + } + if (missing.empty()) + return; + Root callbackObject(ReadField(context, ToggleMeta().queryField)); + auto* callback = reinterpret_cast(callbackObject.get()); + if (!callback || callback->method != QueryMethod() || callback->method_ptr != QueryMethod()->methodPointer + || !callback->target || callback->target->klass != ToggleMeta().director.get_cls()) + throw std::runtime_error("settings command page owner"); + Root director(callback->target); + Root canvas(Call(controller, "get_CanvasContext")); + Root selected(Call(canvas.get(), "get_SelectedOption")); + if (!canvas.get() || canvas.get()->klass != ToggleMeta().context.get_cls() || selected.get() != context) + throw std::runtime_error("settings command page changed"); + // Contexts remain owned by the native page up to its largest binding count. + // Shrinking hides surplus indices; later additions reuse those same contexts. + for (const auto& [action, index] : missing) + AddActionRow(director.get(), canvas.get(), context, *action, index); +} +void InstallActionWidgets() +{ + auto& m = PageMeta(); + if (std::any_of(Pages().begin(), Pages().end(), + [](const auto& page) { return !page.template Controls().empty(); })) { + try { + auto& action = ActionMeta(); + auto* objects = il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object"); + const auto* clickSchema = objects ? il2cpp_class_get_method_from_name(objects, ".ctor", 0) : nullptr; + const auto* getSchema = ToggleMeta().director.GetMethodInfo("GetClientVersion", 0); + if (!Instance(action.add, 6, IL2CPP_TYPE_VOID) || !Reference(action.add->parameters[0]) + || !Type(action.add->parameters[1], IL2CPP_TYPE_STRING) + || !Type(action.add->parameters[2], IL2CPP_TYPE_STRING) || !Reference(action.add->parameters[3]) + || !Reference(action.add->parameters[4]) || !Type(action.add->parameters[5], IL2CPP_TYPE_STRING) + || !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; + InstallShortcutPopup(); + SetShortcutPresentationObserver(RefreshActions); + } 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..297fd0385 --- /dev/null +++ b/mods/src/settings/native/action_widgets.h @@ -0,0 +1,21 @@ +#pragma once + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "interop.h" +#include "settings/page_catalog.h" + +namespace mod_settings::native +{ +using ActionRow = std::pair; +bool ActionsActive(); +// Valid only during a synchronous action invocation; never retain this pointer. +Il2CppObject* InvokingActionWidget(); +void RefreshActions(); +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/shortcut_popup.cc b/mods/src/settings/native/shortcut_popup.cc new file mode 100644 index 000000000..073fd7187 --- /dev/null +++ b/mods/src/settings/native/shortcut_popup.cc @@ -0,0 +1,464 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "settings/shortcut_popup.h" +#include "action_widgets.h" +#include "patches/key.h" +#include "patches/screen_update_hook.h" +#include "row_style.h" +#include "settings/native_boolean_callback.h" +#include "settings/shortcut_popup_keys.h" +#include "value_widgets.h" +#include +#include +#include +#include +#include + +namespace mod_settings::native +{ +namespace +{ + struct Vec2 { + float x, y; + }; + struct Vec3 { + float x, y, z; + }; + struct Color { + float r, g, b, a; + }; + struct Rect { + float x, y, width, height; + }; + struct Popup { + Il2CppGCHandle object{}, owner{}, status{}, proposed{}, current{}, title{}, confirmText{}, scroll{}; + std::array buttons{}, tokens{}; + ShortcutPopupCommands commands; + std::optional rendered; + ShortcutPopupKeys keys; + } popup; + Il2CppGCHandle navigationSystem = nullptr; + Il2CppGCHandle backController = nullptr; + bool previousNavigation = false, releaseNavigationNextTick = false; + bool previousBackEnabled = false; + NativeCallback clickCallback; + bool installed = false, invoking = false; + bool (*focused)() = nullptr; + + Il2CppClass* Class(const char* assembly, const char* ns, const char* name) + { + auto* cls = il2cpp_get_class_helper(assembly, ns, name).get_cls(); + if (!cls) + throw std::runtime_error(std::string("shortcut popup class unavailable: ") + assembly + ":" + ns + "." + name); + return cls; + } + Il2CppClass* UnityClass(const char* name) + { return Class("UnityEngine.CoreModule", "UnityEngine", name); } + Il2CppObject* Static(const MethodInfo* method, void** args) + { + Il2CppObject* result = nullptr; + if (!Il2CppRuntime::TryInvoke(method, nullptr, args, &result)) + throw std::runtime_error("shortcut popup static invocation"); + return result; + } + Il2CppObject* UiCall(Il2CppObject* object, const char* name, int count = 0, void** args = nullptr) + { + try { + return Call(object, name, count, args); + } catch (const std::exception&) { + throw std::runtime_error(std::string("shortcut popup call: ") + + (object ? il2cpp_class_get_name(object->klass) : "null") + "." + name); + } + } + bool Alive(Il2CppObject* object) + { + if (!object) + return false; + static const auto* method = IL2CppClassHelper(UnityClass("Object")).GetMethodInfo("op_Implicit", 1); + void* args[] = {object}; + Root result(Static(method, args)); + return Boolean(result.get()); + } + void Retain(Il2CppGCHandle& handle, Il2CppObject* object) + { + Free(handle); + handle = object ? il2cpp_gchandle_new(object, false) : nullptr; + if (!handle) + throw std::runtime_error("shortcut popup root"); + } + void Set(Il2CppObject* object, const char* method, void* value) + { + void* args[] = {value}; + UiCall(object, method, 1, args); + } + template void Value(Il2CppObject* object, const char* method, T value) + { Set(object, method, &value); } + void ReleaseNavigation() + { + if (Alive(Target(backController))) { + Value(Target(backController), "set_enabled", previousBackEnabled); + if (Boolean(UiCall(Target(backController), "get_enabled")) != previousBackEnabled) + throw std::runtime_error("shortcut popup Back controller restore failed"); + } + Free(backController); + if (Alive(Target(navigationSystem))) { + Value(Target(navigationSystem), "set_sendNavigationEvents", previousNavigation); + if (Boolean(UiCall(Target(navigationSystem), "get_sendNavigationEvents")) != previousNavigation) + throw std::runtime_error("shortcut popup navigation restore failed"); + } + Free(navigationSystem); + releaseNavigationNextTick = false; + } + void HoldNavigation(Il2CppObject* settingsBack) + { + auto* cls = Class("UnityEngine.UI", "UnityEngine.EventSystems", "EventSystem"); + Root current(Static(IL2CppClassHelper(cls).GetMethodInfo("get_current", 0), nullptr)); + if (!current.get() || !Alive(settingsBack)) + throw std::runtime_error("shortcut popup navigation owner unavailable"); + if (current.get() != Target(navigationSystem) || settingsBack != Target(backController)) { + ReleaseNavigation(); + previousNavigation = Boolean(UiCall(current.get(), "get_sendNavigationEvents")); + Retain(navigationSystem, current.get()); + previousBackEnabled = Boolean(UiCall(settingsBack, "get_enabled")); + Retain(backController, settingsBack); + } + releaseNavigationNextTick = false; + // Suppress Unity Submit/Cancel/Move only; pointer clicks still work. Keep + // this held through teardown and key release so Escape cannot also go Back. + Value(current.get(), "set_sendNavigationEvents", false); + // Client263 BackButtonViewController.Update polls Input.GetKeyDown(Escape) + // directly, outside EventSystem and ScreenManager.Update. Suspend only the + // owning settings controller's poll until the dismissal key is released. + Value(settingsBack, "set_enabled", false); + } + Il2CppObject* WithType(Il2CppObject* object, const char* method, Il2CppClass* type, int arity = 1) + { + auto* target = IL2CppClassHelper(object->klass).GetMethodInfoSpecial(method, [arity](auto count, auto params) { + return count == arity && Reference(params[0]) + && (arity == 1 || (arity == 2 && Type(params[1], IL2CPP_TYPE_BOOLEAN))) + && std::strcmp(il2cpp_class_get_name(il2cpp_class_from_type(params[0])), "Type") == 0; + }); + if (!target) + throw std::runtime_error(std::string("shortcut popup typed method unavailable: ") + method); + Root reflection(reinterpret_cast(il2cpp_type_get_object(il2cpp_class_get_type(type)))); + bool includeInactive = false; + void* args[] = {reflection.get(), &includeInactive}; + return Invoke(target, object, args); + } + void Text(Il2CppObject* text, const std::string& value) + { + Root string(reinterpret_cast(il2cpp_string_new(value.c_str()))); + Set(text, "set_text", string.get()); + } + void Layout(Il2CppObject* transform, Vec2 size, Vec2 position) + { + Value(transform, "set_anchorMin", Vec2{.5f, .5f}); + Value(transform, "set_anchorMax", Vec2{.5f, .5f}); + Value(transform, "set_pivot", Vec2{.5f, .5f}); + Value(transform, "set_sizeDelta", size); + Value(transform, "set_anchoredPosition", position); + } + Il2CppObject* NewObject(const char* name, Il2CppObject* parent) + { + auto* cls = UnityClass("GameObject"); + Root object(il2cpp_object_new(cls)); + Root label(reinterpret_cast(il2cpp_string_new(name))); + auto* ctor = IL2CppClassHelper(cls).GetMethodInfoSpecial( + ".ctor", [](auto n, auto p) { return n == 1 && Type(p[0], IL2CPP_TYPE_STRING); }); + void* args[] = {label.get()}; + Invoke(ctor, object.get(), args); + // The root is retained before any operation that can throw. Children are + // parented immediately; closing the root then cleans up partial construction. + if (!parent) + Retain(popup.object, object.get()); + Root transform(UiCall(object.get(), "get_transform")); + if (parent) { + bool world = false; + void* parentArgs[] = {parent, &world}; + UiCall(transform.get(), "SetParent", 2, parentArgs); + } + WithType(object.get(), "AddComponent", UnityClass("RectTransform")); + Value(object.get(), "set_layer", 5); // Unity UI layer. + return object.get(); + } + Il2CppObject* Image(Il2CppObject* object, Color color, Il2CppObject* sprite = nullptr) + { + Root image(WithType(object, "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "Image"))); + Value(image.get(), "set_color", color); + if (sprite) { + Set(image.get(), "set_sprite", sprite); + Value(image.get(), "set_type", 1); // Sliced. + } + return image.get(); + } + Il2CppObject* Label(Il2CppObject* parent, Il2CppObject* font, const char* name, Vec2 size, Vec2 position, + float fontSize, const std::string& content) + { + Root object(NewObject(name, parent)); + Root transform(UiCall(object.get(), "get_transform")); + Layout(transform.get(), size, position); + Root label(WithType(object.get(), "AddComponent", Class("Unity.TextMeshPro", "TMPro", "TextMeshProUGUI"))); + Set(label.get(), "set_font", font); + Value(label.get(), "set_fontSize", fontSize); + Value(label.get(), "set_alignment", 0x202); // Center. + Value(label.get(), "set_color", Color{.88f, .95f, .97f, 1}); + Value(label.get(), "set_raycastTarget", false); + Value(label.get(), "set_enableWordWrapping", true); + Text(label.get(), content); + return label.get(); + } + void Render() + { + if (!popup.commands.read) + return; + const auto value = popup.commands.read(); + if (popup.rendered == value) + return; + Text(Target(popup.title), value.title); + Text(Target(popup.current), "Current: " + value.current); + Text(Target(popup.proposed), value.proposed.empty() ? "Press a shortcut" : value.proposed); + Text(Target(popup.status), value.status); + Root statusTransform(UiCall(Target(popup.status), "get_transform")); + Root preferred(UiCall(Target(popup.status), "get_preferredHeight")); + if (!preferred.get() || !Type(il2cpp_class_get_type(preferred.get()->klass), IL2CPP_TYPE_R4)) + throw std::runtime_error("shortcut popup text height"); + Value(statusTransform.get(), "set_sizeDelta", + Vec2{548, std::max(180.f, *static_cast(il2cpp_object_unbox(preferred.get())))}); + Value(Target(popup.scroll), "set_verticalNormalizedPosition", 1.f); + Text(Target(popup.confirmText), value.confirmLabel); + Value(Target(popup.buttons[0]), "set_interactable", value.canConfirm); + Value(Target(popup.buttons[1]), "set_interactable", value.canRecord); + popup.rendered = value; + } + void Cancel() + { + auto cancel = popup.commands.cancel; + CloseShortcutPopup(); + if (cancel) + cancel(); + } + void Click(Il2CppObject* token, const MethodInfo*) + { + if (invoking || !OnUIThread() || !popup.commands.read) + return; + struct Scope { + Scope() + { invoking = true; } + ~Scope() + { invoking = false; } + } scope; + try { + if (!focused() || !Alive(Target(popup.object)) || !Alive(Target(popup.owner)) + || !Boolean(UiCall(Target(popup.owner), "get_activeInHierarchy"))) { + Cancel(); + return; + } + const auto presentation = popup.commands.read(); + // Copy before invocation: confirming/cancelling destroys the surface. + std::function command; + if (token == Target(popup.tokens[0]) && presentation.canConfirm) + command = popup.commands.confirm; + else if (token == Target(popup.tokens[1]) && presentation.canRecord) + command = popup.commands.record; + else if (token == Target(popup.tokens[2])) { + Cancel(); + return; + } + if (command) + command(); + Render(); + } catch (const std::exception& error) { + spdlog::warn("[Shortcuts] Popup command failed: {}", error.what()); + Cancel(); + } + } + void Button(Il2CppObject* parent, Il2CppObject* font, Il2CppObject* sprite, int index, const char* caption, + Vec2 position) + { + Root object(NewObject(caption, parent)); + Root transform(UiCall(object.get(), "get_transform")); + Layout(transform.get(), {174, 52}, position); + Root image(Image(object.get(), {.60f, .80f, .95f, 1}, sprite)); + Root button(WithType(object.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "Button"))); + Set(button.get(), "set_targetGraphic", image.get()); + Root text(Label(transform.get(), font, "Label", {166, 46}, {0, 0}, 21, caption)); + Retain(popup.buttons[index], button.get()); + if (index == 0) + Retain(popup.confirmText, text.get()); + Root token(il2cpp_object_new(il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object"))); + Retain(popup.tokens[index], token.get()); + Root event(UiCall(button.get(), "get_onClick")); + auto* add = IL2CppClassHelper(event.get()->klass).GetMethodInfo("AddListener", 1); + if (!Instance(add, 1, IL2CPP_TYPE_VOID) || !Reference(add->parameters[0])) + throw std::runtime_error("shortcut popup button event"); + Root callback(MakeDelegate(il2cpp_class_from_type(add->parameters[0]), token.get(), clickCallback.method())); + void* args[] = {callback.get()}; + Invoke(add, event.get(), args); + } + void Tick() + { + try { + if (!popup.commands.read) { + if ((navigationSystem || backController) && !Key::shortcutCaptureActive) { + if (releaseNavigationNextTick) + ReleaseNavigation(); + else + releaseNavigationNextTick = true; // Also consume the release frame. + } else { + releaseNavigationNextTick = false; + } + return; + } + if (!focused() || !Alive(Target(popup.object)) || !Alive(Target(popup.owner)) + || !Boolean(UiCall(Target(popup.owner), "get_activeInHierarchy"))) { + Cancel(); + return; + } + const auto command = popup.keys.Tick( + popup.commands.read().canConfirm, Key::RawPressed(KeyCode::Return) || Key::RawPressed(KeyCode::KeypadEnter), + Key::RawDown(KeyCode::Return) || Key::RawDown(KeyCode::KeypadEnter), Key::RawDown(KeyCode::Escape)); + if (command == ShortcutPopupKey::Cancel) { + Cancel(); + return; + } + if (command == ShortcutPopupKey::Confirm) { + Root token(Target(popup.tokens[0])); + Click(token.get(), nullptr); // Same availability/conflict checks as the button. + } + Render(); + } catch (const std::exception& error) { + spdlog::warn("[Shortcuts] Popup closed: {}", error.what()); + Cancel(); + } + } + +} // namespace + +void CloseShortcutPopup() +{ + popup.commands = {}; + popup.rendered.reset(); + popup.keys = {}; + Key::shortcutPopupActive = false; + try { + if (Alive(Target(popup.object))) { + SetActive(Target(popup.object), false); + static auto* destroy = IL2CppClassHelper(UnityClass("Object")).GetMethodInfo("Destroy", 1); + void* args[] = {Target(popup.object)}; + Static(destroy, args); + } + } catch (...) { + spdlog::warn("[Shortcuts] Popup teardown unavailable"); + } + for (auto* handle : {&popup.object, &popup.owner, &popup.status, &popup.proposed, &popup.current, &popup.title, + &popup.confirmText, &popup.scroll}) + Free(*handle); + for (auto& handle : popup.buttons) + Free(handle); + for (auto& handle : popup.tokens) + Free(handle); +} + +bool OpenShortcutPopup(ShortcutPopupCommands commands) +{ + if (!installed || !OnUIThread() || popup.commands.read || !InvokingActionWidget()) + return false; + try { + Root host(InvokingActionWidget()); + Root hostObject(UiCall(host.get(), "get_gameObject")); + Root settings(WithType(hostObject.get(), "GetComponentInParent", + Class("Assembly-CSharp", "Digit.Prime.GameSettings", "GameSettingsViewController"), 2)); + if (!settings.get()) + throw std::runtime_error("shortcut popup settings controller unavailable"); + Root settingsBack(ReadField(settings.get(), Field(settings.get()->klass, "_backButtonViewController"))); + Root canvas( + WithType(hostObject.get(), "GetComponentInParent", Class("UnityEngine.UIModule", "UnityEngine", "Canvas"), 2)); + if (!canvas.get()) + throw std::runtime_error("shortcut popup canvas"); + Root parent(UiCall(canvas.get(), "get_transform")); + Root owner(UiCall(canvas.get(), "get_gameObject")); + Retain(popup.owner, owner.get()); + Root localizer(ReadField(host.get(), Field(host.get()->klass, "_label"))); + Root labelObject(UiCall(localizer.get(), "get_gameObject")); + Root source(WithType(labelObject.get(), "GetComponent", Class("Unity.TextMeshPro", "TMPro", "TMP_Text"))); + Root font(UiCall(source.get(), "get_font")); + if (!font.get()) + throw std::runtime_error("shortcut popup font"); + Root background(RowImage(host.get())); + Root sprite(background.get() ? UiCall(background.get(), "get_sprite") : nullptr); + + Root object(NewObject("CommunityMod_ShortcutPopup", nullptr)); + SetActive(object.get(), false); + Root transform(UiCall(object.get(), "get_transform")); + bool world = false; + void* parentArgs[] = {parent.get(), &world}; + UiCall(transform.get(), "SetParent", 2, parentArgs); + Value(transform.get(), "set_anchorMin", Vec2{0, 0}); + Value(transform.get(), "set_anchorMax", Vec2{1, 1}); + Value(transform.get(), "set_sizeDelta", Vec2{0, 0}); + Value(transform.get(), "set_anchoredPosition", Vec2{0, 0}); + UiCall(transform.get(), "SetAsLastSibling"); + Image(object.get(), {0, 0, 0, .45f}); // Blocks pointer input to the settings behind it. + Root panel(NewObject("Panel", transform.get())); + Root panelTransform(UiCall(panel.get(), "get_transform")); + Layout(panelTransform.get(), {620, 540}, {0, 0}); + // The row sprite is already dark; tinting it again obscures the popup surface. + Image(panel.get(), {.10f, .18f, .24f, 1}); + Root rect(UiCall(parent.get(), "get_rect")); + if (rect.get() && rect.get()->klass == UnityClass("Rect")) { + const auto value = *static_cast(il2cpp_object_unbox(rect.get())); + const auto scale = std::min({1.f, value.width / 650.f, value.height / 570.f}); + if (scale <= 0) + throw std::runtime_error("shortcut popup canvas size"); + Value(panelTransform.get(), "set_localScale", Vec3{scale, scale, 1}); + } + Retain(popup.title, Label(panelTransform.get(), font.get(), "Title", {560, 58}, {0, 214}, 29, "")); + Retain(popup.current, Label(panelTransform.get(), font.get(), "Current", {560, 45}, {0, 157}, 21, "")); + Retain(popup.proposed, Label(panelTransform.get(), font.get(), "Proposed", {560, 75}, {0, 82}, 32, "")); + Root viewport(NewObject("Warnings", panelTransform.get())); + Root viewportTransform(UiCall(viewport.get(), "get_transform")); + Layout(viewportTransform.get(), {560, 180}, {0, -59}); + WithType(viewport.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "RectMask2D")); + Image(viewport.get(), {0, 0, 0, .01f}); // Wheel/drag target even over empty text. + Root status(Label(viewportTransform.get(), font.get(), "Status", {548, 180}, {0, 0}, 20, "")); + Root statusTransform(UiCall(status.get(), "get_transform")); + Value(statusTransform.get(), "set_anchorMin", Vec2{.5f, 1}); + Value(statusTransform.get(), "set_anchorMax", Vec2{.5f, 1}); + Value(statusTransform.get(), "set_pivot", Vec2{.5f, 1}); + Value(status.get(), "set_alignment", 0x102); // Top center. + Retain(popup.status, status.get()); + Root scroll(WithType(viewport.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "ScrollRect"))); + Set(scroll.get(), "set_viewport", viewportTransform.get()); + Set(scroll.get(), "set_content", statusTransform.get()); + Value(scroll.get(), "set_horizontal", false); + Value(scroll.get(), "set_vertical", true); + Value(scroll.get(), "set_movementType", 2); // Clamped. + Value(scroll.get(), "set_scrollSensitivity", 24.f); + Retain(popup.scroll, scroll.get()); + Button(panelTransform.get(), font.get(), sprite.get(), 2, "Cancel", {-190, -220}); + Button(panelTransform.get(), font.get(), sprite.get(), 1, "Record again", {0, -220}); + Button(panelTransform.get(), font.get(), sprite.get(), 0, "Confirm", {190, -220}); + HoldNavigation(settingsBack.get()); + popup.commands = std::move(commands); + Key::shortcutPopupActive = true; + Render(); + SetActive(object.get(), true); + spdlog::info("[Shortcuts] Popup opened"); + return true; + } catch (const std::exception& error) { + spdlog::warn("[Shortcuts] Popup unavailable: {}", error.what()); + CloseShortcutPopup(); + return false; + } +} + +void InstallShortcutPopup() +{ + if (installed) + return; + auto* object = il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object"); + const auto* ctor = object ? il2cpp_class_get_method_from_name(object, ".ctor", 0) : nullptr; + focused = il2cpp_resolve_icall_typed("UnityEngine.Application::get_isFocused()"); + installed = focused && clickCallback.Initialize(ctor, Click) && register_screen_manager_update_callback(Tick); +} +} // namespace mod_settings::native +#endif diff --git a/mods/src/settings/native/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/shortcut_capture.h b/mods/src/settings/shortcut_capture.h new file mode 100644 index 000000000..e6b11fc7c --- /dev/null +++ b/mods/src/settings/shortcut_capture.h @@ -0,0 +1,86 @@ +#pragma once +#include +#include +#include +#include + +namespace mod_settings +{ +// Preserve the modifier families understood by MapKey. Command and Windows +// have distinct Unity keycodes; if a Command press is also reported as Windows, +// prefer Command so the binding does not require the duplicate event later. +template +std::string CaptureModifierPrefix(Held held) +{ + std::string token; + if (held(KeyCode::LeftControl) || held(KeyCode::RightControl)) + token += "CTRL-"; + if (held(KeyCode::LeftAlt) || held(KeyCode::RightAlt)) + token += "ALT-"; + if (held(KeyCode::LeftShift) || held(KeyCode::RightShift)) + token += "SHIFT-"; + if (held(KeyCode::LeftCommand) || held(KeyCode::RightCommand)) + token += "CMD-"; + else if (held(KeyCode::LeftWindows) || held(KeyCode::RightWindows)) + token += "WIN-"; + return token; +} + +// Pure input ownership state. The runtime samples supported physical keys only +// while active. A click or hold that opened capture cannot become its result. +class ShortcutCapture +{ +public: + using Keys = std::array(KeyCode::Max)>; + enum class Phase { Idle, ReleaseOpeningKeys, Listening, ReleaseCapturedKeys }; + void Begin() + { phase_ = Phase::ReleaseOpeningKeys; } + void Cancel() + { + if (phase_ != Phase::Idle) + phase_ = Phase::ReleaseCapturedKeys; + } + bool active() const + { return phase_ != Phase::Idle; } + bool listening() const + { return phase_ == Phase::Listening; } + Phase phase() const + { return phase_; } + // Escape/focus loss cancel a pending recording and still drain the held keys. + template + std::optional Tick(const Keys& held, const Keys& down, bool focused, Modifier modifier) + { + if (!active()) + return {}; + if (!focused || down[static_cast(KeyCode::Escape)]) + Cancel(); + bool any = false; + for (bool value : held) + any |= value; + if (phase_ == Phase::ReleaseOpeningKeys || phase_ == Phase::ReleaseCapturedKeys) { + // Unity may report no held keys while unfocused. Require a focused sample + // before releasing ownership, including when Alt-Tab cancelled recording. + if (focused && !any) + phase_ = phase_ == Phase::ReleaseOpeningKeys ? Phase::Listening : Phase::Idle; + return {}; + } + std::optional candidate; + for (std::size_t i = 1; i < down.size(); ++i) { + const auto key = static_cast(i); + if (!down[i] || modifier(key)) + continue; + if (candidate) { + Cancel(); + return {}; + } // Ambiguous simultaneous primary keys. + candidate = key; + } + if (candidate) + phase_ = Phase::ReleaseCapturedKeys; + return candidate; + } + +private: + Phase phase_ = Phase::Idle; +}; +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_catalog.h b/mods/src/settings/shortcut_catalog.h new file mode 100644 index 000000000..9a95b94b2 --- /dev/null +++ b/mods/src/settings/shortcut_catalog.h @@ -0,0 +1,251 @@ +#pragma once +#include "page_catalog.h" +#include "patches/gamefunctions.h" +#include +#include +#include +#include +#include +#include +#include + +namespace mod_settings +{ +// Change + Remove per binding, Add, status/Undo, Restore, optional explanation +// and save notice. Retain the established binding limit with three spare slots. +// Oversized player-authored lists stay live; the editor presents their prefix. +inline constexpr std::size_t ShortcutFixedRowLimit = 8; +inline constexpr std::size_t ShortcutBindingDisplayLimit = (PageCatalog::NativeChildLimit - ShortcutFixedRowLimit) / 2; +constexpr std::size_t VisibleShortcutBindingCount(std::size_t count) +{ return std::min(count, ShortcutBindingDisplayLimit); } +constexpr bool ShortcutCountFitsEdit(std::size_t before, std::size_t after) +{ return after <= ShortcutBindingDisplayLimit || after <= before; } +enum class ShortcutGroup { + Screens, + Previews, + Interface, + Fleet, + Travel, + Camera, + Chat, + Client, + Uncategorized, + Diagnostics +}; +struct ShortcutGroupInfo { + ShortcutGroup group; + std::string_view id, label; +}; +// Category registration is display order: alphabetized, with Diagnostics last. +inline constexpr auto ShortcutGroups = std::to_array({ + {ShortcutGroup::Camera, "camera", "Camera"}, + {ShortcutGroup::Chat, "chat", "Chat"}, + {ShortcutGroup::Client, "client", "Client"}, + {ShortcutGroup::Fleet, "fleet", "Fleet Controls"}, + {ShortcutGroup::Screens, "screens", "Game Screens"}, + {ShortcutGroup::Interface, "interface", "Interface Controls"}, + {ShortcutGroup::Travel, "travel", "Map & Travel"}, + {ShortcutGroup::Previews, "previews", "Previews & Cargo"}, + {ShortcutGroup::Uncategorized, "uncategorized", "Uncategorized"}, + {ShortcutGroup::Diagnostics, "diagnostics", "Diagnostics"}, +}); +struct ShortcutInfo { + GameFunction action; + ShortcutGroup group; + std::string_view label; +}; +constexpr std::string_view ShortcutExplanation(GameFunction action) +{ + switch (action) { + case Quit: + return "Force closes the client; allows up to 0.5s for pending saves."; + case NativeShortcutGalaxy: + case NativeShortcutEvents: + return "Uses the game's own shortcut behavior for this screen."; + default: + return {}; + } +} +// Optional presentation overrides. Missing actions use their registered config +// key for a fallback label in Uncategorized. Config/defaults/dispatch/save +// identities remain owned by MapKey/config; opening a screen is a UI action. +inline constexpr auto ShortcutCatalog = std::to_array({ + {MoveLeft, ShortcutGroup::Camera, "Pan left"}, + {MoveRight, ShortcutGroup::Camera, "Pan right"}, + {MoveUp, ShortcutGroup::Camera, "Pan up"}, + {MoveDown, ShortcutGroup::Camera, "Pan down"}, + {SelectChatAlliance, ShortcutGroup::Chat, "Switch to alliance chat"}, + {SelectChatGlobal, ShortcutGroup::Chat, "Switch to global chat"}, + {SelectChatPrivate, ShortcutGroup::Chat, "Switch to private chat"}, + {SelectShip1, ShortcutGroup::Fleet, "Select ship 1"}, + {SelectShip2, ShortcutGroup::Fleet, "Select ship 2"}, + {SelectShip3, ShortcutGroup::Fleet, "Select ship 3"}, + {SelectShip4, ShortcutGroup::Fleet, "Select ship 4"}, + {SelectShip5, ShortcutGroup::Fleet, "Select ship 5"}, + {SelectShip6, ShortcutGroup::Fleet, "Select ship 6"}, + {SelectShip7, ShortcutGroup::Fleet, "Select ship 7"}, + {SelectShip8, ShortcutGroup::Fleet, "Select ship 8"}, + {SelectCurrent, ShortcutGroup::Fleet, "Locate selected ship"}, + {ShowAlliance, ShortcutGroup::Screens, "Open alliance"}, + {ShowAllianceArmada, ShortcutGroup::Screens, "Open alliance armadas"}, + {ShowAllianceHelp, ShortcutGroup::Screens, "Open alliance help"}, + {ShowArtifacts, ShortcutGroup::Screens, "Open artifacts"}, + {ShowOfficers, ShortcutGroup::Screens, "Open officers"}, + {ShowCommander, ShortcutGroup::Screens, "Open Fleet Commanders"}, + {ShowRefinery, ShortcutGroup::Screens, "Open refinery"}, + {ShowQTrials, ShortcutGroup::Screens, "Open Q's Trials"}, + {ShowBookmarks, ShortcutGroup::Travel, "Open bookmarks"}, + {ShowLookup, ShortcutGroup::Travel, "Find coordinates"}, + {ShowExoComp, ShortcutGroup::Screens, "Open Exocomps"}, + {ShowFactions, ShortcutGroup::Screens, "Open factions"}, + {ShowGifts, ShortcutGroup::Screens, "Open gifts"}, + {ShowDaily, ShortcutGroup::Screens, "Open daily goals"}, + {ShowAwayTeam, ShortcutGroup::Screens, "Open Away Teams"}, + {ShowMissions, ShortcutGroup::Screens, "Open missions"}, + {ShowResearch, ShortcutGroup::Screens, "Open research"}, + {ShowScrapYard, ShortcutGroup::Screens, "Open scrapyard"}, + {ShowShips, ShortcutGroup::Screens, "Manage selected ship"}, + {ShowInventory, ShortcutGroup::Screens, "Open inventory"}, + {ShowStationInterior, ShortcutGroup::Travel, "View station interior"}, + {ShoWStationExterior, ShortcutGroup::Travel, "View station exterior"}, + {ShowGalaxy, ShortcutGroup::Travel, "View galaxy"}, + {NativeShortcutGalaxy, ShortcutGroup::Travel, "View galaxy (native shortcut)"}, + {ShowSystem, ShortcutGroup::Travel, "View system"}, + {ShowChat, ShortcutGroup::Chat, "Open chat"}, + {ShowChatSide1, ShortcutGroup::Chat, "Open side chat 1"}, + {ShowChatSide2, ShortcutGroup::Chat, "Open side chat 2"}, + {ShowEvents, ShortcutGroup::Screens, "Open events"}, + {NativeShortcutEvents, ShortcutGroup::Screens, "Open events (native shortcut)"}, + {ShowSettings, ShortcutGroup::Screens, "Open settings"}, + {ToggleShortcutHints, ShortcutGroup::Interface, "Toggle shortcut hints"}, + {ZoomPreset1, ShortcutGroup::Camera, "Use zoom preset 1"}, + {ZoomPreset2, ShortcutGroup::Camera, "Use zoom preset 2"}, + {ZoomPreset3, ShortcutGroup::Camera, "Use zoom preset 3"}, + {ZoomPreset4, ShortcutGroup::Camera, "Use zoom preset 4"}, + {ZoomPreset5, ShortcutGroup::Camera, "Use zoom preset 5"}, + {ZoomIn, ShortcutGroup::Camera, "Zoom in"}, + {ZoomOut, ShortcutGroup::Camera, "Zoom out"}, + {ZoomMin, ShortcutGroup::Camera, "Zoom to minimum"}, + {ZoomMax, ShortcutGroup::Camera, "Zoom to maximum"}, + {ZoomReset, ShortcutGroup::Camera, "Reset zoom"}, + {UiScaleUp, ShortcutGroup::Interface, "Increase interface size"}, + {UiScaleDown, ShortcutGroup::Interface, "Decrease interface size"}, + {UiShipScaleUp, ShortcutGroup::Interface, "Increase ship panel size"}, + {UiShipScaleDown, ShortcutGroup::Interface, "Decrease ship panel size"}, + {UiViewerScaleUp, ShortcutGroup::Interface, "Increase viewer size"}, + {UiViewerScaleDown, ShortcutGroup::Interface, "Decrease viewer size"}, + {ActionPrimary, ShortcutGroup::Fleet, "Primary action"}, + {ActionSecondary, ShortcutGroup::Fleet, "Secondary action"}, + {ActionQueue, ShortcutGroup::Fleet, "Queue action"}, + {ActionQueueClear, ShortcutGroup::Fleet, "Clear action queue"}, + {ActionView, ShortcutGroup::Fleet, "View target details"}, + {ActionRecall, ShortcutGroup::Fleet, "Recall selected ship"}, + {ActionRecallCancel, ShortcutGroup::Fleet, "Cancel recall"}, + {ActionRepair, ShortcutGroup::Fleet, "Repair selected ship"}, + {SetZoomPreset1, ShortcutGroup::Camera, "Save zoom preset 1"}, + {SetZoomPreset2, ShortcutGroup::Camera, "Save zoom preset 2"}, + {SetZoomPreset3, ShortcutGroup::Camera, "Save zoom preset 3"}, + {SetZoomPreset4, ShortcutGroup::Camera, "Save zoom preset 4"}, + {SetZoomPreset5, ShortcutGroup::Camera, "Save zoom preset 5"}, + {SetZoomDefault, ShortcutGroup::Camera, "Save default zoom"}, + {DisableHotKeys, ShortcutGroup::Client, "Disable mod shortcuts"}, + {EnableHotKeys, ShortcutGroup::Client, "Enable mod shortcuts"}, + {ToggleQueue, ShortcutGroup::Fleet, "Toggle action queue"}, + {ToggleAutoConfirmInstantWarp, ShortcutGroup::Travel, "Cycle instant warp mode"}, + {TogglePreviewLocate, ShortcutGroup::Previews, "Toggle Locate on previews"}, + {TogglePreviewRecall, ShortcutGroup::Previews, "Toggle Recall on previews"}, + {ToggleCargoDefault, ShortcutGroup::Previews, "Toggle automatic cargo previews"}, + {ToggleCargoPlayer, ShortcutGroup::Previews, "Toggle player cargo previews"}, + {ToggleCargoStation, ShortcutGroup::Previews, "Toggle station cargo previews"}, + {ToggleCargoHostile, ShortcutGroup::Previews, "Toggle hostile cargo previews"}, + {ToggleCargoArmada, ShortcutGroup::Previews, "Toggle armada cargo previews"}, + {LogLevelDebug, ShortcutGroup::Diagnostics, "Set logging to Debug"}, + {LogLevelInfo, ShortcutGroup::Diagnostics, "Set logging to Info"}, + {LogLevelTrace, ShortcutGroup::Diagnostics, "Set logging to Trace"}, + {LogLevelError, ShortcutGroup::Diagnostics, "Set logging to Error"}, + {LogLevelWarn, ShortcutGroup::Diagnostics, "Set logging to Warning"}, + {LogLevelOff, ShortcutGroup::Diagnostics, "Turn logging off"}, + {Restart, ShortcutGroup::Client, "Clear localization cache and reload"}, + {Quit, ShortcutGroup::Client, "Force close client"}, + {FocusSearch, ShortcutGroup::Interface, "Focus search"}, + {ShowShipConstruction, ShortcutGroup::Screens, "Open ship construction"}, + {ShowShields, ShortcutGroup::Screens, "Open shields"}, + {ShowBattlelogs, ShortcutGroup::Screens, "Open battle logs"}, +}); +constexpr bool ValidShortcutCatalog(std::span catalog) +{ + std::array seen{}; + for (const auto& info : catalog) { + if (info.action < 0 || info.action >= GameFunction::Max || info.label.empty() || seen[info.action]) + return false; + bool groupFound = false; + for (const auto& group : ShortcutGroups) + groupFound |= group.group == info.group; + if (!groupFound) + return false; + seen[info.action] = true; + } + return true; +} +static_assert(ValidShortcutCatalog(ShortcutCatalog), "Shortcut overrides need unique valid actions, labels and groups"); + +inline std::string HumanizeShortcutKey(std::string_view key) +{ + std::string label; + bool space = false; + for (char c : key) { + if (c == '_' || c == '-' || c == ' ') { + space = !label.empty(); + continue; + } + if (space) + label += ' '; + space = false; + label += label.empty() && c >= 'a' && c <= 'z' ? static_cast(c - ('a' - 'A')) : c; + } + return label.empty() ? "Shortcut" : label; +} + +// Own generated text: descriptions survive sorting and never borrow temporary +// labels. The same resolver names editors, summaries and overlap warnings. +struct ShortcutDescription { + GameFunction action; + ShortcutGroup group; + std::string label; + bool fallback; +}; +inline ShortcutDescription DescribeShortcut(GameFunction action, std::string_view key, + std::span catalog = ShortcutCatalog) +{ + if (action < 0 || action >= GameFunction::Max) + throw std::out_of_range("shortcut action"); + for (const auto& info : catalog) + if (info.action == action) + return {action, info.group, std::string(info.label), false}; + return {action, ShortcutGroup::Uncategorized, HumanizeShortcutKey(key), true}; +} + +// Discover registered actions once at startup, including unbound (NONE) actions. +// An empty key excludes an unregistered or unavailable action. This never scans +// TOML or infers a setting type from its value. +inline std::vector DiscoverShortcuts(const auto& registeredKey, + std::span catalog = ShortcutCatalog) +{ + std::vector result; + for (int i = 0; i < GameFunction::Max; ++i) { + const auto action = static_cast(i); + const auto key = registeredKey(action); + if (!key.empty()) + result.push_back(DescribeShortcut(action, key, catalog)); + } + // ASCII folding preserves existing English label ordering (Away Teams, etc.). + std::stable_sort(result.begin(), result.end(), [](const auto& first, const auto& second) { + return std::lexicographical_compare( + first.label.begin(), first.label.end(), second.label.begin(), second.label.end(), [](char a, char b) { + const auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; }; + return lower(a) < lower(b); + }); + }); + return result; +} +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_draft.h b/mods/src/settings/shortcut_draft.h new file mode 100644 index 000000000..f419e7d8b --- /dev/null +++ b/mods/src/settings/shortcut_draft.h @@ -0,0 +1,88 @@ +#pragma once +#include "value_settings.h" +#include +#include + +namespace mod_settings +{ +using ShortcutList = std::vector; +enum class ShortcutStage { Staged, AlreadyBound, Invalid }; +// A draft edits one alternative, or restores the action's complete default list, +// against the snapshot the user saw. +// The existing ValueSetting owner provides reentry/stale-read protection. +class ShortcutDraft +{ +public: + using SameBinding = std::function; + explicit ShortcutDraft(ValueSetting& owner, SameBinding sameBinding = std::equal_to{}) + : owner_(owner) + , same_binding_(std::move(sameBinding)) + { + } + void Begin() + { + Cancel(); + observed_ = owner_.Observe(); + } + ShortcutStage Stage(std::size_t index, std::optional replacement) + { + desired_.reset(); + if (!observed_.state.known()) { + Cancel(); + return ShortcutStage::Invalid; + } + auto value = *observed_.state.value; + if (index > value.size() || (!replacement && index == value.size())) { + Cancel(); + return ShortcutStage::Invalid; + } + // Reject an existing alternative without changing the snapshot or publishing + // a draft. Re-recording the selected binding is also a no-op. + if (replacement && std::any_of(value.begin(), value.end(), [&](const auto& binding) { + return same_binding_(binding, *replacement); + })) + return ShortcutStage::AlreadyBound; + if (!replacement) + value.erase(value.begin() + index); + else if (index == value.size()) + value.push_back(*replacement); + else + value[index] = *replacement; + desired_ = std::move(value); + return ShortcutStage::Staged; + } + bool pending() const + { return desired_.has_value(); } + ShortcutStage Restore(ShortcutList defaults) + { + desired_.reset(); + if (!observed_.state.known()) + return ShortcutStage::Invalid; + if (defaults == *observed_.state.value) + return ShortcutStage::AlreadyBound; + desired_ = std::move(defaults); + return ShortcutStage::Staged; + } + const std::optional& desired() const + { return desired_; } + Outcome Apply() + { + if (!desired_) + return Outcome::Suppressed; + const auto result = owner_.SetFromUser(*desired_, observed_).outcome; + Cancel(); + return result; + } + void Cancel() + { + desired_.reset(); + observed_ = {}; + } + +private: + ValueSetting& owner_; + SameBinding same_binding_; + ValueSnapshot observed_; + std::optional desired_; +}; +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_popup.h b/mods/src/settings/shortcut_popup.h new file mode 100644 index 000000000..0d99c47d0 --- /dev/null +++ b/mods/src/settings/shortcut_popup.h @@ -0,0 +1,30 @@ +#pragma once +#include +#include + +namespace mod_settings +{ +// The feature owns the draft; the native surface only renders and sends commands. +struct ShortcutPopupPresentation { + std::string title, current, proposed, status, confirmLabel; + bool canConfirm = false, canRecord = false; + bool operator==(const ShortcutPopupPresentation&) const = default; +}; +struct ShortcutPopupCommands { + std::function read; + std::function confirm, record, cancel; +}; +namespace native +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + void InstallShortcutPopup(); + bool OpenShortcutPopup(ShortcutPopupCommands commands); + void CloseShortcutPopup(); +#else + inline void InstallShortcutPopup() {} + inline bool OpenShortcutPopup(ShortcutPopupCommands) + { return false; } + inline void CloseShortcutPopup() {} +#endif +} // namespace native +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_popup_keys.h b/mods/src/settings/shortcut_popup_keys.h new file mode 100644 index 000000000..b9c8b12e2 --- /dev/null +++ b/mods/src/settings/shortcut_popup_keys.h @@ -0,0 +1,33 @@ +#pragma once + +namespace mod_settings +{ +enum class ShortcutPopupKey { None, Confirm, Cancel }; +// Confirm needs a fresh press after the preview is ready. In particular, an +// Enter captured as the binding must be released before it can confirm itself. +class ShortcutPopupKeys +{ +public: + ShortcutPopupKey Tick(bool canConfirm, bool enterHeld, bool enterDown, bool escapeDown) + { + if (escapeDown) { + armed_ = false; + return ShortcutPopupKey::Cancel; + } + if (!canConfirm) { + armed_ = false; + return ShortcutPopupKey::None; + } + if (enterDown && armed_) { + armed_ = false; + return ShortcutPopupKey::Confirm; + } + if (!enterHeld && !enterDown) + armed_ = true; + return ShortcutPopupKey::None; + } + +private: + bool armed_ = false; +}; +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_settings.cc b/mods/src/settings/shortcut_settings.cc new file mode 100644 index 000000000..54f43d801 --- /dev/null +++ b/mods/src/settings/shortcut_settings.cc @@ -0,0 +1,443 @@ +#include "shortcut_settings.h" +#include "config.h" +#include "page_catalog.h" +#include "patches/key.h" +#include "patches/keyboard_layout.h" +#include "patches/mapkey.h" +#include "patches/runtime_config.h" +#include "patches/screen_update_hook.h" +#include "shortcut_capture.h" +#include "shortcut_catalog.h" +#include "shortcut_draft.h" +#include "shortcut_popup.h" +#include "shortcut_undo.h" +#include "str_utils.h" +#include +#include +#include +#include + +namespace mod_settings +{ +namespace +{ + void (*changed)() = nullptr; + void Notify() + { + if (changed) + changed(); + } + std::string Join(const ShortcutList& list) + { + std::string result; + for (const auto& text : list) { + if (!result.empty()) + result += " | "; + result += text; + } + return result.empty() ? "NONE" : result; + } + struct Editor { + GameFunction function; + ValueSetting state; + ShortcutDraft draft; + ShortcutUndo undo; + std::string status, replacing; + std::vector overlaps; + std::vector> rows; + Editor(GameFunction action, const std::string& label) + : function(action) + , state({"community_mod.shortcuts." + MapKey::Definition(action).key, label, + [action] { + ShortcutList list; + for (const auto& binding : MapKey::Bindings(action)) + list.push_back(binding.GetParsedValues()); + return ValueReadResult::Known(std::move(list), 1); + }, + [this, action](ShortcutList list, std::uint64_t generation) { + if (generation != 1) + return ApplyResult::Rejected; + // Existing oversized lists may be reduced/rebound, but a UI + // addition must fit before either publication or persistence. + if (!ShortcutCountFitsEdit(MapKey::Bindings(action).size(), list.size()) + && !undo.Restoring(list)) + return ApplyResult::Rejected; + std::vector bindings; + for (const auto& text : list) { + auto parsed = MapKey::Parse(text); + if (parsed.Key == KeyCode::None) + return ApplyResult::Rejected; + bindings.push_back(std::move(parsed)); + } + const auto serialized = Join(list); // Allocate before publishing live bindings. + if (!MapKey::ReplaceBindings(action, std::move(bindings))) + return ApplyResult::Rejected; + runtime_config::SaveSetting("shortcuts", MapKey::Definition(action).key.c_str(), serialized); + return ApplyResult::Applied; + }}) + , draft(state, [](const auto& first, const auto& second) { + return MapKey::SameBinding(MapKey::Parse(first), MapKey::Parse(second)); + }) + , undo(state) + { + } + ShortcutList Current() + { + auto snapshot = state.Observe(); + return snapshot.state.value.value_or(ShortcutList{}); + } + }; + std::vector> editors; + Editor* recording = nullptr; + std::size_t recordingIndex = 0; + ShortcutCapture capture; + std::vector sampledKeys; + bool (*isFocused)() = nullptr; + Editor* popupEditor = nullptr; + + // Compare the dispatcher's modifier rules as well as the physical key. + // Contexts may still make an overlap intentional; warn without removing either. + std::vector Overlaps(Editor& editor, const std::string& token) + { + const auto candidate = MapKey::Parse(token); + if (keyboard_layout::DescribeChord(candidate.Key).key == KeyCode::None) + return {token + ": layout unavailable; check this binding"}; + std::vector result; + // These direct Enter handlers are outside the configurable MapKey bindings. + if (candidate.Key == KeyCode::Return || candidate.Key == KeyCode::KeypadEnter) { + result.push_back(token + " may also confirm game dialogs."); + if (Config::Get().double_click_to_assign_ship) + result.push_back(token + " also assigns the selected ship while the ship selection screen is open."); + } + for (int i = 0; i < GameFunction::Max; ++i) { + const auto action = static_cast(i); + if (action == editor.function) + continue; + bool overlap = false; + for (const auto& binding : MapKey::Bindings(action)) + overlap |= MapKey::MayOverlap(candidate, binding); + if (!overlap) + continue; + result.push_back(token + " may overlap: " + DescribeShortcut(action, MapKey::Definition(action).key).label); + } + return result; + } + + void Cancel(Editor& editor, bool closePopup = true) + { + if (closePopup && popupEditor == &editor) { + popupEditor = nullptr; + native::CloseShortcutPopup(); + // Closing from preview still owns Escape/Enter/the mouse through release. + capture.Begin(); + capture.Cancel(); + Key::shortcutCaptureActive = true; + } + if (recording == &editor) { + recording = nullptr; + capture.Cancel(); + } + editor.draft.Cancel(); + editor.undo.Clear(); + editor.overlaps.clear(); + editor.status.clear(); + } + void Begin(Editor& editor, std::size_t index, bool keepPopup = false) + { + if (capture.active()) + return; + Cancel(editor, !keepPopup); + editor.draft.Begin(); + const auto list = editor.Current(); + if (index > list.size() || (index == list.size() && list.size() >= ShortcutBindingDisplayLimit)) + return; + recordingIndex = index; + editor.replacing = index < list.size() ? list[index] : ""; + recording = &editor; + capture.Begin(); + Key::shortcutCaptureActive = true; + editor.status = "Release keys, then press a shortcut. Esc cancels."; + } + void OpenEditor(Editor& editor, std::size_t index) + { + Begin(editor, index); + if (recording != &editor) + return; + popupEditor = &editor; + const bool opened = native::OpenShortcutPopup({ + [&editor, index] { + ShortcutPopupPresentation view; + view.title = editor.state.label(); + view.current = editor.replacing.empty() ? "Unbound" : editor.replacing; + if (editor.draft.desired() && index < editor.draft.desired()->size()) + view.proposed = editor.draft.desired()->at(index); + view.status = editor.status; + for (const auto& warning : editor.overlaps) + view.status += "\n" + warning + ""; + view.confirmLabel = editor.overlaps.empty() ? "Confirm" : "Use anyway"; + view.canConfirm = editor.draft.pending() && !capture.active(); + view.canRecord = !capture.active(); + return view; + }, + [&editor, index] { + if (!editor.draft.pending() || capture.active()) + return; + auto overlaps = Overlaps(editor, editor.draft.desired()->at(index)); + if (overlaps != editor.overlaps) { + editor.overlaps = std::move(overlaps); + editor.status = "Shortcut usage changed. Check the warnings and confirm again."; + return; + } + const auto outcome = editor.draft.Apply(); + if (outcome == Outcome::AppliedVerified || outcome == Outcome::Unchanged) { + Cancel(editor); + spdlog::info("[Shortcuts] Popup binding applied: {}", editor.state.id()); + } else { + editor.status = "Binding changed; cancel and reopen to try again."; + } + Notify(); + }, + [&editor, index] { Begin(editor, index, true); }, + [&editor] { Cancel(editor); Notify(); }}); + if (!opened) { + popupEditor = nullptr; + Cancel(editor); + editor.status = "Popup unavailable; no binding changed."; + } + } + void UpdateCapture() + { + if (!capture.active()) + return; // No input scan, layout query or logging while idle. + try { + ShortcutCapture::Keys held{}, down{}; + for (auto key : sampledKeys) { + held[static_cast(key)] = Key::RawPressed(key); + down[static_cast(key)] = Key::RawDown(key); + } + const bool hasFocus = isFocused(); + const bool cancel = !hasFocus || down[static_cast(KeyCode::Escape)]; + auto* editor = recording; + const auto primary = capture.Tick(held, down, hasFocus, Key::IsModifier); + if (editor && cancel) { + Cancel(*editor); + Notify(); + } else if (editor && primary) { + const auto isHeld = [&](KeyCode key) { return held[static_cast(key)]; }; + const auto key = + keyboard_layout::CaptureIdentity(*primary, isHeld(KeyCode::LeftShift) || isHeld(KeyCode::RightShift)); + // Generic modifiers for new bindings, matching ordinary TOML bindings. + // Existing sided modifiers remain untouched unless that binding is replaced. + std::string token = CaptureModifierPrefix(isHeld); + if (key == KeyCode::None || isHeld(KeyCode::AltGr)) { + editor->status = "This key/layout is unavailable; record another"; + } else { + token += Key::Token(key); + const auto result = editor->draft.Stage(recordingIndex, token); + editor->overlaps.clear(); + if (result == ShortcutStage::Staged) { + editor->overlaps = Overlaps(*editor, token); + editor->status = editor->replacing.empty() ? "Add " + token + : editor->replacing + " -> " + token; + } else { + editor->status = result == ShortcutStage::AlreadyBound + ? "Already bound: " + token + : "Binding unavailable; reopen and try again"; + } + } + recording = nullptr; + Notify(); + } else if (editor && !capture.active()) { + recording = nullptr; + editor->status = "Recording cancelled; press one primary key"; + Notify(); + } + const bool wasBlocked = Key::shortcutCaptureActive; + Key::shortcutCaptureActive = capture.active(); + if (wasBlocked != Key::shortcutCaptureActive) + Notify(); + } catch (...) { + capture.Cancel(); + if (recording) { + recording->draft.Cancel(); + recording = nullptr; + } + // Continue draining via raw input on subsequent frames; never leave a draft + // from a failed capture eligible for application. + static bool warned = false; + if (!warned) { + warned = true; + spdlog::warn("[Shortcuts] Key recording unavailable"); + } + } + } + void AddRows(PageCatalog& catalog, Editor& editor) + { + catalog.OnLeave(editor.state.id(), [&editor] { Cancel(editor); }); + catalog.SetSummary(editor.state.id(), [&editor] { + const auto& bindings = MapKey::Bindings(editor.function); + if (bindings.empty()) + return std::string{"Unbound"}; + return bindings.front().GetParsedValues() + + (bindings.size() > 1 ? " +" + std::to_string(bindings.size() - 1) : ""); + }); + auto add = [&](const char* id, const char* label, auto read, auto invoke, std::function count = {}) { + auto row = std::make_unique(); + row->identity = editor.state.id() + "." + id; + row->label = label; + row->read = read; + row->invoke = [invoke](std::size_t index) { + invoke(index); + Notify(); + }; + row->count = std::move(count); + catalog.AddAction(editor.state.id(), *row); + editor.rows.push_back(std::move(row)); + }; + using P = ActionSetting::Presentation; + add( + "status", "Shortcut status", + [&editor](std::size_t) { + if (popupEditor == &editor) + return P{"", "", "", false, false}; + if (editor.undo.available()) + return P{"" + editor.undo.message() + "", "Undo", "", !capture.active()}; + return P{editor.status, "", "", false, !editor.status.empty()}; + }, + [&editor](std::size_t) { + if (editor.undo.available()) { + const auto result = editor.undo.Undo(); + editor.status = result == Outcome::AppliedVerified ? "" + : result == Outcome::Conflict ? "Bindings changed; undo cancelled." + : "Could not verify undo; check current bindings."; + } + }); + const auto help = ShortcutExplanation(editor.function); + if (!help.empty()) + add( + "help", "About this shortcut", [help](std::size_t) { return P{std::string(help), "", "", false}; }, + [](std::size_t) {}); + auto count = [&editor] { return VisibleShortcutBindingCount(editor.Current().size()); }; + add( + "binding", "Shortcut", + [&editor](std::size_t index) { + const auto list = editor.Current(); + return P{list.at(index), "Change", "", + popupEditor != &editor && !capture.active() && !editor.draft.pending()}; + }, + [&editor](std::size_t index) { OpenEditor(editor, index); }, count); + add( + "add", "Add shortcut", + [&editor](std::size_t) { + const auto size = editor.Current().size(); + if (size > ShortcutBindingDisplayLimit) + return P{"Showing first " + std::to_string(ShortcutBindingDisplayLimit) + "; remove shortcuts or edit TOML", + "", "", false}; + if (size == ShortcutBindingDisplayLimit) + return P{"Remove a shortcut before adding another", "", "", false}; + return P{size == 0 ? "No shortcut assigned" : "Add shortcut", "Record", "", + popupEditor != &editor && !capture.active() && !editor.draft.pending()}; + }, + [&editor](std::size_t) { OpenEditor(editor, editor.Current().size()); }); + add( + "remove", "Remove shortcut", + [&editor](std::size_t index) { + return P{editor.Current().at(index), "Remove", "", !capture.active() && !editor.draft.pending(), + popupEditor != &editor}; + }, + [&editor](std::size_t index) { + const auto list = editor.Current(); + if (index >= list.size()) + return; + Cancel(editor); + const auto result = editor.undo.Remove(index); + editor.status = result == Outcome::AppliedVerified ? "Removed " + list[index] + : "Could not verify removal; check current bindings."; + }, + count); + // Parse the same canonical defaults for visibility and restoration. An extra + // alternative still makes Restore useful, even if the default is present. + const auto defaults = [&editor]() -> std::optional { + const auto& definition = MapKey::Definition(editor.function).defaultBinding; + ShortcutList result; + if (AsciiStrToUpper(StripAsciiWhitespace(definition)) != "NONE") { + for (const auto& token : StrSplit(definition, '|')) { + const auto parsed = MapKey::Parse(token); + if (parsed.Key == KeyCode::None) + return std::nullopt; + result.push_back(parsed.GetParsedValues()); + } + } + return result; + }(); + add( + "default", "Restore default", + [&editor, defaults](std::size_t) { + return P{"Default: " + MapKey::Definition(editor.function).defaultBinding, "Restore", "", + defaults.has_value() && !capture.active() && !editor.draft.pending(), + popupEditor != &editor && (!defaults || editor.Current() != *defaults)}; + }, + [&editor, defaults](std::size_t) { + if (!defaults) + return; + Cancel(editor); + const auto result = editor.undo.Restore(*defaults); + editor.status = result == Outcome::AppliedVerified ? "Restored defaults" + : result == Outcome::Unchanged ? "Already using defaults" + : "Could not verify restore; check current bindings."; + }); + } +} // namespace + +void SetShortcutPresentationObserver(void (*observer)()) +{ changed = observer; } +void RegisterShortcutPages(PageCatalog& catalog) +{ + if (!editors.empty() || !Config::Get().installHotkeyHooks || Config::Get().use_scopely_hotkeys) + return; +// macOS ports the shared native settings adapter; capture stays in physical +// keyboard mode and relies on the same runtime-resolved hooks as Windows. +// Only Windows x64 resolves layout-dependent printable chords. +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + // Capture must observe a focused release before returning keys to gameplay. + // A missing query is not ordinary focus loss: never offer capture without it. + isFocused = il2cpp_resolve_icall_typed("UnityEngine.Application::get_isFocused()"); + if (!isFocused) { + spdlog::warn("[Shortcuts] Editor unavailable: focus query not resolved"); + return; + } + if (!install_screen_manager_update_hook() || !register_screen_manager_update_callback(UpdateCapture)) + return; + for (int i = 1; i < static_cast(KeyCode::Max); ++i) { + const auto key = static_cast(i); + if (!Key::Token(key).empty()) + sampledKeys.push_back(key); + } + catalog.AddPage("community_mod.shortcuts", "Shortcuts", "community_mod.settings"); + for (const auto& group : ShortcutGroups) + catalog.AddPage(std::string("community_mod.shortcuts.") + std::string(group.id), std::string(group.label), + "community_mod.shortcuts"); + const auto ordered = DiscoverShortcuts([](GameFunction action) -> std::string_view { + // A startup NONE binding opts out of installing the hints adapter. Do not + // present a live editor for an action that cannot dispatch in this session. + if (action == GameFunction::ToggleShortcutHints && !ShortcutHintControlAvailable()) + return {}; + return MapKey::Definition(action).key; + }); + for (const auto& info : ordered) { +#ifdef _MODDBG + if (info.fallback) + spdlog::warn("[Shortcuts] {} has no presentation override; listed in Uncategorized", + MapKey::Definition(info.action).key); +#endif + auto editor = std::make_unique(info.action, info.label); + const auto group = std::find_if(ShortcutGroups.begin(), ShortcutGroups.end(), + [&](const auto& group) { return group.group == info.group; }); + catalog.AddPage(editor->state.id(), editor->state.label(), + std::string("community_mod.shortcuts.") + std::string(group->id)); + AddRows(catalog, *editor); + editors.push_back(std::move(editor)); + } +#endif +} +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_settings.h b/mods/src/settings/shortcut_settings.h new file mode 100644 index 000000000..127084985 --- /dev/null +++ b/mods/src/settings/shortcut_settings.h @@ -0,0 +1,8 @@ +#pragma once +namespace mod_settings +{ +class PageCatalog; +void RegisterShortcutPages(PageCatalog& catalog); +void SetShortcutPresentationObserver(void (*observer)()); +bool ShortcutHintControlAvailable(); +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_undo.h b/mods/src/settings/shortcut_undo.h new file mode 100644 index 000000000..fc0b4991e --- /dev/null +++ b/mods/src/settings/shortcut_undo.h @@ -0,0 +1,82 @@ +#pragma once +#include "shortcut_draft.h" + +namespace mod_settings +{ +// One immediate change per editor. Undo restores the exact list (including order and +// existing duplicates), and cannot overwrite a subsequent binding change. +class ShortcutUndo +{ +public: + explicit ShortcutUndo(ValueSetting& owner) + : owner_(owner) + { + } + Outcome Remove(std::size_t index) + { + Clear(); + const auto before = owner_.Observe(); + if (!before.state.known() || index >= before.state.value->size()) + return Outcome::Rejected; + auto desired = *before.state.value; + const auto removed = desired[index]; + desired.erase(desired.begin() + index); + return Commit(std::move(desired), before, "Removed " + removed); + } + Outcome Restore(ShortcutList defaults) + { + Clear(); + const auto before = owner_.Observe(); + if (!before.state.known()) + return Outcome::Rejected; + return Commit(std::move(defaults), before, "Restored defaults"); + } + Outcome Undo() + { + if (!original_) + return Outcome::Suppressed; + struct Scope { + bool& active; + explicit Scope(bool& value) + : active(value) + { active = true; } + ~Scope() + { active = false; } + } scope(restoring_); + const auto result = owner_.SetFromUser(*original_, after_).outcome; + Clear(); + return result; + } + bool available() const + { return original_.has_value(); } + const std::string& message() const + { return message_; } + // An oversized player-authored list may be restored only by this exact undo, + // not used as permission for arbitrary additions beyond the UI's row budget. + bool Restoring(const ShortcutList& value) const + { return restoring_ && original_ && value == *original_; } + void Clear() + { + original_.reset(); + after_ = {}; + message_.clear(); + } + +private: + Outcome Commit(ShortcutList desired, const ValueSnapshot& before, std::string message) + { + const auto result = owner_.SetFromUser(std::move(desired), before); + if (result.outcome == Outcome::AppliedVerified) { + original_ = *before.state.value; + after_ = result.snapshot; + message_ = std::move(message); + } + return result.outcome; + } + ValueSetting& owner_; + std::optional original_; + ValueSnapshot after_; + std::string message_; + bool restoring_ = false; +}; +} // namespace mod_settings diff --git a/mods/src/settings/slider_setting.h b/mods/src/settings/slider_setting.h new file mode 100644 index 000000000..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/keyboard_chord_tests.cc b/tests/keyboard_chord_tests.cc new file mode 100644 index 000000000..f3876be4d --- /dev/null +++ b/tests/keyboard_chord_tests.cc @@ -0,0 +1,48 @@ +#include "patches/keyboard_layout_windows.h" +#include +#include + +void Check(bool value, const char* message) +{ + if (!value) + throw std::runtime_error(message); +} + +int main() +{ +#if _WIN32 + using namespace keyboard_layout; + HKL layouts[256]{}; + const int count = GetKeyboardLayoutList(256, layouts); + HKL de = nullptr, us = nullptr; + for (int i = 0; i < count && i < 256; ++i) { + const auto id = reinterpret_cast(layouts[i]) & 0xffffffffu; + if (id == 0x04070407u) + de = layouts[i]; + if (id == 0x04090409u) + us = layouts[i]; + } + if (!de || !us) { + std::cout << "SKIP: requires already-loaded standard US and German layouts\n"; + return 0; + } + // Query explicit layouts without activating, loading or unloading any layout. + const auto original = GetKeyboardLayout(0); + for (const auto [character, key] : + {std::pair{'/', KeyCode::Alpha7}, {'=', KeyCode::Alpha0}, {'`', KeyCode::Equals}, {'\'', KeyCode::Backslash}}) { + const auto chord = FindWindowsChord(character, de); + Check(chord.key == key && chord.shift, "German shifted punctuation"); + } + Check(FindWindowsChord('^', de).key == KeyCode::BackQuote && !FindWindowsChord('^', de).shift, + "German unshifted dead key"); + Check(FindWindowsChord('Z', de).key == KeyCode::Y && !FindWindowsChord('Z', de).shift, + "Uppercase config letter does not infer Shift"); + Check(FindWindowsChord('@', de).key == KeyCode::None, "AltGr stays unsupported"); + Check(FindWindowsChord('/', us).key == KeyCode::Slash && !FindWindowsChord('/', us).shift, "US slash"); + Check(FindWindowsChord('\'', us).key == KeyCode::Quote && !FindWindowsChord('\'', us).shift, "US apostrophe"); + Check(FindWindowsChord('/', nullptr).key == KeyCode::None, "Absent layout"); + Check(ResolveWindowsChord('/', "invalid").key == KeyCode::None, "Layout mismatch fails closed"); + Check(GetKeyboardLayout(0) == original, "Test did not switch layout"); +#endif + std::cout << "Keyboard chord tests passed\n"; +} diff --git a/tests/keyboard_layout_tests.cc b/tests/keyboard_layout_tests.cc new file mode 100644 index 000000000..fcff12e3b --- /dev/null +++ b/tests/keyboard_layout_tests.cc @@ -0,0 +1,168 @@ +#include "patches/keyboard_layout_mapping.h" +#include "patches/keyboard_layout_refresh.h" +#include "patches/keyboard_layout_windows.h" + +#include +#include +#include + +using namespace keyboard_layout; + +void Check(bool condition, const char* name) +{ + if (!condition) { + std::cerr << "FAIL: " << name << '\n'; + std::exit(1); + } +} + +int main() +{ + Check(ToLegacyKey(39) == KeyCode::Y && ToLegacyKey(40) == KeyCode::Z, "distinct enum translation Y/Z"); + Check(ToLegacyKey(6) == KeyCode::Semicolon, "French M punctuation position"); + for (const auto code : {-1, 0, 51, 9999}) + Check(ToLegacyKey(code) == KeyCode::None, "unsupported codes fail closed"); + +#if _WIN32 + // Use installed fixtures without loading/unloading system keyboard layouts. + const auto before = GetKeyboardLayout(0); + HKL existing[256]{}; + const auto count = GetKeyboardLayoutList(256, existing); + Check(count > 0, "existing Windows layouts available"); + HKL german = nullptr, american = nullptr; + for (int i = 0; i < count && i < 256; ++i) { + const auto id = reinterpret_cast(existing[i]) & 0xffffffffu; + if (id == 0x04070407u) german = existing[i]; + if (id == 0x04090409u) american = existing[i]; + } + if (german && american) { + Check(FindUnshiftedDeadKey('^', german) == KeyCode::BackQuote, "German circumflex physical position"); + BYTE keyboard_state[256]{}; + wchar_t translated[8]{}; + const auto accent_vk = MapVirtualKeyExW(0x29, MAPVK_VSC_TO_VK_EX, german); + Check(ToUnicodeEx(accent_vk, 0x29, keyboard_state, translated, 8, 0, german) < 0, "seed pending accent"); + Check(FindUnshiftedDeadKey('^', german) == KeyCode::BackQuote, "lookup with pending accent"); + Check(ToUnicodeEx('A', 0x1e, keyboard_state, translated, 8, 0, german) == 1 && translated[0] == L'\u00e2', + "native lookup preserves pending accent composition"); + for (const char symbol : {'/', '=', '`', 'a'}) + Check(FindUnshiftedDeadKey(symbol, german) == KeyCode::None, "shifted and non-dead symbols excluded"); + Check(FindUnshiftedDeadKey('^', american) == KeyCode::None, "US shifted caret excluded"); + Check(FindUnshiftedDeadKey('^', nullptr) == KeyCode::None, "missing Windows layout fails closed"); + Check(ResolveWindowsChord('^', "not-the-active-layout").key == KeyCode::None, "layout disagreement fails closed"); + Check(GetKeyboardLayout(0) == before, "lookup does not activate a layout"); + } else { + std::cout << "SKIP: native dead-key fixtures require already-loaded US and German layouts\n"; + } +#endif + + LayoutKeys us{}; + for (int i = 0; i < 26; ++i) { + us[static_cast(KeyCode::A) + i] = ToLegacyKey(15 + i); + Check(us[static_cast(KeyCode::A) + i] == static_cast(static_cast(KeyCode::A) + i), + "all US letter positions"); + } + us[static_cast(KeyCode::Alpha1)] = KeyCode::Alpha1; + us[static_cast(KeyCode::Slash)] = KeyCode::Slash; + std::array(KeyCode::Max)> held{}; + int frame = 10, clock_calls = 0, held_calls = 0; + auto is_held = [&](KeyCode key) { return held[static_cast(key)]; }; + BindingState state; + auto resolve = [&](KeyCode key) { + return state.Resolve(key, [&] { ++clock_calls; return frame; }, + [&](KeyCode physical) { ++held_calls; return is_held(physical); }); + }; + Check(resolve(KeyCode::Z) == KeyCode::None && resolve(KeyCode::Alpha1) == KeyCode::None, "pending keys disabled"); + for (const auto key : {KeyCode::Escape, KeyCode::UpArrow, KeyCode::F1, KeyCode::Space, KeyCode::Return, + KeyCode::LeftControl, KeyCode::Keypad1, KeyCode::KeypadPlus, KeyCode::Mouse0}) + Check(resolve(key) == key, "named controls keep identity"); + Check(clock_calls == 0 && held_calls == 0, "pending and named keys never query frame or input"); + + state.Replace(us, is_held, frame); + Check(resolve(KeyCode::Z) == KeyCode::None && resolve(KeyCode::Alpha1) == KeyCode::None, + "entire transition frame suppressed"); + ++frame; + Check(resolve(KeyCode::Z) == KeyCode::Z, "first later query clears transition"); + clock_calls = held_calls = 0; + for (int i = 0; i < 1000; ++i) { + ++frame; + Check(resolve(KeyCode::Z) == KeyCode::Z && resolve(KeyCode::Alpha1) == KeyCode::Alpha1 + && resolve(KeyCode::Slash) == KeyCode::Slash, "quiet cached mappings"); + } + Check(clock_calls == 0 && held_calls == 0, "quiet frames perform no clock or held-state polling"); + + auto de = us; + de[static_cast(KeyCode::Y)] = KeyCode::Z; + de[static_cast(KeyCode::Z)] = KeyCode::Y; + de[static_cast(KeyCode::Plus)] = KeyCode::RightBracket; + held[static_cast(KeyCode::Y)] = true; + held[static_cast(KeyCode::RightBracket)] = true; + state.Replace(de, is_held, frame); + Check(resolve(KeyCode::Z) == KeyCode::None, "German transition suppressed"); + ++frame; + Check(resolve(KeyCode::Y) == KeyCode::Z, "German Y uses US Z"); + Check(resolve(KeyCode::Z) == KeyCode::None && resolve(KeyCode::Plus) == KeyCode::None, + "held letter and punctuation blocked"); + ++frame; + Check(resolve(KeyCode::Z) == KeyCode::None, "hold remains blocked"); + held.fill(false); + Check(resolve(KeyCode::Z) == KeyCode::Y && resolve(KeyCode::Plus) == KeyCode::RightBracket, + "release unblocks only the queried target"); + Check(resolve(KeyCode::LeftParen) == KeyCode::None, "missing symbol has no physical fallback"); + clock_calls = held_calls = 0; + ++frame; + Check(resolve(KeyCode::Plus) == KeyCode::RightBracket && resolve(KeyCode::Z) == KeyCode::Y, + "released mapping cached"); + Check(clock_calls == 0 && held_calls == 0, "release checks stop once target is unblocked"); + + auto fr = us; + fr[static_cast(KeyCode::A)] = KeyCode::Q; + fr[static_cast(KeyCode::Q)] = KeyCode::A; + fr[static_cast(KeyCode::W)] = KeyCode::Z; + fr[static_cast(KeyCode::Z)] = KeyCode::W; + fr[static_cast(KeyCode::M)] = ToLegacyKey(6); + fr[static_cast(KeyCode::Y)] = KeyCode::None; + state.Replace(fr, is_held, frame); + ++frame; + Check(resolve(KeyCode::A) == KeyCode::Q && resolve(KeyCode::Q) == KeyCode::A, "French A/Q"); + Check(resolve(KeyCode::W) == KeyCode::Z && resolve(KeyCode::Z) == KeyCode::W, "French W/Z"); + Check(resolve(KeyCode::M) == KeyCode::Semicolon && resolve(KeyCode::Y) == KeyCode::None, "French M and missing key"); + + RefreshState refresh; + // A failed symbol lookup publishes a partial map, without losing unrelated + // letters/digits. A later layout generation must restore that symbol. + auto partial = us; + partial[static_cast(KeyCode::Slash)] = KeyCode::None; + state.Replace(partial, is_held, frame); + ++frame; + Check(resolve(KeyCode::Slash) == KeyCode::None && resolve(KeyCode::Z) == KeyCode::Z + && resolve(KeyCode::Alpha1) == KeyCode::Alpha1, "unavailable symbol isolates letters and digits"); + state.Replace(us, is_held, frame); + ++frame; + Check(resolve(KeyCode::Slash) == KeyCode::Slash, "later mapping restores unavailable symbol"); + Check(refresh.Consume(), "one initial lookup"); + for (int i = 0; i < 1000; ++i) + Check(!refresh.Consume(), "no notification means no refresh, regardless of elapsed frames"); + refresh.Invalidate(); + refresh.Invalidate(); + Check(refresh.Consume() && !refresh.Consume(), "notifications coalesce"); + refresh.Invalidate(); // A callback during rebuild must survive consumption. + Check(refresh.Consume(), "notification during refresh is preserved"); + state.Replace(us, is_held, frame); + refresh.Invalidate(); + Check(refresh.Consume(), "same-frame event refreshes too"); + state.Replace(de, is_held, frame); + Check(resolve(KeyCode::Y) == KeyCode::None, "same-frame rebuild cannot clear suppression"); + ++frame; + Check(resolve(KeyCode::Z) == KeyCode::Y, "next frame resolves after same-frame event"); + state.Clear(); + clock_calls = held_calls = 0; + Check(resolve(KeyCode::Z) == KeyCode::None && resolve(KeyCode::Plus) == KeyCode::None + && resolve(KeyCode::Alpha1) == KeyCode::None, "device/setup failure clears all printable mappings"); + Check(clock_calls == 0 && held_calls == 0, "unavailable mappings never poll"); + for (const auto key : {KeyCode::Alpha0, KeyCode::Alpha9, KeyCode::Exclaim, KeyCode::At, + KeyCode::LeftBracket, KeyCode::Tilde, KeyCode::Pipe, KeyCode::Minus}) + Check(IsLayoutKey(key), "digits and punctuation eligible"); + Check(!IsLayoutKey(KeyCode::None) && !IsLayoutKey(KeyCode::Delete) + && !IsLayoutKey(static_cast(65)), "enum gaps excluded"); + std::cout << "PASS: mapping, event-only refresh, zero quiet-frame polling, transitions, held keys, failure clearing\n"; +} 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..abf73bd68 --- /dev/null +++ b/tests/run-config-save.ps1 @@ -0,0 +1,107 @@ +[CmdletBinding()] +param([string]$TomlInclude) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +function Get-PermissionState([string]$Path) { + $acl = Get-Acl -LiteralPath $Path + # Windows can normalize descriptor control bits; compare actual rules, + # ownership and inheritance protection rather than serialized SDDL spelling. + [ordered]@{ + Owner = $acl.Owner + Group = $acl.Group + Protected = $acl.AreAccessRulesProtected + Rules = @($acl.Access | Select-Object IdentityReference, FileSystemRights, + AccessControlType, IsInherited, InheritanceFlags, PropagationFlags) + } | ConvertTo-Json -Depth 5 -Compress +} +Push-Location $repoRoot +try { + if (-not $TomlInclude) { + $packageRoot = Join-Path $env:LOCALAPPDATA '.xmake/packages/t/toml++' + $header = Get-ChildItem -LiteralPath $packageRoot -Recurse -Filter toml.h | + Where-Object { $_.Directory.Name -eq 'toml++' } | Select-Object -First 1 + if (-not $header) { throw 'Build with AX first, or supply -TomlInclude.' } + $TomlInclude = $header.Directory.Parent.FullName + } + New-Item -ItemType Directory -Force build/config-save-test | Out-Null + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/config_save_test.cc mods/src/config_save.cc /Febuild/config-save-test/test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Config save test compilation failed.' } + $fixtureRoot = Join-Path $repoRoot ('build/config-save-test/' + [guid]::NewGuid()) + & ./build/config-save-test/test.exe (Join-Path $fixtureRoot 'created') + if ($LASTEXITCODE -ne 0) { throw 'Initial config creation regression failed.' } + $fixtureRoot = Join-Path $fixtureRoot 'permissions' + # Establish the baseline independently, before the first production save. + New-Item -ItemType Directory -Path $fixtureRoot | Out-Null + $testFile = Join-Path $fixtureRoot 'settings.toml' + Set-Content -LiteralPath $testFile -Value 'enabled = false' + if (-not ((Get-Acl -LiteralPath $testFile).Access | Where-Object IsInherited)) { + throw 'Fixture must have inherited permission entries.' + } + $inherited = Get-PermissionState $testFile + & ./build/config-save-test/test.exe $fixtureRoot + if ($LASTEXITCODE -ne 0 -or (Get-PermissionState $testFile) -ne $inherited) { + throw 'Inherited ACL regression failed.' + } + $acl = Get-Acl -LiteralPath $testFile + $acl.SetAccessRuleProtection($true, $true) + Set-Acl -LiteralPath $testFile -AclObject $acl + $explicit = Get-PermissionState $testFile + & ./build/config-save-test/test.exe $fixtureRoot + if ($LASTEXITCODE -ne 0 -or (Get-PermissionState $testFile) -ne $explicit) { + throw 'Explicit ACL regression failed.' + } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/config_save_failure_test.cc /Febuild/config-save-test/failure-test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Config failure test compilation failed.' } + & ./build/config-save-test/failure-test.exe (Join-Path $fixtureRoot 'failures') + if ($LASTEXITCODE -ne 0) { throw 'Config failure regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/toml_editor_test.cc mods/src/toml_editor.cc mods/src/config_save.cc ` + /Febuild/config-save-test/editor-test.exe /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'TOML editor test compilation failed.' } + & ./build/config-save-test/editor-test.exe (Join-Path $fixtureRoot 'editor') + if ($LASTEXITCODE -ne 0) { throw 'TOML editor regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/runtime_config_writer_test.cc /Febuild/config-save-test/worker-test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Runtime writer test compilation failed.' } + & ./build/config-save-test/worker-test.exe + if ($LASTEXITCODE -ne 0) { throw 'Runtime writer regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Itests ` + tests/runtime_config_test.cc /Febuild/config-save-test/adapter-test.exe /Fobuild/config-save-test/ + if ($LASTEXITCODE -ne 0) { throw 'Native adapter test compilation failed.' } + & ./build/config-save-test/adapter-test.exe + if ($LASTEXITCODE -ne 0) { throw 'Native adapter regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/runtime_config_persistence_test.cc mods/src/runtime_config_writer.cc ` + mods/src/toml_editor.cc mods/src/config_save.cc ` + /Febuild/config-save-test/persistence-test.exe /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Persistence reload test compilation failed.' } + & ./build/config-save-test/persistence-test.exe (Join-Path $fixtureRoot 'reload') + if ($LASTEXITCODE -ne 0) { throw 'Persistence reload regression failed.' } + foreach ($mode in @('idle', 'deadline', 'finished', 'missing-handle')) { + $outputPath = Join-Path $fixtureRoot ('force-' + $mode + '.txt') + $timer = [Diagnostics.Stopwatch]::StartNew() + $child = Start-Process -FilePath (Join-Path $repoRoot 'build/config-save-test/adapter-test.exe') ` + -ArgumentList $mode -WindowStyle Hidden -PassThru -RedirectStandardOutput $outputPath + if (-not $child.WaitForExit(5000)) { + $child.Kill() + throw "Force-close fixture stalled: $mode" + } + $child.Refresh() + if ($child.ExitCode -ne 1) { throw "Force-close fixture did not terminate: $mode" } + if ($mode -eq 'deadline') { + if ($timer.ElapsedMilliseconds -lt 450 -or + (Get-Content $outputPath -Raw) -notmatch 'pending cancellation requested') { + throw 'Deadline did not allow best effort and request cancellation.' + } + } + } + Write-Output 'Native force-close child-process fixtures passed (500ms requested deadline; 5s harness watchdog).' +} finally { + Pop-Location +} diff --git a/tests/run-config-save.sh b/tests/run-config-save.sh new file mode 100644 index 000000000..a370ced9b --- /dev/null +++ b/tests/run-config-save.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Pass the include directory of the toml++ package used by the normal build. +toml_include="${1:?usage: run-config-save.sh TOML_INCLUDE_DIR}" +cd "$(dirname "$0")/.." +mkdir -p build/config-save-test +test_root="$(mktemp -d "$PWD/build/config-save-test/run-XXXXXX")" + +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/config_save_test.cc mods/src/config_save.cc -o "$test_root/test" +"$test_root/test" "$test_root/ordinary" +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/config_save_failure_test.cc -o "$test_root/failure-test" +"$test_root/failure-test" "$test_root/failures" +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/toml_editor_test.cc mods/src/toml_editor.cc mods/src/config_save.cc -o "$test_root/editor-test" +"$test_root/editor-test" "$test_root/editor" +clang++ -std=c++23 -pthread -I mods/src -I "$toml_include" \ + tests/runtime_config_writer_test.cc -o "$test_root/worker-test" +"$test_root/worker-test" + +# Compile the actual platform adapter, including Mac save admission and quit draining. +clang++ -std=c++23 -pthread -I mods/src -I tests \ + tests/runtime_config_test.cc -o "$test_root/adapter-test" +"$test_root/adapter-test" + +clang++ -std=c++23 -pthread -I mods/src -I "$toml_include" \ + tests/runtime_config_persistence_test.cc mods/src/runtime_config_writer.cc \ + mods/src/toml_editor.cc mods/src/config_save.cc -o "$test_root/persistence-test" +"$test_root/persistence-test" "$test_root/reload" diff --git a/tests/run-confirmation-settings.ps1 b/tests/run-confirmation-settings.ps1 new file mode 100644 index 000000000..eaa0c7d31 --- /dev/null +++ b/tests/run-confirmation-settings.ps1 @@ -0,0 +1,17 @@ +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + New-Item -ItemType Directory -Force build/settings-test | Out-Null + foreach ($fixture in @('boolean_settings', 'boolean_view', 'native_boolean_callback')) { + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src /Ithird_party/libil2cpp ` + "tests/${fixture}_test.cc" "/Febuild/settings-test/$fixture.exe" /Fobuild/settings-test/ + if ($LASTEXITCODE -ne 0) { throw "Settings fixture compilation failed: $fixture" } + & "./build/settings-test/$fixture.exe" + if ($LASTEXITCODE -ne 0) { throw "Settings fixture failed: $fixture" } + } +} finally { + Pop-Location +} diff --git a/tests/run-confirmation-settings.sh b/tests/run-confirmation-settings.sh new file mode 100644 index 000000000..f22f7c042 --- /dev/null +++ b/tests/run-confirmation-settings.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p build/settings-test +for fixture in boolean_settings boolean_view native_boolean_callback; do + clang++ -std=c++23 -pthread -I mods/src -I third_party/libil2cpp \ + "tests/${fixture}_test.cc" -o "build/settings-test/$fixture" + "build/settings-test/$fixture" +done diff --git a/tests/run-settings.ps1 b/tests/run-settings.ps1 new file mode 100644 index 000000000..a34e9a139 --- /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', 'shortcut_editor')) { + & 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..b4c535617 --- /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 shortcut_editor; 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/shortcut_editor_test.cc b/tests/shortcut_editor_test.cc new file mode 100644 index 000000000..bcb256a33 --- /dev/null +++ b/tests/shortcut_editor_test.cc @@ -0,0 +1,337 @@ +#include "settings/shortcut_capture.h" +#include "settings/shortcut_catalog.h" +#include "settings/shortcut_draft.h" +#include "settings/shortcut_undo.h" +#include "settings/shortcut_popup_keys.h" +#include +#include + +using namespace mod_settings; +void Check(bool ok, const char* label) +{ + if (!ok) { + std::cerr << label << '\n'; + std::exit(1); + } +} +int main() +{ + constexpr auto limit = ShortcutBindingDisplayLimit; + Check(ShortcutCountFitsEdit(limit - 1, limit) && !ShortcutCountFitsEdit(limit, limit + 1), + "native row budget rejects oversized growth before publication"); + Check(ShortcutCountFitsEdit(62, 62) && ShortcutCountFitsEdit(62, 61) && !ShortcutCountFitsEdit(62, 63), + "existing oversized TOML lists can be rebound/reduced but not grown in UI"); + Check(VisibleShortcutBindingCount(0) == 0 && VisibleShortcutBindingCount(1) == 1, + "empty/single-binding pages have no extra binding rows"); + for (std::size_t count : {61u, 62u, 1000u}) { + Check(VisibleShortcutBindingCount(count) == limit, + "existing long lists present a bounded prefix without dropping other settings pages"); + Check(2 * VisibleShortcutBindingCount(count) + ShortcutFixedRowLimit <= PageCatalog::NativeChildLimit, + "Change and Remove plus fixed rows fit native page capacity"); + } + Check(DescribeShortcut(ShowInventory, "show_inventory").group == ShortcutGroup::Screens + && DescribeShortcut(ShowArtifacts, "show_artifacts").group == ShortcutGroup::Screens, + "opening inventory/artifacts belongs to game screens"); + Check(DescribeShortcut(ToggleAutoConfirmInstantWarp, "toggle_instant_warp").group == ShortcutGroup::Travel, + "instant warp shortcut belongs to map and travel"); + Check(DescribeShortcut(Restart, "restart").label == "Clear localization cache and reload", + "cache-clearing shortcut must not promise a plain restart"); + Check(DescribeShortcut(Quit, "quit").label == "Force close client" && !ShortcutExplanation(Quit).empty(), + "force close must be identified before binding it"); + + // A sparse override list models adding an action without editing UI metadata. + // Discovery depends on registered keys, never on whether a binding is present. + constexpr auto partial = std::to_array({ + {MoveRight, ShortcutGroup::Camera, "Pan right"}, + {ShowArtifacts, ShortcutGroup::Screens, "Open artifacts"}, + }); + static_assert(ValidShortcutCatalog(partial)); + constexpr auto duplicates = std::to_array({ + {MoveRight, ShortcutGroup::Camera, "Pan right"}, + {MoveRight, ShortcutGroup::Travel, "Duplicate"}, + }); + static_assert(!ValidShortcutCatalog(duplicates)); + constexpr auto invalidAction = std::to_array({{GameFunction::Max, ShortcutGroup::Camera, "Invalid"}}); + constexpr auto invalidGroup = std::to_array({{MoveRight, static_cast(99), "Invalid"}}); + constexpr auto emptyLabel = std::to_array({{MoveRight, ShortcutGroup::Camera, ""}}); + static_assert(!ValidShortcutCatalog(invalidAction) && !ValidShortcutCatalog(invalidGroup) + && !ValidShortcutCatalog(emptyLabel)); + std::array registered{}; + registered[MoveLeft] = "toggle_new_feature"; + registered[MoveRight] = "move_right"; + registered[MoveUp] = "test_value_2"; + auto key = [&](GameFunction action) { return registered[action]; }; + const auto discovered = DiscoverShortcuts(key, partial); + Check(discovered.size() == 3 && discovered[0].action == MoveRight && discovered[0].label == "Pan right" + && !discovered[0].fallback && discovered[0].group == ShortcutGroup::Camera, + "registered actions keep explicit labels and categories; metadata alone cannot expose an unregistered action"); + Check(discovered[1].action == MoveUp && discovered[1].label == "Test value 2" && discovered[1].fallback + && discovered[2].action == MoveLeft && discovered[2].label == "Toggle new feature" + && discovered[2].group == ShortcutGroup::Uncategorized, + "uncatalogued registered actions are discovered and sorted with readable labels"); + const auto promoted = std::to_array({{MoveLeft, ShortcutGroup::Interface, "Toggle polished feature"}}); + const auto description = DescribeShortcut(MoveLeft, registered[MoveLeft], promoted); + Check(description.action == discovered[2].action && description.group == ShortcutGroup::Interface + && description.label == "Toggle polished feature" && !description.fallback, + "a later override changes presentation without changing action identity"); + registered[MoveLeft] = {}; + Check(DiscoverShortcuts(key, partial).size() == 2, + "startup availability filtering excludes actions before fallback discovery"); + registered = {}; + Check(DiscoverShortcuts(key, partial).empty(), "an empty registry cannot create fallback editors"); + Check(ShortcutGroups[ShortcutGroups.size() - 2].group == ShortcutGroup::Uncategorized + && ShortcutGroups.back().group == ShortcutGroup::Diagnostics, + "Uncategorized precedes Diagnostics at the bottom"); + Check(HumanizeShortcutKey("__open--panel_2__") == "Open panel 2" && HumanizeShortcutKey("__") == "Shortcut", + "fallback labels collapse separators, retain numbers and never become blank"); + + ShortcutList live{"LCTRL-G", "F8"}; + int writes = 0; + ValueSetting owner({"shortcuts.test", "Test", + [&] { return ValueReadResult::Known(live, 1); }, + [&](ShortcutList value, std::uint64_t) { + ++writes; + live = value; + return ApplyResult::Applied; + }}); + ShortcutDraft draft(owner); + draft.Begin(); + draft.Stage(1, "ALT-I"); + Check(writes == 0 && live == ShortcutList{"LCTRL-G", "F8"}, "recording must not write"); + Check(draft.Apply() == Outcome::AppliedVerified && live == ShortcutList{"LCTRL-G", "ALT-I"}, + "replace preserves alternatives and sided modifiers"); + draft.Begin(); + draft.Stage(2, "F9"); + draft.Cancel(); + Check(draft.Apply() == Outcome::Suppressed && writes == 1, "cancel never writes"); + draft.Begin(); + draft.Stage(2, "F9"); + Check(draft.Apply() == Outcome::AppliedVerified && live.size() == 3, "append preserves previous alternatives"); + draft.Begin(); + live = {"F10"}; + draft.Stage(0, "F7"); + Check(draft.Apply() == Outcome::Conflict && live == ShortcutList{"F10"}, + "change during capture cannot be overwritten"); + draft.Begin(); + draft.Stage(0, {}); + Check(draft.Apply() == Outcome::AppliedVerified && live.empty(), "explicit removal can unbind last key"); + draft.Begin(); + draft.Stage(0, "CTRL-I"); + Check(draft.Apply() == Outcome::AppliedVerified && live == ShortcutList{"CTRL-I"}, "unbound action can be rebound"); + const auto beforeDuplicates = writes; + draft.Begin(); + Check(draft.Stage(1, "CTRL-I") == ShortcutStage::AlreadyBound && !draft.pending(), + "append must reject an existing binding"); + Check(draft.Apply() == Outcome::Suppressed && writes == beforeDuplicates, + "duplicate append must not publish or save"); + draft.Begin(); + Check(draft.Stage(0, "CTRL-I") == ShortcutStage::AlreadyBound && !draft.pending(), + "re-recording the selected binding is a no-op"); + live = {"SHIFT-I", "ALT-I"}; + draft.Begin(); + Check(draft.Stage(1, "F8") == ShortcutStage::Staged && draft.pending(), "stage another alternative"); + Check(draft.Stage(1, "SHIFT-I") == ShortcutStage::AlreadyBound && !draft.pending(), + "duplicate replacement clears any preceding draft"); + Check(draft.Apply() == Outcome::Suppressed && writes == beforeDuplicates && live == ShortcutList{"SHIFT-I", "ALT-I"}, + "duplicate replacement preserves all alternatives without writing"); + live = {"SHIFT-I", "SHIFT-I", "ALT-I"}; + draft.Begin(); + Check(writes == beforeDuplicates && live.size() == 3, "opening does not clean up existing duplicates"); + Check(draft.Stage(0, {}) == ShortcutStage::Staged && draft.Apply() == Outcome::AppliedVerified + && live == ShortcutList{"SHIFT-I", "ALT-I"} && writes == beforeDuplicates + 1, + "explicit removal can clean up one existing duplicate without losing the shortcut"); + const auto beforeRestore = writes; + draft.Begin(); + Check(draft.Restore({"F8", "CTRL-G"}) == ShortcutStage::Staged && writes == beforeRestore, + "restoring defaults stages a full action without writing"); + draft.Cancel(); + Check(draft.Apply() == Outcome::Suppressed && writes == beforeRestore, + "cancelling default restoration preserves current bindings"); + draft.Begin(); + draft.Restore({"F8", "CTRL-G"}); + live = {"F10"}; + Check(draft.Apply() == Outcome::Conflict && live == ShortcutList{"F10"}, + "restoration cannot overwrite an action changed since staging"); + draft.Begin(); + Check(draft.Restore({}) == ShortcutStage::Staged && draft.Apply() == Outcome::AppliedVerified && live.empty(), + "a NONE default explicitly unbinds the action"); + draft.Begin(); + Check(draft.Restore({}) == ShortcutStage::AlreadyBound && !draft.pending(), + "restoring an already active default is a no-op"); + ShortcutUndo removal(owner); + live = {"LCTRL-G", "F8", "LCTRL-G"}; + const auto beforeRemoval = writes; + Check(removal.Remove(1) == Outcome::AppliedVerified && writes == beforeRemoval + 1 + && live == ShortcutList{"LCTRL-G", "LCTRL-G"} && removal.available() && removal.message() == "Removed F8", + "Remove writes immediately without a pending Apply and offers the removed key for Undo"); + Check(removal.Undo() == Outcome::AppliedVerified && writes == beforeRemoval + 2 + && live == ShortcutList{"LCTRL-G", "F8", "LCTRL-G"} && !removal.available(), + "Undo writes immediately and restores ordering, sided modifiers and existing duplicates exactly"); + Check(removal.Undo() == Outcome::Suppressed && writes == beforeRemoval + 2, + "repeated Undo cannot write again"); + live = {"F8"}; + Check(removal.Remove(0) == Outcome::AppliedVerified && live.empty() + && removal.Undo() == Outcome::AppliedVerified && live == ShortcutList{"F8"}, + "removing the last binding makes the action unbound and remains undoable"); + removal.Remove(0); + live = {"F9"}; + const auto beforeStaleUndo = writes; + Check(removal.Undo() == Outcome::Conflict && live == ShortcutList{"F9"} && writes == beforeStaleUndo + && !removal.available(), + "stale Undo cannot overwrite a newer binding or persist it"); + live = {"F7", "F8", "F9"}; + removal.Remove(0); + removal.Remove(0); + Check(removal.message() == "Removed F8" && removal.Undo() == Outcome::AppliedVerified + && live == ShortcutList{"F8", "F9"}, + "Undo belongs to the most recent removal only"); + Check(removal.Remove(99) == Outcome::Rejected && !removal.available(), + "invalid removal cannot offer Undo"); + removal.Remove(0); + removal.Clear(); + const auto afterClear = writes; + Check(removal.Undo() == Outcome::Suppressed && writes == afterClear, + "leaving the editor or starting another edit clears Undo without changing bindings"); + live = {"LEFT", "N"}; + const auto beforeDefault = writes; + Check(removal.Restore({"LEFT"}) == Outcome::AppliedVerified && live == ShortcutList{"LEFT"} + && writes == beforeDefault + 1 && removal.message() == "Restored defaults" && removal.available(), + "Restore replaces the complete list and persists immediately, without pending Apply"); + Check(removal.Undo() == Outcome::AppliedVerified && live == ShortcutList{"LEFT", "N"} + && writes == beforeDefault + 2, + "Undo default restoration immediately restores every previous alternative"); + Check(removal.Restore({}) == Outcome::AppliedVerified && live.empty() + && removal.Undo() == Outcome::AppliedVerified && live == ShortcutList{"LEFT", "N"}, + "NONE defaults apply immediately and can be undone"); + const auto beforeNoopRestore = writes; + Check(removal.Restore(live) == Outcome::Unchanged && writes == beforeNoopRestore && !removal.available(), + "restoring an already matching default neither writes nor advertises Undo"); + removal.Restore({"LEFT"}); + live = {"RIGHT"}; + const auto beforeRestoreConflict = writes; + Check(removal.Undo() == Outcome::Conflict && live == ShortcutList{"RIGHT"} && writes == beforeRestoreConflict, + "Undo restoration cannot overwrite a subsequent edit"); + { + ShortcutList oversized(limit + 2, "F8"); + ShortcutUndo* activeRemoval = nullptr; + bool reject = false; + ValueSetting boundedOwner({"shortcuts.large", "Large", + [&] { return ValueReadResult::Known(oversized, 1); }, + [&](ShortcutList value, std::uint64_t) { + if (reject || (!ShortcutCountFitsEdit(oversized.size(), value.size()) + && !(activeRemoval && activeRemoval->Restoring(value)))) + return ApplyResult::Rejected; + oversized = std::move(value); + return ApplyResult::Applied; + }}); + ShortcutUndo largeRemoval(boundedOwner); + activeRemoval = &largeRemoval; + Check(largeRemoval.Remove(0) == Outcome::AppliedVerified && oversized.size() == limit + 1 + && largeRemoval.Undo() == Outcome::AppliedVerified && oversized.size() == limit + 2, + "oversized TOML lists permit an exact removal undo"); + auto addition = oversized; + addition.push_back("F9"); + Check(boundedOwner.SetFromUser(addition, boundedOwner.Observe()).outcome == Outcome::Rejected, + "Undo does not grant ordinary additions permission to exceed the row budget"); + Check(largeRemoval.Restore({"LEFT"}) == Outcome::AppliedVerified + && largeRemoval.Undo() == Outcome::AppliedVerified && oversized.size() == limit + 2, + "Undo default restoration can recover the original oversized player list"); + reject = true; + Check(largeRemoval.Remove(0) == Outcome::Rejected && !largeRemoval.available() + && oversized.size() == limit + 2, + "failed removal cannot offer success or Undo"); + Check(largeRemoval.Restore({"LEFT"}) == Outcome::Rejected && !largeRemoval.available() + && oversized.size() == limit + 2, + "failed restoration cannot offer success or Undo"); + } + ShortcutCapture capture; + ShortcutPopupKeys popupKeys; + Check(popupKeys.Tick(false, true, true, false) == ShortcutPopupKey::None, + "Enter during recording cannot confirm"); + Check(popupKeys.Tick(true, true, true, false) == ShortcutPopupKey::None, + "captured Enter cannot confirm itself when the preview becomes ready"); + Check(popupKeys.Tick(true, false, false, false) == ShortcutPopupKey::None + && popupKeys.Tick(true, true, true, false) == ShortcutPopupKey::Confirm, + "after release a fresh Enter confirms a ready preview"); + Check(popupKeys.Tick(true, true, false, false) == ShortcutPopupKey::None, + "holding Enter cannot repeat confirmation"); + popupKeys.Tick(true, false, false, false); + Check(popupKeys.Tick(true, true, true, true) == ShortcutPopupKey::Cancel, + "Escape takes priority over simultaneous confirmation"); + Check(popupKeys.Tick(false, false, false, true) == ShortcutPopupKey::Cancel, + "Escape cancels even when confirmation is unavailable"); + popupKeys.Tick(true, false, false, false); + popupKeys.Tick(false, false, false, false); + Check(popupKeys.Tick(true, true, true, false) == ShortcutPopupKey::None, + "Record again resets confirmation readiness"); + ShortcutCapture::Keys held{}, down{}; + auto modifier = [](KeyCode key) { return key == KeyCode::LeftControl; }; + auto tick = [&] { return capture.Tick(held, down, true, modifier); }; + capture.Begin(); + held[(int)KeyCode::Mouse0] = true; + down[(int)KeyCode::Mouse0] = true; + Check(!tick() && !capture.listening(), "opening click cannot be captured"); + held = {}; + down = {}; + tick(); + Check(capture.listening(), "all opening keys must release"); + held[(int)KeyCode::LeftControl] = true; + down[(int)KeyCode::LeftControl] = true; + Check(!tick() && capture.listening(), "modifier alone is not a binding"); + down = {}; + held[(int)KeyCode::I] = true; + down[(int)KeyCode::I] = true; + Check(tick() == KeyCode::I && capture.active(), "record primary but retain input ownership"); + down = {}; + tick(); + Check(capture.active(), "held captured chord cannot leak to gameplay"); + held[(int)KeyCode::I] = false; + tick(); + Check(capture.active(), "modifier release also required"); + held = {}; + tick(); + Check(!capture.active(), "release ends ownership"); + capture.Begin(); + tick(); + held[(int)KeyCode::Escape] = true; + down[(int)KeyCode::Escape] = true; + Check(!tick() && capture.active(), "escape cancels instead of binding"); + down = {}; + held = {}; + tick(); + Check(!capture.active(), "escape release ends cancellation drain"); + capture.Begin(); + tick(); + held[(int)KeyCode::I] = true; + down[(int)KeyCode::I] = true; + Check(!capture.Tick(held, down, false, modifier) && capture.active(), "focus loss cancels and drains"); + held = {}; + down = {}; + capture.Tick(held, down, false, modifier); + Check(capture.active(), "an empty unfocused input sample must not end ownership"); + held[(int)KeyCode::I] = true; + tick(); + Check(capture.active(), "refocusing with the chord still held remains blocked"); + held = {}; + tick(); + Check(!capture.active(), "a focused release ends the cancellation drain"); + // The popup can close while capture is idle (preview/confirmation). Its + // closing input must be drained just like a captured chord. + capture.Begin(); + capture.Cancel(); + held[(int)KeyCode::Return] = true; + down = held; + Check(!tick() && capture.active(), "closing preview retains the submit key"); + held = {}; + down = {}; + Check(!capture.Tick(held, down, false, modifier) && capture.active(), + "closing preview cannot release ownership on an unfocused sample"); + tick(); + Check(!capture.active(), "closing preview releases ownership after focused key release"); + capture.Begin(); + tick(); + held[(int)KeyCode::I] = held[(int)KeyCode::G] = true; + down = held; + Check(!tick() && !capture.listening(), "two simultaneous primary keys are ambiguous"); + std::cout << "Shortcut draft and capture fixtures passed\n"; +} diff --git a/tests/shortcut_hint_cache.cc b/tests/shortcut_hint_cache.cc index fc23adb80..7a63dc2e9 100644 --- a/tests/shortcut_hint_cache.cc +++ b/tests/shortcut_hint_cache.cc @@ -1,36 +1,95 @@ -// Link against the built mods library. Only Unity-dependent Key operations are stubbed; -// MapKey parsing, ModifierKey parsing, binding detection, and hint caching are production code. +// Link production MapKey/ModifierKey parsing, action dispatch and hint caching from mods.lib. +// Key token parsing and input are test fixtures; layout lookup is injected below. +// These tests do not exercise Unity lookup, native notifications or legacy input caching. +#include "patches/keyboard_layout.h" +#include "patches/keyboard_layout_mapping.h" #include "patches/mapkey.h" +#include "settings/shortcut_draft.h" +#include "settings/shortcut_capture.h" #include "defaultconfig.h" -#include +#include #include #include +#include -static std::array(KeyCode::Max)> held{}; +static std::array(KeyCode::Max)> pressed{}; +static std::array(KeyCode::Max)> down{}; +static keyboard_layout::BindingState layout_bindings; +static bool layout_enabled = false; +static std::array chords; static KeyCode claimed = KeyCode::None; KeyCode Key::Parse(std::string_view key) { - if (key == "F7") return KeyCode::F7; - if (key == "F8") return KeyCode::F8; - if (key == "G") return KeyCode::G; - for (const auto [token, code] : {std::pair{"W", KeyCode::W}, {"A", KeyCode::A}, {"S", KeyCode::S}, - {"D", KeyCode::D}, {"UP", KeyCode::UpArrow}, {"DOWN", KeyCode::DownArrow}, - {"LEFT", KeyCode::LeftArrow}, {"RIGHT", KeyCode::RightArrow}}) - if (key == token) return code; + static constexpr std::pair tokens[] = { + {"=", KeyCode::Equals}, + {"Z", KeyCode::Z}, + {"LSHIFT", KeyCode::LeftShift}, + {"F7", KeyCode::F7}, + {"F8", KeyCode::F8}, + {"G", KeyCode::G}, + {"+", KeyCode::Plus}, + {"/", KeyCode::Slash}, + {"(", KeyCode::LeftParen}, + {"1", KeyCode::Alpha1}, + {"'", KeyCode::Quote}, + {"^", KeyCode::Caret}, + {"I", KeyCode::I}, + {"7", KeyCode::Alpha7}, + {"RSHIFT", KeyCode::RightShift}, + {"LCTRL", KeyCode::LeftControl}, + {"RCTRL", KeyCode::RightControl}, + {"EQUAL", KeyCode::Equals}, + {"LCOM", KeyCode::LeftCommand}, + {"RCOM", KeyCode::RightCommand}, + {"W", KeyCode::W}, + {"A", KeyCode::A}, + {"S", KeyCode::S}, + {"D", KeyCode::D}, + {"UP", KeyCode::UpArrow}, + {"DOWN", KeyCode::DownArrow}, + {"LEFT", KeyCode::LeftArrow}, + {"RIGHT", KeyCode::RightArrow}, + }; + for (const auto& [token, code] : tokens) { + if (key == token) + return code; + } return KeyCode::None; } -bool Key::IsModifier(KeyCode) { return false; } -bool Key::Pressed(KeyCode key) { return held[static_cast(key)]; } -bool Key::Down(KeyCode key) { return Key::Pressed(key); } +bool Key::IsModifier(KeyCode key) +{ + return key == KeyCode::LeftShift || key == KeyCode::RightShift || key == KeyCode::LeftControl + || key == KeyCode::RightControl || key == KeyCode::LeftCommand || key == KeyCode::RightCommand + || key == KeyCode::LeftWindows || key == KeyCode::RightWindows; +} +bool Key::Pressed(KeyCode key) { return pressed[static_cast(key)]; } +bool Key::Down(KeyCode key) { return down[static_cast(key)]; } bool Key::IsModified() { - for (auto key : {KeyCode::LeftControl, KeyCode::RightControl, KeyCode::LeftShift, KeyCode::LeftAlt, KeyCode::LeftCommand}) + for (auto key : {KeyCode::LeftShift, KeyCode::RightShift, KeyCode::LeftControl, KeyCode::RightControl, + KeyCode::LeftAlt, KeyCode::RightAlt, KeyCode::AltGr, KeyCode::LeftCommand, + KeyCode::RightCommand, KeyCode::LeftWindows, KeyCode::RightWindows}) if (Key::Pressed(key)) return true; return false; } void Key::ClaimDirectionalInput(KeyCode key) { claimed = key; } +namespace keyboard_layout +{ +void RegisterShortcut(KeyCode) {} // Runtime registration boundary, no Unity in this fixture. +KeyCode Resolve(KeyCode configured) { return layout_enabled ? layout_bindings.Resolve(configured, [] { return 1; }, Key::Pressed) : configured; } +ResolvedChord ResolveChord(KeyCode configured) { + return {Resolve(configured), layout_enabled && IsLayoutKey(configured) + && (chords[static_cast(configured)].shift) != 0}; +} +ResolvedChord DescribeChord(KeyCode configured) +{ + return layout_enabled && IsLayoutKey(configured) ? chords[static_cast(configured)] + : ResolvedChord{configured, false}; +} +} // namespace keyboard_layout + void Check(bool condition, const char* message) { if (!condition) { @@ -39,8 +98,117 @@ void Check(bool condition, const char* message) } } +// Compare the editor prediction against production modifier dispatch over every +// held modifier combination, including either side and layout-required Shift. +void CheckOverlap(const char* first, const char* second, bool expected) +{ + const auto a = MapKey::Parse(first), b = MapKey::Parse(second); + const auto ca = keyboard_layout::DescribeChord(a.Key), cb = keyboard_layout::DescribeChord(b.Key); + bool reachable = false; + constexpr auto modifiers = + std::to_array({KeyCode::LeftShift, KeyCode::RightShift, KeyCode::LeftControl, KeyCode::RightControl, + KeyCode::LeftAlt, KeyCode::RightAlt, KeyCode::AltGr, KeyCode::LeftCommand, + KeyCode::RightCommand, KeyCode::LeftWindows, KeyCode::RightWindows}); + for (unsigned mask = 0; mask < (1u << modifiers.size()); ++mask) { + pressed.fill(false); + for (std::size_t i = 0; i < modifiers.size(); ++i) + pressed[static_cast(modifiers[i])] = (mask & (1u << i)) != 0; + reachable |= ca.key != KeyCode::None && ca.key == cb.key && MapKey::HasCorrectModifiers(a, ca.shift) + && MapKey::HasCorrectModifiers(b, cb.shift); + } + pressed.fill(false); + if (reachable != expected || MapKey::MayOverlap(a, b) != reachable || MapKey::MayOverlap(b, a) != reachable) { + std::cerr << "Overlap disagrees with dispatch: " << first << " / " << second << '\n'; + std::exit(1); + } +} + +void CheckDuplicate(const char* existing, const char* recorded, bool duplicate) +{ + using namespace mod_settings; + ShortcutList live{existing}; + int writes = 0; + ValueSetting owner({"shortcuts.duplicate_test", "Test", + [&] { return ValueReadResult::Known(live, 1); }, + [&](ShortcutList value, std::uint64_t) { + live = std::move(value); + ++writes; + return ApplyResult::Applied; + }}); + ShortcutDraft draft( + owner, [](const auto& a, const auto& b) { return MapKey::SameBinding(MapKey::Parse(a), MapKey::Parse(b)); }); + draft.Begin(); + const auto result = draft.Stage(1, recorded); + Check((result == ShortcutStage::AlreadyBound) == duplicate, "Wrong same-action duplicate decision"); + Check(writes == 0 && live == ShortcutList{existing}, "Recording changed live bindings"); + Check(draft.Apply() == (duplicate ? Outcome::Suppressed : Outcome::AppliedVerified), "Wrong duplicate apply result"); + Check(writes == (duplicate ? 0 : 1) && live.size() == (duplicate ? 1 : 2), "Duplicate escaped to writer"); +} + +// Exercise production capture serialization, draft application, parser and +// dispatch with injected physical input. Reload is modeled by reparsing saved +// text, not by a filesystem or a running Unity client. +void CheckCapturedCommand() +{ + using namespace mod_settings; + constexpr auto action = GameFunction::ShowGalaxy; + for (auto command : {KeyCode::LeftCommand, KeyCode::RightCommand}) { + for (bool dualReported : {false, true}) { + pressed.fill(false); + down.fill(false); + ShortcutCapture capture; + capture.Begin(); + capture.Tick(pressed, down, true, Key::IsModifier); + pressed[static_cast(command)] = down[static_cast(command)] = true; + pressed[static_cast(KeyCode::LeftWindows)] = dualReported; + pressed[static_cast(KeyCode::G)] = down[static_cast(KeyCode::G)] = true; + Check(capture.Tick(pressed, down, true, Key::IsModifier) == KeyCode::G, + "Command must be a modifier, not an ambiguous primary key"); + const auto token = CaptureModifierPrefix(Key::Pressed) + "G"; + Check(token == "CMD-G", "Command capture lost its native modifier identity"); + ShortcutList saved; + ValueSetting owner({"shortcuts.command_test", "Test", + [&] { return ValueReadResult::Known(saved, 1); }, + [&](ShortcutList value, std::uint64_t) { + if (!MapKey::ReplaceBindings(action, {MapKey::Parse(value.at(0))})) + return ApplyResult::Rejected; + saved = std::move(value); + return ApplyResult::Applied; + }}); + ShortcutDraft draft(owner); + draft.Begin(); + Check(draft.Stage(0, token) == ShortcutStage::Staged && draft.Apply() == Outcome::AppliedVerified, + "Recorded Command binding must apply"); + pressed[static_cast(KeyCode::LeftWindows)] = false; + Check(MapKey::IsDown(action) && MapKey::IsPressed(action), "Recorded Command-only input must dispatch"); + Check(MapKey::ReplaceBindings(action, {}), "Clear before simulated config reload"); + Check(MapKey::ReplaceBindings(action, {MapKey::Parse(saved.at(0))}) && MapKey::IsDown(action), + "Saved Command binding must dispatch after reparsing"); + CheckDuplicate("CMD-G", token.c_str(), true); + CheckDuplicate("APPLE-G", token.c_str(), true); + CheckDuplicate("LCOM-G", token.c_str(), false); + CheckDuplicate("RCOM-G", token.c_str(), false); + CheckDuplicate("WIN-G", token.c_str(), false); + pressed[static_cast(command)] = false; + pressed[static_cast(KeyCode::LeftWindows)] = true; + Check(!MapKey::IsDown(action), "Command binding must not silently become a Windows binding"); + } + } + for (auto windows : {KeyCode::LeftWindows, KeyCode::RightWindows}) { + pressed.fill(false); + pressed[static_cast(windows)] = true; + const auto token = CaptureModifierPrefix(Key::Pressed) + "G"; + Check(token == "WIN-G" && MapKey::HasCorrectModifiers(MapKey::Parse(token)), + "Windows-only recording must retain Windows dispatch"); + } + pressed.fill(false); + down.fill(false); + Check(MapKey::ReplaceBindings(action, {}), "Clean fixture bindings"); +} + int main() { + constexpr auto toggle = GameFunction::ToggleShortcutHints; constexpr auto galaxy = GameFunction::ShowGalaxy; Check(!MapKey::HasBinding(toggle), "Absent binding must not enable hints"); @@ -69,8 +237,162 @@ int main() Check(MapKey::GetShortcutHint(GameFunction::ShowResearch).empty(), "Unbound action acquired a badge"); MapKey::CacheShortcutHints(); Check(MapKey::GetShortcutHint(galaxy) == "^G", "Repeated cache preparation changed label"); - // Pan uses the production shortcut matcher: both default alternatives work, - // but adding a modifier must not also match the unmodified movement action. + + MapKey::RegisterAction(galaxy, "show_galaxy", "CTRL-G | F8"); + Check(MapKey::Definition(galaxy).key == "show_galaxy", "Canonical storage key missing"); + Check(MapKey::ReplaceBindings(galaxy, {MapKey::Parse("F7"), MapKey::Parse("F8")}), "Replace action failed"); + Check(MapKey::GetShortcuts(galaxy) == "F7 | F8" && MapKey::GetShortcutHint(galaxy) == "F7", + "Replacement did not publish full list and fresh hint together"); + Check(!MapKey::ReplaceBindings(galaxy, {MapKey::Parse("INVALID")}), "Invalid replacement was accepted"); + Check(MapKey::GetShortcuts(galaxy) == "F7 | F8", "Invalid replacement lost prior bindings"); + Check(MapKey::ReplaceBindings(galaxy, {}) && !MapKey::HasBinding(galaxy) && MapKey::GetShortcutHint(galaxy).empty(), + "Unbind left active input or a stale hint"); + Check(MapKey::ReplaceBindings(galaxy, {MapKey::Parse("CTRL-G"), MapKey::Parse("F8")}), "Restore failed"); + + // Supply a mapping fixture to test action dispatch through BindingState. + // This does not assert that Unity returns these mappings on any real layout. + keyboard_layout::LayoutKeys keys{}; + keys[static_cast(KeyCode::Plus)] = KeyCode::RightBracket; + keys[static_cast(KeyCode::Slash)] = KeyCode::Alpha7; + keys[static_cast(KeyCode::Alpha1)] = KeyCode::Alpha1; + layout_bindings.Replace(keys, Key::Pressed, 0); + layout_enabled = true; + MapKey::AddMappedKey(GameFunction::ShowDaily, MapKey::Parse("+")); + MapKey::AddMappedKey(GameFunction::ShowScrapYard, MapKey::Parse("SHIFT-/")); + MapKey::AddMappedKey(GameFunction::ShowResearch, MapKey::Parse("(")); + MapKey::AddMappedKey(GameFunction::ShowInventory, MapKey::Parse("1")); + pressed[static_cast(KeyCode::RightBracket)] = true; + down[static_cast(KeyCode::RightBracket)] = true; + Check(MapKey::IsDown(GameFunction::ShowDaily) && MapKey::IsPressed(GameFunction::ShowDaily), + "Punctuation action did not use resolved physical key"); + down[static_cast(KeyCode::RightBracket)] = false; + Check(!MapKey::IsDown(GameFunction::ShowDaily) && MapKey::IsPressed(GameFunction::ShowDaily), + "Held action was mistaken for a new key-down edge"); + down[static_cast(KeyCode::RightBracket)] = true; + pressed[static_cast(KeyCode::LeftShift)] = true; + Check(!MapKey::IsDown(GameFunction::ShowDaily), "Unmodified symbol unexpectedly accepts Shift"); + pressed[static_cast(KeyCode::Alpha7)] = true; + down[static_cast(KeyCode::Alpha7)] = true; + Check(MapKey::IsDown(GameFunction::ShowScrapYard), "Explicit Shift chord did not use resolved symbol key"); + pressed[static_cast(KeyCode::LeftShift)] = false; + Check(!MapKey::IsDown(GameFunction::ShowScrapYard), "Explicit modifier requirement was lost"); + pressed[static_cast(KeyCode::LeftParen)] = true; + down[static_cast(KeyCode::LeftParen)] = true; + Check(!MapKey::IsDown(GameFunction::ShowResearch), "Unresolved symbol silently fell back to physical"); + pressed[static_cast(KeyCode::Alpha1)] = true; + down[static_cast(KeyCode::Alpha1)] = true; + Check(MapKey::IsDown(GameFunction::ShowInventory), "Digit action did not use resolved mapping"); + Check(MapKey::GetShortcuts(GameFunction::ShowScrapYard) == "SHIFT-/", "Resolution rewrote configured chord"); + + // German slash needs Shift on either side; plain 7 must not fire slash. + auto& slash = chords[static_cast(KeyCode::Slash)]; + slash.shift = true; + constexpr auto slashAction = GameFunction::ShowAlliance; + MapKey::AddMappedKey(slashAction, MapKey::Parse("/")); + MapKey::CacheShortcutHints(); + pressed.fill(false); + down.fill(false); + pressed[static_cast(KeyCode::Alpha7)] = down[static_cast(KeyCode::Alpha7)] = true; + Check(!MapKey::IsDown(slashAction), "Bare 7 fired German slash"); + for (const auto shift : {KeyCode::LeftShift, KeyCode::RightShift}) { + pressed[static_cast(shift)] = true; + Check(MapKey::IsDown(slashAction) && MapKey::IsPressed(slashAction), "Inferred Shift chord did not dispatch"); + for (const auto extra : {KeyCode::LeftControl, KeyCode::RightAlt, KeyCode::AltGr, KeyCode::LeftWindows}) { + pressed[static_cast(extra)] = true; + Check(!MapKey::IsDown(slashAction), "Bare slash stole modified chord"); + pressed[static_cast(extra)] = false; + } + pressed[static_cast(shift)] = false; + } + Check(MapKey::GetShortcutHint(slashAction) == "/", "Hint replaced configured slash with physical recipe"); + Check(MapKey::GetShortcutHint(GameFunction::ShowScrapYard) == "+/", "Hint changed explicit configured Shift"); + Check(MapKey::GetShortcuts(slashAction) == "/", "Display rewrote configuration"); + const auto explicitCtrl = MapKey::Parse("CTRL-/"); + pressed[static_cast(KeyCode::LeftControl)] = true; + Check(!MapKey::HasCorrectModifiers(explicitCtrl, true), "Explicit Ctrl bypassed inferred Shift"); + pressed[static_cast(KeyCode::RightShift)] = true; + Check(MapKey::HasCorrectModifiers(explicitCtrl, true), "Explicit Ctrl was not combined with inferred Shift"); + const auto explicitSide = MapKey::Parse("LSHIFT-/"); + Check(!MapKey::HasCorrectModifiers(explicitSide, true), "Inferred Shift bypassed explicit left side"); + pressed[static_cast(KeyCode::LeftShift)] = true; + Check(MapKey::HasCorrectModifiers(explicitSide, true), "Explicit left Shift was not accepted"); + Check(MapKey::GetShortcutHint(slashAction) == "/", "Resolution status rewrote configured hint"); + // Simulate the next US generation; no stale German recipe may remain cached. + slash.shift = false; + Check(MapKey::GetShortcutHint(slashAction) == "/", "Hint retained previous layout's Shift"); + + // The upstream German defaults overlap under minimum modifier matching. + // Preserve that policy; verify the documented Help remap makes Armada distinct. + pressed.fill(false); + down.fill(false); + keys[static_cast(KeyCode::Quote)] = KeyCode::Backslash; + keys[static_cast(KeyCode::Caret)] = KeyCode::BackQuote; + chords[static_cast(KeyCode::Quote)].shift = true; + layout_bindings.Replace(keys, Key::Pressed, 0); + MapKey::AddMappedKey(GameFunction::ShowAllianceHelp, MapKey::Parse("SHIFT-'")); + MapKey::AddMappedKey(GameFunction::ShowAllianceArmada, MapKey::Parse("CTRL-'")); + pressed[static_cast(KeyCode::LeftControl)] = true; + pressed[static_cast(KeyCode::LeftShift)] = true; + pressed[static_cast(KeyCode::Backslash)] = down[static_cast(KeyCode::Backslash)] = true; + Check(MapKey::IsDown(GameFunction::ShowAllianceHelp) && MapKey::IsDown(GameFunction::ShowAllianceArmada), + "German default overlap changed without an explicit modifier policy change"); + // Keep both configurations available to compare dispatch in the same sample. + constexpr auto remappedHelp = GameFunction::ShowOfficers; + MapKey::AddMappedKey(remappedHelp, MapKey::Parse("SHIFT-^")); + Check(!MapKey::IsDown(remappedHelp) && MapKey::IsDown(GameFunction::ShowAllianceArmada), + "Documented Help remap still captures Armada"); + pressed[static_cast(KeyCode::LeftControl)] = false; + pressed[static_cast(KeyCode::Backslash)] = down[static_cast(KeyCode::Backslash)] = false; + pressed[static_cast(KeyCode::BackQuote)] = down[static_cast(KeyCode::BackQuote)] = true; + Check(MapKey::IsDown(remappedHelp) && !MapKey::IsDown(GameFunction::ShowAllianceArmada), + "Documented Shift-caret Help chord failed"); + layout_enabled = false; + pressed.fill(false); + down.fill(false); + pressed[static_cast(KeyCode::Plus)] = true; + down[static_cast(KeyCode::Plus)] = true; + Check(MapKey::IsDown(GameFunction::ShowDaily), "Physical mode no longer uses configured key"); + + CheckOverlap("I", "SHIFT-I", false); // Inventory must not appear for Shift-I. + CheckOverlap("I", "I", true); + CheckOverlap("SHIFT-I", "SHIFT-I", true); // Artifacts must still appear. + CheckOverlap("SHIFT-I", "CTRL-I", true); // Both can match Ctrl+Shift+I. + CheckOverlap("LSHIFT-I", "RSHIFT-I", true); // Both sides can be held. + CheckOverlap("LCTRL-I", "CTRL-SHIFT-I", true); + CheckOverlap("SHIFT-I", "SHIFT-G", false); + CheckOverlap("NONE", "NONE", false); + layout_enabled = true; + chords[static_cast(KeyCode::Slash)] = {KeyCode::Alpha7, true}; + chords[static_cast(KeyCode::Alpha7)] = {KeyCode::Alpha7, false}; + CheckOverlap("/", "7", false); + CheckOverlap("/", "SHIFT-7", true); + CheckOverlap("/", "RSHIFT-7", true); + CheckOverlap("/", "CTRL-SHIFT-7", false); + CheckOverlap("/", "CTRL-/", false); + CheckOverlap("/", "/", true); + CheckOverlap("(", "SHIFT-7", false); // Unresolved layout character. + layout_enabled = false; + CheckDuplicate("SHIFT-I", "SHIFT-I", true); + CheckDuplicate("shift-i", "SHIFT-I", true); + CheckDuplicate("SHIFT-CTRL-I", "CTRL-SHIFT-I", true); + CheckDuplicate("SHIFT-SHIFT-I", "SHIFT-I", true); + CheckDuplicate("CMD-I", "APPLE-I", true); + CheckDuplicate("EQUAL", "=", true); + CheckDuplicate("LSHIFT-I", "SHIFT-I", false); + CheckDuplicate("LSHIFT-I", "RSHIFT-I", false); + CheckDuplicate("SHIFT-I", "CTRL-I", false); // An overlap is not a duplicate. + CheckDuplicate("SHIFT-I", "SHIFT-G", false); + CheckDuplicate("I", "SHIFT-I", false); + // Current layout equivalence must not collapse two configured identities. + layout_enabled = true; + CheckDuplicate("/", "SHIFT-7", false); + layout_enabled = false; + CheckCapturedCommand(); + + // Movement uses the production matcher. Physical defaults stay active, and + // extra modifiers must not turn an unmodified movement chord into a match. + pressed.fill(false); + down.fill(false); for (const auto [action, defaults] : { std::pair{GameFunction::MoveUp, DefaultConfig::Shortcuts::move_up}, std::pair{GameFunction::MoveDown, DefaultConfig::Shortcuts::move_down}, @@ -78,36 +400,43 @@ int main() std::pair{GameFunction::MoveRight, DefaultConfig::Shortcuts::move_right}}) { std::string_view remaining = defaults; while (!remaining.empty()) { - auto end = remaining.find('|'); - auto binding = MapKey::Parse(std::string(remaining.substr(0, end))); + const auto end = remaining.find('|'); + const auto binding = MapKey::Parse(std::string(remaining.substr(0, end))); Check(binding.Key != KeyCode::None, "Invalid movement default"); MapKey::AddMappedKey(action, binding); - held[static_cast(binding.Key)] = true; - Check(MapKey::IsPressed(action), "Default movement key did not match"); + pressed[static_cast(binding.Key)] = true; + down[static_cast(binding.Key)] = true; + Check(MapKey::IsDown(action) && MapKey::IsPressed(action), "Default movement key did not match"); for (auto modifier : {KeyCode::LeftControl, KeyCode::RightControl, KeyCode::LeftShift, KeyCode::LeftAlt, KeyCode::LeftCommand}) { - held[static_cast(modifier)] = true; - Check(!MapKey::IsPressed(action), "Modified chord leaked into plain movement"); - held[static_cast(modifier)] = false; + pressed[static_cast(modifier)] = true; + down[static_cast(modifier)] = true; + Check(!MapKey::IsDown(action) && !MapKey::IsPressed(action), "Modified chord leaked into plain movement"); + pressed[static_cast(modifier)] = false; + down[static_cast(modifier)] = false; } - held[static_cast(binding.Key)] = false; - Check(!MapKey::IsPressed(action), "Released movement key remained active"); - if (end == std::string_view::npos) break; + pressed[static_cast(binding.Key)] = false; + down[static_cast(binding.Key)] = false; + Check(!MapKey::IsDown(action) && !MapKey::IsPressed(action), "Released movement key remained active"); + if (end == std::string_view::npos) + break; remaining.remove_prefix(end + 1); } } - // A modified movement binding must not claim its own direction. Non-movement - // actions still claim it, preserving the existing hold-until-release guard. + + // A modified movement binding must not claim its own direction. Other + // actions still claim the same chord through its resolved physical key. MapKey::AddMappedKey(GameFunction::MoveDown, MapKey::Parse("CTRL-DOWN")); - held[static_cast(KeyCode::LeftControl)] = true; - held[static_cast(KeyCode::DownArrow)] = true; + pressed[static_cast(KeyCode::LeftControl)] = true; + down[static_cast(KeyCode::LeftControl)] = true; + pressed[static_cast(KeyCode::DownArrow)] = true; + down[static_cast(KeyCode::DownArrow)] = true; claimed = KeyCode::None; Check(MapKey::IsDown(GameFunction::MoveDown) && MapKey::IsPressed(GameFunction::MoveDown), "Explicit movement chord did not match"); Check(claimed == KeyCode::None, "Movement claimed and blocked itself"); MapKey::AddMappedKey(toggle, MapKey::Parse("CTRL-DOWN")); - Check(MapKey::IsDown(toggle) && claimed == KeyCode::DownArrow, "Other chord lost directional ownership"); - held.fill(false); - std::cout << "Shortcut hint and movement binding tests passed\n"; - + Check(MapKey::IsDown(toggle) && MapKey::IsPressed(toggle) && claimed == KeyCode::DownArrow, + "Other chord lost directional ownership"); + std::cout << "Shortcut hint, editor and movement binding tests passed\n"; } diff --git a/tests/slider_setting_test.cc b/tests/slider_setting_test.cc new file mode 100644 index 000000000..284913846 --- /dev/null +++ b/tests/slider_setting_test.cc @@ -0,0 +1,115 @@ +#include "settings/native_boolean_callback.h" +#include "settings/native_view_state.h" +#include "settings/page_catalog.h" +#include +#include +#include + +using namespace mod_settings; +float stored = 0; +float GetFloat(Il2CppObject*, const MethodInfo*) +{ return stored; } +void SetFloat(Il2CppObject*, float value, const MethodInfo*) +{ stored = value; } +int main() +{ + float value = 0.33333f; + bool enabled = true, available = true; + int writes = 0; + SliderSetting setting({"threshold", "Threshold", + [&] { return available ? ValueReadResult::Known(value, 1) : ValueReadResult{}; }, + [&](float desired, std::uint64_t) { + ++writes; + value = desired; + return ApplyResult::Applied; + }}, + 0, 1, 0.01f, [&] { return enabled; }, SliderLabel::Percentage, 2, "Enable test feature"); + NativeViewState view(setting), stale(setting); + view.Bind(); + stale.Bind(); + assert(view.known() && view.number() == 0.33333f); // Reading does not quantize player-authored values. + assert(view.displayNumber() == 0.33f && value == 0.33333f && writes == 0); + assert(view.Request(0.604f) == Outcome::AppliedVerified && std::abs(value - 0.6f) < 0.000001f); + assert(stale.Request(0.8f) == Outcome::Conflict && writes == 1); + assert(view.Request(std::numeric_limits::quiet_NaN()) == Outcome::Rejected); + assert(view.Request(std::numeric_limits::infinity()) == Outcome::Rejected); + assert(view.Request(-0.01f) == Outcome::Rejected && view.Request(1.01f) == Outcome::Rejected); + enabled = false; + assert(view.known() && !view.enabled() && view.Request(0.9f) == Outcome::Rejected && writes == 1); + assert(view.disabledReason() == "Enable test feature"); + enabled = true; + { + NativeViewState::RenderScope render(view); + assert(view.Request(0.9f) == Outcome::Suppressed && writes == 1); + } + assert(view.Request(1.0f) == Outcome::AppliedVerified && value == 1.0f); + assert(view.Request(0.0f) == Outcome::AppliedVerified && value == 0.0f); + available = false; + view.Bind(); + assert(!view.known() && !view.enabled() && view.Request(0.8f) == Outcome::Rejected); + available = true; + value = std::numeric_limits::quiet_NaN(); + view.Bind(); + assert(!view.known()); + assert(view.unavailableReason() == "Invalid value; edit TOML"); + value = 0.5f; + view.Bind(); + view.Unbind(); + assert(view.Request(0.7f) == Outcome::Rejected); + PageCatalog catalog("root", "Mod Settings"); + assert(catalog.AddPage("labels", "Fleet Labels", "root") == Registration::Added); + assert(catalog.AddSlider("labels", setting) == Registration::Added); + assert(catalog.AddSlider("labels", setting) == Registration::Duplicate); + assert(catalog.Build().size() == 2); + assert(catalog.AddSlider("labels", setting) == Registration::Frozen); + + // Native drag labels can arrive after a change has already snapped/read back. + // Display that snapshot, and keep display precision independent of the step. + value = 382.1429f; + SliderSetting speed( + {"speed", "Speed", [&] { return ValueReadResult::Known(value, 1); }, + [&](float desired, std::uint64_t) { + value = desired; + return ApplyResult::Applied; + }}, + 0, 1000, 25, [] { return true; }, SliderLabel::Value, 0); + NativeViewState speedView(speed); + speedView.Bind(); + assert(speedView.disabledReason().empty()); // Generic sliders have no feature-specific instruction. + value = 2000.0f; + speedView.Bind(); + assert(!speedView.known() && speedView.unavailableReason() == "Out of range; edit TOML"); + speedView.Bind(); // Reopening cannot repair a value outside this editor's range. + assert(value == 2000.0f && speedView.unavailableReason() == "Out of range; edit TOML"); + value = 382.1429f; + speedView.Bind(); + assert(speedView.displayNumber() == 382 && value == 382.1429f); + assert(speedView.Request(382.1429f) == Outcome::AppliedVerified); + assert(speedView.number() == 375 && speedView.displayNumber() == 375); + assert(setting.DisplayValue(0.72575f) == 0.73f); + assert(setting.DisplayValue(0.0f) == 0 && setting.DisplayValue(0.99f) == 0.99f); + + Il2CppType single{}, nothing{}, integer{}; + single.type = IL2CPP_TYPE_R4; + nothing.type = IL2CPP_TYPE_VOID; + integer.type = IL2CPP_TYPE_I4; + const Il2CppType* parameters[]{&single}; + MethodInfo getter{}, setter{}; + getter.return_type = &single; + setter.return_type = ¬hing; + setter.parameters = parameters; + setter.parameters_count = 1; + NativeCallback get; + NativeCallback set; + assert(get.Initialize(&getter, GetFloat) && set.Initialize(&setter, SetFloat)); + float input = 0.67f, output = 0; + void* args[]{&input}; + set.method()->invoker_method(nullptr, set.method(), nullptr, args, nullptr); + get.method()->invoker_method(nullptr, get.method(), nullptr, nullptr, &output); + assert(output == input); + NativeCallback wrong; + getter.return_type = &integer; + assert(!wrong.Initialize(&getter, GetFloat)); + std::cout + << "PASS slider: range, finite values, dependency, readback, stale views, render guard and Single callbacks\n"; +} diff --git a/tests/toml_editor_test.cc b/tests/toml_editor_test.cc new file mode 100644 index 000000000..14a6f22ce --- /dev/null +++ b/tests/toml_editor_test.cc @@ -0,0 +1,116 @@ +#include "config_save.h" +#include "toml_editor.h" +#include +#include +#include +#include + +using namespace config_edit; +static Request Mode(std::optional expected, std::string desired) +{ return {"ui", "auto_confirm_instant_warp", std::move(expected), std::move(desired)}; } +int main(int argc, char** argv) +{ + assert(argc == 2); + TomlEditor editor; + const auto request = Mode(Value{std::string("none")}, "warp"); + for (const std::string original : + {"# header\n[ui] # section\nauto_confirm_instant_warp = 'none' # comment\nother = 9\n", + "\xef\xbb\xbf# BOM\r\n[ui]\r\nauto_confirm_instant_warp = 'none' # comment\r\n", + "ui = { other = '\xc3\xa9', auto_confirm_instant_warp = 'none' } # tail\n", + "ui.auto_confirm_instant_warp = '''none'''\n[elsewhere]\nx = nan\n", + "[\"ui\"]\n\"auto_confirm_instant_warp\" = \"\"\"none\"\"\""}) { + auto edit = editor.Prepare(original, request); + assert(edit.outcome == Outcome::Prepared); + const auto parsed = toml::parse(edit.text); + assert(parsed["ui"]["auto_confirm_instant_warp"].value() == "warp"); + // Only the value token changes; comments, surrounding bytes and line endings remain. + const auto old = original.find("'''none'''") != std::string::npos ? "'''none'''" + : original.find("\"\"\"none\"\"\"") != std::string::npos ? "\"\"\"none\"\"\"" + : "'none'"; + auto expected = original; + expected.replace(expected.find(old), std::string(old).size(), "\"warp\""); + assert(edit.text == expected); + } + for (const std::string original : + {"", "# comments only", "[ui]", "[ui]\nother = 4\n[other]\nx=3\n", "ui = {} # inline\n", "ui = {other = 4}\n", + "ui.other = 4\n", "[ui.child]\nx = 4\n"}) { + const auto edit = editor.Prepare(original, Mode(std::nullopt, "jump")); + assert(edit.outcome == Outcome::Prepared); + assert(toml::parse(edit.text)["ui"]["auto_confirm_instant_warp"].value() == "jump"); + } + const std::string special = "quotes \"'\\\n[ui]\nauto_confirm_instant_warp = 'jump'\n\xc3\xa9"; + const auto escaped = editor.Prepare("ui = {auto_confirm_instant_warp='none'}", Mode(request.expected, special)); + assert(escaped.outcome == Outcome::Prepared); + assert(toml::parse(escaped.text)["ui"]["auto_confirm_instant_warp"].value() == special); + assert(editor.Prepare("[ui]\nauto_confirm_instant_warp='jump'", request).outcome == Outcome::Conflict); + assert(editor.Prepare("[ui]\nauto_confirm_instant_warp='warp'", request).outcome == Outcome::AlreadySaved); + assert(editor.Prepare("ui = [", request).outcome == Outcome::InvalidDocument); + assert(editor.Prepare("ui = 3", request).outcome == Outcome::Unsupported); + assert(editor.Prepare("[ui]\nother=true", request).outcome == Outcome::Conflict); + // Decoded key identities may contain dots, Unicode and combining codepoints. + // Columns are parser coordinates, not UTF-8 byte counts or display widths. + const std::string unicode_section = "ui.\xc3\xa9"; + const std::string unicode_key = "e\xcc\x81.mode"; + const std::string unicode_document = + "[\"" + unicode_section + "\"]\r\n\"" + unicode_key + "\" = 'none' # untouched\r\n"; + const auto unicode_edit = + editor.Prepare(unicode_document, {unicode_section, unicode_key, Value{std::string("none")}, std::string("jump")}); + assert(unicode_edit.outcome == Outcome::Prepared); + auto unicode_expected = unicode_document; + unicode_expected.replace(unicode_expected.find("'none'"), 6, "\"jump\""); + assert(unicode_edit.text == unicode_expected); + const std::string combining = "ui = { other = 'e\xcc\x81', auto_confirm_instant_warp = 'none' }\n"; + auto combining_expected = combining; + combining_expected.replace(combining_expected.find("'none'"), 6, "\"warp\""); + assert(editor.Prepare(combining, request).text == combining_expected); + const Request boolean{"ui", "enabled", Value{false}, true}; + const auto numeric = + editor.Prepare("[graphics]\nthreshold = 0.5 # keep\n", {"graphics", "threshold", Value{0.5}, 0.75}); + assert(numeric.outcome == Outcome::Prepared && numeric.text == "[graphics]\nthreshold = 0.75 # keep\n"); + assert(editor.Prepare("[graphics]\nthreshold = 0.6\n", {"graphics", "threshold", Value{0.5}, 0.75}).outcome + == Outcome::Conflict); + const auto integer = + editor.Prepare("[graphics]\nthreshold = 0\n", {"graphics", "threshold", Value{std::int64_t{0}}, 0.5}); + assert(integer.outcome == Outcome::Prepared + && toml::parse(integer.text)["graphics"]["threshold"].value() == 0.5); + for (const auto desired : {std::numeric_limits::min(), std::numeric_limits::max()}) { + const auto whole = + editor.Prepare("[graphics]\ncount = 0 # keep\n", {"graphics", "count", Value{std::int64_t{0}}, desired}); + assert(whole.outcome == Outcome::Prepared && whole.text.ends_with(" # keep\n")); + assert(toml::parse(whole.text)["graphics"]["count"].value() == desired); + } + for (const auto desired : {std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + const auto invalid = + editor.Prepare("[graphics]\nthreshold = 0.5 # keep\n", {"graphics", "threshold", Value{0.5}, desired}); + assert(invalid.outcome == Outcome::Unsupported && invalid.text.empty()); + } + assert(editor.Prepare("[ui]\nenabled = false # keep\n", boolean).text == "[ui]\nenabled = true # keep\n"); + assert(editor.Prepare("[ui]\nenabled = true", boolean).outcome == Outcome::AlreadySaved); + assert(editor.Prepare("[ui]\nenabled = 'false'", boolean).outcome == Outcome::Conflict); + const std::string escaped_key = "a.\"b\\c"; + for (const std::string document : {"[ui]\n", "ui = {}\n"}) { + const auto inserted = editor.Prepare(document, {"ui", escaped_key, std::nullopt, true}); + assert(inserted.outcome == Outcome::Prepared); + assert(toml::parse(inserted.text)["ui"][escaped_key].value() == true); + } + + const std::filesystem::path root(argv[1]); + std::filesystem::create_directories(root); + const auto path = root / "settings.toml"; + const std::string initial = "[ui]\nauto_confirm_instant_warp='none'\nother=true # preserve\n"; + ReplaceConfigText(path, initial); + const auto external = initial + "# external comment\n"; + ReplaceConfigText(path, external); + assert(editor.Save(path, request) == Outcome::Saved); + assert(ReadConfigText(path).ends_with("# external comment\n")); + ReplaceConfigText(path, "[ui]\nauto_confirm_instant_warp='jump'\n"); + assert(editor.Save(path, request) == Outcome::Conflict); + assert(!ReplaceConfigText(path, initial, external)); + assert(toml::parse(ReadConfigText(path))["ui"]["auto_confirm_instant_warp"].value() == "jump"); + const auto absent = root / "absent.toml"; + assert(!std::filesystem::exists(absent)); + assert(editor.Save(absent, request) == Outcome::IoError); + assert(!std::filesystem::exists(absent)); + std::cout << "TOML editor preservation/conflict fixtures passed\n"; +} diff --git a/tests/xmake.lua b/tests/xmake.lua new file mode 100644 index 000000000..f1af1d1f8 --- /dev/null +++ b/tests/xmake.lua @@ -0,0 +1,30 @@ +target("keyboard-layout-tests") +do + set_kind("binary") + set_default(false) + add_files("keyboard_layout_tests.cc") + add_includedirs("../mods/src") + if is_plat("windows") then + add_syslinks("user32") + end +end + +target("shortcut-layout-dispatch-tests") +do + set_kind("binary") + set_default(false) + add_deps("mods") + add_files("shortcut_hint_cache.cc") + add_packages("libil2cpp", "eastl", "toml++", "spdlog") +end + +target("keyboard-chord-tests") +do + set_kind("binary") + set_default(false) + add_files("keyboard_chord_tests.cc") + add_includedirs("../mods/src") + if is_plat("windows") then + add_syslinks("user32") + end +end diff --git a/xmake.lua b/xmake.lua index 099d26406..ab98245bf 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,6 +26,7 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") +includes("tests") target("il2cpp-checked-tests") set_kind("binary")