diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6d912f79d..c4db85207 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,6 +33,11 @@ env: jobs: build-win: runs-on: windows-2025-vs2026 + outputs: + run_attempt: ${{ steps.build_identity.outputs.run_attempt }} + permissions: + actions: read + contents: read env: STFC_MSVC_SCCACHE: "1" WINDOWS_SCCACHE_SCHEMA: "v1" @@ -40,6 +45,12 @@ jobs: SCCACHE_CACHE_SIZE: "2G" steps: + - name: Capture build identity + id: build_identity + shell: pwsh + run: | + "run_attempt=${{ github.run_attempt }}" >> $env:GITHUB_OUTPUT + - name: Checkout uses: actions/checkout@v7 with: @@ -58,7 +69,7 @@ jobs: key: xmake-tool-${{ runner.os }}-${{ runner.arch }}-${{ env.XMAKE_VERSION }}-${{ env.XMAKE_CACHE_SCHEMA }} - name: Setup XMake - uses: xmake-io/github-action-setup-xmake@v1 + uses: xmake-io/github-action-setup-xmake@v1.2.5 with: xmake-version: ${{ env.XMAKE_VERSION }} @@ -119,7 +130,27 @@ jobs: - name: Configure id: configure_windows timeout-minutes: 30 - run: xmake f -m release -y -v + shell: pwsh + run: | + $commit = (git rev-parse HEAD).Trim() + if ($commit -notmatch '^[0-9a-f]{40}$') { + throw "Unable to resolve the checked-out commit for DLL provenance." + } + + $sourceState = "git:$commit" + $buildInvocation = "github:${{ github.run_id }}:${{ github.run_attempt }}:windows" + $identity = "stfc-identity-v1;distribution=netniv.stfc-community-mod;source=$sourceState;" + + "base=$commit;build=$buildInvocation;mode=release;channel=ci" + "STFC_EXPECTED_IDENTITY=$identity" >> $env:GITHUB_ENV + + $xmakeArgs = @( + 'f', '-m', 'release', '-y', '-v', + "--stfc_source_state_id=$sourceState", + "--stfc_base_commit=$commit", + "--stfc_build_invocation_id=$buildInvocation", + '--stfc_build_channel=ci' + ) + xmake @xmakeArgs - name: Verify dependency lock shell: pwsh @@ -146,7 +177,7 @@ jobs: - name: Upload configure diagnostics if: ${{ failure() && steps.configure_windows.outcome == 'failure' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v7.0.1 with: name: xmake-configure-diagnostics-windows path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt @@ -172,8 +203,18 @@ jobs: "build/windows/x64/release/stfc-community-mod.dll" ` "version.dll" + - name: Verify DLL provenance + shell: pwsh + run: | + $dll = (Resolve-Path -LiteralPath 'version.dll').Path + $comments = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll).Comments + if (-not [string]::Equals($comments, $env:STFC_EXPECTED_IDENTITY, [System.StringComparison]::Ordinal)) { + throw "DLL provenance mismatch. Expected '$env:STFC_EXPECTED_IDENTITY'; found '$comments'." + } + Write-Host "Verified DLL provenance: $comments" + - name: Upload - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v7.0.1 with: name: stfc-community-mod path: version.dll @@ -208,6 +249,63 @@ jobs: path: ${{ runner.tool_cache }}/xmake key: ${{ steps.restore_xmake.outputs.cache-primary-key }} + attest-win: + name: attest-win + needs: build-win + if: >- + github.event_name == 'push' && + github.repository == 'netniV/stfc-mod' && + github.ref_protected && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + runs-on: windows-2025-vs2026 + permissions: + actions: read + contents: read + id-token: write + attestations: write + + steps: + - name: Download Windows DLL + uses: actions/download-artifact@v8.0.1 + with: + name: stfc-community-mod + path: .artifacts/windows + + - name: Verify DLL provenance + shell: pwsh + env: + STFC_SOURCE_COMMIT: ${{ github.sha }} + STFC_RUN_ID: ${{ github.run_id }} + STFC_BUILD_RUN_ATTEMPT: ${{ needs.build-win.outputs.run_attempt }} + run: | + $dll = (Resolve-Path -LiteralPath '.artifacts/windows/version.dll').Path + $commit = $env:STFC_SOURCE_COMMIT + $runId = $env:STFC_RUN_ID + $buildAttempt = $env:STFC_BUILD_RUN_ATTEMPT + + if ($commit -notmatch '^[0-9a-f]{40}$') { + throw "Unable to resolve the workflow commit for DLL provenance." + } + if ($runId -notmatch '^[1-9][0-9]*$') { + throw "Unable to resolve the workflow run ID for DLL provenance." + } + if ($buildAttempt -notmatch '^[1-9][0-9]*$') { + throw "The producing build did not provide a valid run attempt." + } + + $expected = "stfc-identity-v1;distribution=netniv.stfc-community-mod;source=git:$commit;" + + "base=$commit;build=github:${runId}:${buildAttempt}:windows;mode=release;channel=ci" + $comments = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll).Comments + if (-not [string]::Equals($comments, $expected, [System.StringComparison]::Ordinal)) { + throw "DLL provenance mismatch. Expected '$expected'; found '$comments'." + } + Write-Host "Verified DLL provenance: $comments" + + - name: Attest Windows DLL + uses: actions/attest@v4.2.1 + with: + subject-path: .artifacts/windows/version.dll + build-mac: name: build-mac (${{ matrix.arch }}) @@ -249,7 +347,7 @@ jobs: key: xmake-tool-${{ runner.os }}-${{ runner.arch }}-${{ env.XMAKE_VERSION }}-${{ env.XMAKE_CACHE_SCHEMA }} - name: Setup XMake - uses: xmake-io/github-action-setup-xmake@v1 + uses: xmake-io/github-action-setup-xmake@v1.2.5 with: xmake-version: ${{ env.XMAKE_VERSION }} @@ -359,7 +457,7 @@ jobs: - name: Upload configure diagnostics if: ${{ failure() && steps.configure.outcome == 'failure' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v7.0.1 with: name: xmake-configure-diagnostics-macos-${{ matrix.arch }} path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt @@ -468,7 +566,7 @@ jobs: fi - name: Upload intermediate build - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v7.0.1 with: name: ${{ matrix.artifact }} path: macos-${{ matrix.arch }}-build.tar @@ -532,13 +630,13 @@ jobs: show-progress: false - name: Download ARM64 build - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v8.0.1 with: name: macos-arm64-build path: .artifacts/arm64 - name: Download x86_64 build - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v8.0.1 with: name: macos-x86_64-build path: .artifacts/x86_64 @@ -740,7 +838,7 @@ jobs: "$DMG_SOURCE/" - name: Upload installer - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v7.0.1 with: name: stfc-community-mod-installer path: stfc-community-mod-installer.dmg @@ -748,7 +846,7 @@ jobs: if-no-files-found: error - name: Upload macOS mod archive - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v7.0.1 with: name: stfc-community-mod-macos-universal path: stfc-community-mod-macos-universal diff --git a/AGENTS.md b/AGENTS.md index a0124914f..507b3d8f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ void SomeFunction_Hook(auto original, SomeClass* _this, ...) { ``` macOS does not tolerate repeated hooks of the same function. If multiple features need to intercept the same game method, consolidate the behavior behind one detour or add platform guards instead of installing overlapping hooks. Do not over-focus on hidden IL2CPP `MethodInfo*` parameters during drift repair; match the game-visible signature from `dump.cs` unless there is concrete runtime evidence that the hidden parameter is the issue. +Before adding a detour to a small IL2CPP wrapper or thunk, verify its native method extent against SPUD's architecture-specific overwrite size on every supported architecture for the exact client build. A successful platform build does not validate hook fit; prefer a substantive downstream method when exact per-architecture extent is unavailable. **IL2CPP class resolution** — Game classes are resolved at runtime using helpers: ```cpp diff --git a/README.md b/README.md index 79b566f7d..57f3b0c81 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ This project is maintained solely at my own cost of time, energy and money. Any Please see the [INSTALL.md](INSTALL.md) instructions which has steps on how to use this mod with Star Trek Fleet Command. +For the Windows DLL identity marker, artifact-attestation trust boundary, and verification command, see +[Windows DLL provenance](docs/WINDOWS_DLL_PROVENANCE.md). + Please note, that whilst Mac support was added in this version, it's supported on an as-is basis due to lack of Mac development environments. ## Keyboard shortcuts @@ -69,10 +72,10 @@ been applied. Valid values for any short can be found in [KEYMAPPING.md](KEYMAPP | -----------: | -------------------------------- | | F10 | Bug fixer (exits game) | | F1-F5 | Zoom presets | -| Q | Zoom Out | -| E | Zoom In | +| Q | Zoom In | +| E | Zoom Out | | MINUS | Zoom (min) | -| EQUALS | Zoom (default) | +| = | Zoom (default) | | BACKSPACE | Zoom (max) | | C | Open/Focus Chat - Full Screen | | Alt-C | Open/Focus Chat - Side of Screen | @@ -109,9 +112,12 @@ action items such as: | Key | Shortcut | | ------: | ---------------- | -| T | Events | +| T | Away Teams | | G | Galaxy | +| Ctrl-G | Galaxy (native) | | H | System | +| Shift-E | Events | +| Ctrl-E | Events (native) | | Shift-G | Exterior View | | Shift-H | Interior View | | B | Bookmarks | @@ -125,7 +131,6 @@ action items such as: | O | Command Center | | Shift-O | Officers | | Shift-Q | Q-Trials | -| Shift-T | Away Teams | | X | ExoComp | | Z | Daily Missions | diff --git a/docs/WINDOWS_DLL_PROVENANCE.md b/docs/WINDOWS_DLL_PROVENANCE.md new file mode 100644 index 000000000..b47dc91f4 --- /dev/null +++ b/docs/WINDOWS_DLL_PROVENANCE.md @@ -0,0 +1,34 @@ +# Windows DLL provenance + +Windows builds publish a `stfc-identity-v1` marker in the DLL's standard `Comments` version resource. The marker is +minimal, descriptive, and self-declared. It helps identify a build, but it is not evidence of authenticity and must +not be used as an authorization or trust decision by itself. + +For protected `main` and `dev` pushes in `netniV/stfc-mod`, the upstream Build workflow creates a GitHub/Sigstore +build-provenance attestation for the exact `version.dll` bytes after verifying the embedded marker. Pull requests, +fork runs, unprotected refs, tags, and other repositories do not create authoritative attestations. + +After downloading and extracting an attested DLL, verify its byte integrity, expected upstream workflow identity, +protected source ref, and reviewed source commit with a current GitHub CLI. Replace `` with the +40-character commit for the artifact: + +```shell +gh attestation verify version.dll \ + --repo netniV/stfc-mod \ + --signer-workflow netniV/stfc-mod/.github/workflows/ci.yaml \ + --source-ref refs/heads/main \ + --source-digest +``` + +For a reviewed `dev` artifact, use `--source-ref refs/heads/dev` instead. Do not omit the source ref or digest when +making a trust decision. + +Successful verification establishes that the exact file digest is covered by an attestation issued for the named +repository and signer workflow, including source commit provenance. It does not establish that the source, +dependencies, build environment, or resulting behavior are free from malicious or unsafe behavior. It also does not, +by itself, authorize the artifact as an official release. + +The first authoritative upstream attestation can exist only after the attestation workflow is merged and then runs on +an eligible protected upstream push. Existing releases are unattested. Downstream consumers, including STFC Mod +Bridge, must continue to use exact reviewed file size and SHA-256 allowlists until an attested upstream artifact is +actually available and their attestation policy is separately reviewed. diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 4e4003d2f..0d6d61ec6 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -31,12 +31,31 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[======================================================================================================]> # # ***** * * * @@ -121,6 +140,21 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Detaljegrad for flådeetiketter i systemvisning. Konfigurer spiller- og ikke-spillerflåder separat: +# "native" - brug Scopelys Near/Middle/Far-adfærd +# "expanded" - vis altid lange flådeetiketter +# "compact" - vis altid kompakte flådeetiketter +# "threshold" - skift fra lange til kompakte etiketter ved den tilsvarende zoomgrænse +# Spillerflåder +zoom_label_player_detail = "native" + +# Normaliseret zoomgrænse for "threshold" (0.0 = altid kompakt, 1.0 = altid udvidet) +zoom_label_player_threshold = 0.5 + +# Ikke-spillerflåder og uklassificerede flåder +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 + # Tiden mellem overgange; øg værdien for at gøre overgangene langsommere transition_time = 0.01 @@ -235,6 +269,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -455,6 +490,9 @@ show_daily = "Z" # Gå til begivenheder show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Gå til Exocomp show_exocomp = "X" @@ -464,6 +502,9 @@ show_factions = "F" # Gå til galaksen show_galaxy = "G" +# Scopely shortcut-native Galaxy Action +native_shortcut_galaxy = "CTRL-G" + # Gå til gaver show_gifts = "/" @@ -491,6 +532,10 @@ show_scrapyard = "Y" # Gå til indstillinger show_settings = "SHIFT-S" +# Slå genvejstips på skærmen til/fra (viser mod-genveje, hvor de understøttes) +toggle_shortcut_hints = "NONE" +# Symboler i mærker bruger AutoHotkey-notation: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Gå til administration af skibe show_ships = "N" @@ -593,9 +638,18 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Forhindr "Escape" i at spørge, om spillet skal afsluttes disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EKSPERIMENTELT: Forhindr den første pop op-reklame i at blive vist disable_first_popup = false @@ -632,6 +686,12 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- @@ -829,4 +889,3 @@ verify_ssl = true # <[========================================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 2dda59892..9906f13ed 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -31,12 +31,31 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -121,6 +140,21 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Detailgrad der Flottenbeschriftungen. Spieler- und Nicht-Spieler-Flotten werden separat konfiguriert: +# "native" - Scopelys Near/Middle/Far-Verhalten verwenden +# "expanded" - lange Flottenbeschriftungen immer anzeigen +# "compact" - kompakte Flottenbeschriftungen immer anzeigen +# "threshold" - an der zugehörigen Zoomgrenze von erweitert zu kompakt wechseln +# Spielerflotten +zoom_label_player_detail = "native" + +# Normalisierte Zoomgrenze für "threshold" (0.0 = immer kompakt, 1.0 = immer erweitert) +zoom_label_player_threshold = 0.5 + +# Nicht-Spieler-Flotten und nicht klassifizierte Flotten +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 + # Dauer zwischen Übergängen; erhöhen, um Übergänge zu verlangsamen transition_time = 0.01 @@ -235,6 +269,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -455,6 +490,9 @@ show_daily = "Z" # Zu den Events show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Zu den Exocomps show_exocomp = "X" @@ -464,6 +502,9 @@ show_factions = "F" # Zur Galaxie show_galaxy = "G" +# Scopely shortcut-native Galaxy Action +native_shortcut_galaxy = "CTRL-G" + # Zu den Geschenken show_gifts = "/" @@ -491,6 +532,10 @@ show_scrapyard = "Y" # Zu den Einstellungen show_settings = "SHIFT-S" +# Bildschirmhinweise für Tastenkürzel umschalten (zeigt unterstützte Mod-Tastenkürzel) +toggle_shortcut_hints = "NONE" +# Hinweissymbole verwenden die AutoHotkey-Schreibweise: ^ Strg, ! Alt, + Umschalt, # Win/Befehl + # Zur Schiffsverwaltung show_ships = "N" @@ -593,9 +638,18 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Verhindert, dass die Escape-Taste zum Beenden des Spiels auffordert disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTELL: Verhindert das Erscheinen des ersten Werbe-Pop-ups disable_first_popup = false @@ -632,6 +686,12 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- @@ -829,4 +889,3 @@ verify_ssl = true # <[===================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 0ccf711ca..79cd669f9 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -31,12 +31,31 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -121,6 +140,21 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Fleet label detail in system view. Configure player and non-player fleets separately: +# "native" - use Scopely's Near/Middle/Far behaviour +# "expanded" - always show long fleet labels +# "compact" - always show compact fleet labels +# "threshold" - switch from expanded to compact at the matching zoom threshold +# Player fleets +zoom_label_player_detail = "native" + +# Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) +zoom_label_player_threshold = 0.5 + +# Non-player and unclassified fleets +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 + # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 @@ -235,6 +269,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -455,6 +490,9 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Go to Exocomp show_exocomp = "X" @@ -464,6 +502,9 @@ show_factions = "F" # Go to Galaxy show_galaxy = "G" +# Scopely shortcut-native Galaxy Action +native_shortcut_galaxy = "CTRL-G" + # Go to Gifts show_gifts = "/" @@ -491,6 +532,10 @@ show_scrapyard = "Y" # Go to Settings show_settings = "SHIFT-S" +# Toggle on-screen shortcut hints (shows mod bindings where supported) +toggle_shortcut_hints = "NONE" +# Badge modifiers use AutoHotkey notation: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Go to Manage Ships show_ships = "N" @@ -593,9 +638,18 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTAL: Prevent the first popup advert from appearing disable_first_popup = false @@ -632,6 +686,12 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- @@ -829,4 +889,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 9f1e1e3a6..515ead438 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -31,12 +31,31 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -121,6 +140,21 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Fleet label detail in system view. Configure player and non-player fleets separately: +# "native" - use Scopely's Near/Middle/Far behaviour +# "expanded" - always show long fleet labels +# "compact" - always show compact fleet labels +# "threshold" - switch from expanded to compact at the matching zoom threshold +# Player fleets +zoom_label_player_detail = "native" + +# Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) +zoom_label_player_threshold = 0.5 + +# Non-player and unclassified fleets +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 + # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 @@ -235,6 +269,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -455,6 +490,9 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Go to Exocomp show_exocomp = "X" @@ -464,6 +502,9 @@ show_factions = "F" # Go to Galaxy show_galaxy = "G" +# Scopely shortcut-native Galaxy Action +native_shortcut_galaxy = "CTRL-G" + # Go to Gifts show_gifts = "/" @@ -491,6 +532,10 @@ show_scrapyard = "Y" # Go to Settings show_settings = "SHIFT-S" +# Toggle on-screen shortcut hints (shows mod bindings where supported) +toggle_shortcut_hints = "NONE" +# Badge modifiers use AutoHotkey notation: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Go to Manage Ships show_ships = "N" @@ -593,9 +638,18 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTAL: Prevent the first popup advert from appearing disable_first_popup = false @@ -632,6 +686,12 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- @@ -829,4 +889,3 @@ verify_ssl = true # <[=============================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9ffe927fe..d4259945e 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -31,12 +31,31 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -121,6 +140,21 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Fleet label detail in system view. Configure player and non-player fleets separately: +# "native" - use Scopely's Near/Middle/Far behaviour +# "expanded" - always show long fleet labels +# "compact" - always show compact fleet labels +# "threshold" - switch from expanded to compact at the matching zoom threshold +# Player fleets +zoom_label_player_detail = "native" + +# Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) +zoom_label_player_threshold = 0.5 + +# Non-player and unclassified fleets +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 + # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 @@ -235,6 +269,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -455,6 +490,9 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Go to Exocomp show_exocomp = "X" @@ -464,6 +502,9 @@ show_factions = "F" # Go to Galaxy show_galaxy = "G" +# Scopely shortcut-native Galaxy Action +native_shortcut_galaxy = "CTRL-G" + # Go to Gifts show_gifts = "/" @@ -491,6 +532,10 @@ show_scrapyard = "Y" # Go to Settings show_settings = "SHIFT-S" +# Toggle on-screen shortcut hints (shows mod bindings where supported) +toggle_shortcut_hints = "NONE" +# Badge modifiers use AutoHotkey notation: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Go to Manage Ships show_ships = "N" @@ -593,9 +638,18 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTAL: Prevent the first popup advert from appearing disable_first_popup = false @@ -632,6 +686,12 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- @@ -829,4 +889,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index b89c9a2e8..d48f6d3a3 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -31,12 +31,31 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=====================================================]> # # * * * @@ -121,6 +140,21 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Detalle de las etiquetas. Configura por separado las flotas de jugadores y las que no son de jugadores: +# "native" - usar el comportamiento Near/Middle/Far de Scopely +# "expanded" - mostrar siempre las etiquetas largas +# "compact" - mostrar siempre las etiquetas compactas +# "threshold" - cambiar de expandido a compacto en el umbral de zoom correspondiente +# Flotas de jugadores +zoom_label_player_detail = "native" + +# Umbral de zoom normalizado para "threshold" (0.0 = siempre compacto, 1.0 = siempre expandido) +zoom_label_player_threshold = 0.5 + +# Flotas que no son de jugadores y flotas sin clasificar +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 + # Tiempo entre transiciones; aumenta el valor para ralentizarlas transition_time = 0.01 @@ -235,6 +269,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -455,6 +490,9 @@ show_daily = "Z" # Ir a eventos show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Ir a Exocomp show_exocomp = "X" @@ -464,6 +502,9 @@ show_factions = "F" # Ir a la galaxia show_galaxy = "G" +# Scopely shortcut-native Galaxy Action +native_shortcut_galaxy = "CTRL-G" + # Ir a regalos show_gifts = "/" @@ -491,6 +532,10 @@ show_scrapyard = "Y" # Ir a ajustes show_settings = "SHIFT-S" +# Alternar las indicaciones de atajos en pantalla (muestra los atajos compatibles del mod) +toggle_shortcut_hints = "NONE" +# Los modificadores usan la notación de AutoHotkey: ^ Ctrl, ! Alt, + Mayús, # Win/Comando + # Ir a administrar barcos show_ships = "N" @@ -593,9 +638,18 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Impide que "Escape" pregunte si deseas salir del juego disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTAL: Impide que aparezca el primer anuncio emergente disable_first_popup = false @@ -632,6 +686,12 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- @@ -829,4 +889,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index fdbc5f79d..f77e68d67 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -31,12 +31,31 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -121,6 +140,21 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Détail des étiquettes. Configurez séparément les flottes de joueurs et les flottes non-joueurs : +# "native" - utiliser le comportement Near/Middle/Far de Scopely +# "expanded" - toujours afficher les étiquettes longues +# "compact" - toujours afficher les étiquettes compactes +# "threshold" - passer du format étendu au format compact au seuil de zoom correspondant +# Flottes de joueurs +zoom_label_player_detail = "native" + +# Seuil de zoom normalisé pour "threshold" (0.0 = toujours compact, 1.0 = toujours étendu) +zoom_label_player_threshold = 0.5 + +# Flottes non-joueurs et flottes non classées +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 + # Temps entre les transitions, augmenter pour ralentir les transitions transition_time = 0.01 @@ -235,6 +269,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -455,6 +490,9 @@ show_daily = "Z" # Aller aux événements show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Aller aux Exocomps show_exocomp = "X" @@ -464,6 +502,9 @@ show_factions = "F" # Aller à la galaxie show_galaxy = "G" +# Scopely shortcut-native Galaxy Action +native_shortcut_galaxy = "CTRL-G" + # Aller aux cadeaux show_gifts = "/" @@ -491,6 +532,10 @@ show_scrapyard = "Y" # Aller aux paramètres show_settings = "SHIFT-S" +# Afficher ou masquer les raccourcis à l’écran (affiche les raccourcis du mod pris en charge) +toggle_shortcut_hints = "NONE" +# Les modificateurs utilisent la notation AutoHotkey : ^ Ctrl, ! Alt, + Maj, # Win/Commande + # Aller aux vaisseaux show_ships = "N" @@ -593,9 +638,18 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Empêcher "échap" de proposer de quitter le jeu disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPÉRIMENTAL : Empêcher la première publicité pop-up d'apparaître disable_first_popup = false @@ -632,6 +686,12 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- @@ -829,4 +889,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b2b39b208..60917a152 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -31,12 +31,31 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -121,6 +140,21 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Detailniveau van vlootlabels. Configureer spelers- en niet-spelersvloten afzonderlijk: +# "native" - gebruik Scopely's Near/Middle/Far-gedrag +# "expanded" - toon altijd lange vlootlabels +# "compact" - toon altijd compacte vlootlabels +# "threshold" - schakel bij de bijbehorende zoomgrens van uitgebreid naar compact +# Spelersvloten +zoom_label_player_detail = "native" + +# Genormaliseerde zoomgrens voor "threshold" (0.0 = altijd compact, 1.0 = altijd uitgebreid) +zoom_label_player_threshold = 0.5 + +# Niet-spelersvloten en niet-geclassificeerde vloten +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 + # Bepaald de tijd tussen transities, hoe hoger het getal des te trager de transitie transition_time = 0.01 @@ -235,6 +269,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -455,6 +490,9 @@ show_daily = "Z" # Ga naar Evenementen show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Ga naar Exocomp show_exocomp = "X" @@ -464,6 +502,9 @@ show_factions = "F" # Ga naar Sterrenstelsel show_galaxy = "G" +# Scopely shortcut-native Galaxy Action +native_shortcut_galaxy = "CTRL-G" + # Ga naar Cadeaus show_gifts = "/" @@ -491,6 +532,10 @@ show_scrapyard = "Y" # Ga naar Instellingen show_settings = "SHIFT-S" +# Schermtips voor sneltoetsen in- of uitschakelen (toont ondersteunde mod-sneltoetsen) +toggle_shortcut_hints = "NONE" +# Modificatietoetsen gebruiken AutoHotkey-notatie: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Ga naar Scheepsmanagement show_ships = "N" @@ -593,9 +638,18 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Zorgt ervoor dat het gebruiken van "escape" het spel niet sluit disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTEEL: Zorgt ervoor dat de eerste advertentie popup niet toont disable_first_popup = false @@ -632,6 +686,12 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- @@ -829,4 +889,3 @@ verify_ssl = true # <[===========================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index ad3db036a..ff2a0e4cd 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -31,12 +31,31 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[============================================================]> # # @@ -121,6 +140,21 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Детализация меток. Настройте отдельно флоты игроков и неигровые флоты: +# "native" - использовать поведение Scopely Near/Middle/Far +# "expanded" - всегда показывать длинные метки флотов +# "compact" - всегда показывать компактные метки флотов +# "threshold" - переключать расширенные метки на компактные при соответствующем пороге масштаба +# Флоты игроков +zoom_label_player_detail = "native" + +# Нормализованный порог масштаба для "threshold" (0.0 = всегда компактно, 1.0 = всегда расширенно) +zoom_label_player_threshold = 0.5 + +# Неигровые и неклассифицированные флоты +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 + # Время между переходами; увеличьте значение, чтобы замедлить переходы transition_time = 0.01 @@ -235,6 +269,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -455,6 +490,9 @@ show_daily = "Z" # Перейти к событиям show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Перейти к экзокомпу show_exocomp = "X" @@ -464,6 +502,9 @@ show_factions = "F" # Перейти к галактике show_galaxy = "G" +# Scopely shortcut-native Galaxy Action +native_shortcut_galaxy = "CTRL-G" + # Перейти к подаркам show_gifts = "/" @@ -491,6 +532,10 @@ show_scrapyard = "Y" # Перейти к настройкам show_settings = "SHIFT-S" +# Показать или скрыть подсказки клавиш (показывает поддерживаемые сочетания мода) +toggle_shortcut_hints = "NONE" +# Модификаторы используют нотацию AutoHotkey: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Перейти к управлению кораблями show_ships = "N" @@ -593,9 +638,18 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Не показывать запрос выхода из игры при нажатии Escape disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ ЭКСПЕРИМЕНТАЛЬНО: Не показывать первое всплывающее рекламное окно disable_first_popup = false @@ -632,6 +686,12 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- @@ -829,4 +889,3 @@ verify_ssl = true # <[=====================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index b9bce7597..9d0e6e7e8 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -31,12 +31,31 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=====================================================]> # # * * **** * * @@ -121,6 +140,21 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Fleet label detail in system view. Configure player and non-player fleets separately: +# "native" - use Scopely's Near/Middle/Far behaviour +# "expanded" - always show long fleet labels +# "compact" - always show compact fleet labels +# "threshold" - switch from expanded to compact at the matching zoom threshold +# Player fleets +zoom_label_player_detail = "native" + +# Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) +zoom_label_player_threshold = 0.5 + +# Non-player and unclassified fleets +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 + # Amount of poH Daq spend between transitions, increase Daq slow transitions bIng transition_time = 0.01 @@ -235,6 +269,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -455,6 +490,9 @@ show_daily = "Z" # Go Daq Events show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Go Daq Exocomp show_exocomp = "X" @@ -464,6 +502,9 @@ show_factions = "F" # Go Daq Galaxy show_galaxy = "G" +# Scopely shortcut-native Galaxy Action +native_shortcut_galaxy = "CTRL-G" + # Go Daq Gifts show_gifts = "/" @@ -491,6 +532,10 @@ show_scrapyard = "Y" # Go Daq ghun show_settings = "SHIFT-S" +# Toggle on-screen shortcut hints (shows mod bindings where supported) +toggle_shortcut_hints = "NONE" +# Badge modifiers use AutoHotkey notation: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Go Daq Manage Ships show_ships = "N" @@ -593,9 +638,18 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" vo' prompting Daq exit game disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ ngong: Prevent wa'DIch popup advert vo' appearing disable_first_popup = false @@ -632,6 +686,12 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- @@ -829,4 +889,3 @@ verify_ssl = true # <[======================================================================================================]> [sync.targets.spocksclub] - diff --git a/mods/src/config.cc b/mods/src/config.cc index 95deab836..d5bda8221 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -13,6 +13,7 @@ #include "defaultconfig.h" #include #include +#include #include #include #include @@ -328,6 +329,38 @@ T get_config_or_default(toml::table& config, toml::table& new_config, std::strin return (T)final_value; } +NotificationSound get_notification_sound(toml::table& config, toml::table& new_config, std::string_view item, + std::string_view default_value, bool write_log) +{ + new_config.emplace("audio", toml::table()); + + std::string value{default_value}; + const auto configured = config["audio"][item]; + if (configured) { + if (const auto string_value = configured.value()) { + value = *string_value; + } else if (const auto bool_value = configured.value(); bool_value.has_value() && !*bool_value) { + value = "none"; + } else { + spdlog::warn("invalid config value audio.{}; expected a sound name or false; disabling this alert", item); + value = "none"; + } + } + + auto sound = notification_sound_from_name(StripAsciiWhitespace(value)); + if (!sound.has_value()) { + spdlog::warn("invalid config value audio.{}: '{}'; disabling this alert", item, value); + sound = NotificationSound::None; + } + + const auto result = sound.value_or(NotificationSound::None); + new_config["audio"].as_table()->insert_or_assign(item, notification_sound_name(result)); + if (write_log) { + spdlog::debug("config value audio.{} value: {}", item, notification_sound_name(result)); + } + return result; +} + std::string_view to_string(MissionHudVisibility visibility) { switch (visibility) { @@ -423,6 +456,79 @@ InstantWarpConfirmation get_auto_confirm_instant_warp(toml::table& config, toml: return confirmation; } +std::string_view to_string(FleetLabelDetail detail) +{ + switch (detail) { + case FleetLabelDetail::Expanded: + return "expanded"; + case FleetLabelDetail::Compact: + return "compact"; + case FleetLabelDetail::Threshold: + return "threshold"; + case FleetLabelDetail::Native: + default: + return "native"; + } +} + +FleetLabelDetail parse_fleet_label_detail(std::string_view key, std::string_view value) +{ + const auto trimmed = std::string(StripAsciiWhitespace(value)); + const auto normalized = AsciiStrToUpper(trimmed); + + if (normalized == "EXPANDED" || normalized == "ALWAYS") { + return FleetLabelDetail::Expanded; + } + if (normalized == "COMPACT" || normalized == "NEVER") { + return FleetLabelDetail::Compact; + } + if (normalized == "THRESHOLD" || normalized == "CUSTOM") { + return FleetLabelDetail::Threshold; + } + if (normalized == "NATIVE" || normalized == "AUTO" || normalized == "NONE" || normalized == "OFF" + || normalized.empty()) { + return FleetLabelDetail::Native; + } + + spdlog::warn("invalid config value graphics.{}: '{}'; using native", key, value); + return FleetLabelDetail::Native; +} + +FleetLabelDetail get_fleet_label_detail(toml::table& config, toml::table& new_config, std::string_view key, + std::string_view default_value, bool write_log) +{ + const auto value = config["graphics"][key].value().value_or(std::string(default_value)); + const auto detail = parse_fleet_label_detail(key, value); + + new_config.emplace("graphics", toml::table()); + new_config["graphics"].as_table()->insert_or_assign(key, std::string(to_string(detail))); + + if (write_log) { + spdlog::debug("config value graphics.{} value: {}", key, to_string(detail)); + } + + return detail; +} + +float get_fleet_label_zoom_threshold(toml::table& config, toml::table& new_config, std::string_view key, + float default_value, bool write_log) +{ + auto threshold = config["graphics"][key].value().value_or(default_value); + if (!std::isfinite(threshold) || threshold < 0.0f || threshold > 1.0f) { + spdlog::warn("invalid config value graphics.{}: {}; using {}", key, threshold, default_value); + threshold = default_value; + } + + new_config.emplace("graphics", toml::table()); + new_config["graphics"].as_table()->insert_or_assign(key, threshold); + + if (write_log) { + spdlog::debug("config value graphics.{} value: {}", key, threshold); + } + + return threshold; +} + void parse_ship_filter(std::string_view value, std::vector& names, bool& match_all) { names.clear(); @@ -825,6 +931,8 @@ void Config::Load() this->installPanHooks = get_config_or_default(config, parsed, "patches", "panhooks", DCP::panhooks, write_config); this->installHotkeyHooks = get_config_or_default(config, parsed, "patches", "hotkeyhooks", DCP::hotkeyhooks, write_config); + this->installFleetWatchHooks = + get_config_or_default(config, parsed, "patches", "fleetwatchhooks", DCP::fleetwatchhooks, write_config); this->installFreeResizeHooks = get_config_or_default(config, parsed, "patches", "freeresizehooks", DCP::freeresizehooks, write_config); this->installTempCrashFixes = @@ -881,8 +989,16 @@ void Config::Load() get_config_or_default(config, parsed, "graphics", "ui_scale_ship", DCG::ui_scale_ship, write_config); this->ui_scale_viewer = get_config_or_default(config, parsed, "graphics", "ui_scale_viewer", DCG::ui_scale_viewer, write_config); - this->zoom = get_config_or_default(config, parsed, "graphics", "zoom", DCG::zoom, write_config); - this->fr_scale = get_config_or_default(config, parsed, "graphics", "fr_scale", DCG::fr_scale, write_config); + this->zoom = get_config_or_default(config, parsed, "graphics", "zoom", DCG::zoom, write_config); + this->fr_scale = get_config_or_default(config, parsed, "graphics", "fr_scale", DCG::fr_scale, write_config); + this->zoom_label_player.detail = + get_fleet_label_detail(config, parsed, "zoom_label_player_detail", DCG::zoom_label_player_detail, write_config); + this->zoom_label_player.zoom_threshold = get_fleet_label_zoom_threshold( + config, parsed, "zoom_label_player_threshold", DCG::zoom_label_player_threshold, write_config); + this->zoom_label_non_player.detail = get_fleet_label_detail(config, parsed, "zoom_label_non_player_detail", + DCG::zoom_label_non_player_detail, write_config); + this->zoom_label_non_player.zoom_threshold = get_fleet_label_zoom_threshold( + config, parsed, "zoom_label_non_player_threshold", DCG::zoom_label_non_player_threshold, write_config); this->free_resize = get_config_or_default(config, parsed, "graphics", "free_resize", DCG::free_resize, write_config); this->allow_cursor = get_config_or_default(config, parsed, "graphics", "allow_cursor", DCG::allow_cursor, write_config); @@ -929,6 +1045,8 @@ void Config::Load() this->disable_escape_exit = get_config_or_default(config, parsed, "ui", "disable_escape_exit", DCU::disable_escape_exit, write_config); + this->escape_exit_timer = + get_config_or_default(config, parsed, "ui", "escape_exit_timer", DCU::escape_exit_timer, write_config); this->disable_preview_locate = get_config_or_default(config, parsed, "ui", "disable_preview_locate", DCU::disable_preview_locate, write_config); this->disable_preview_recall = @@ -943,16 +1061,44 @@ void Config::Load() get_config_or_default(config, parsed, "audio", "trace_events", DCA::trace_events, write_config); auto disabled_audio_events = get_config_or_default( config, parsed, "audio", "disabled_events", DCA::disabled_events, write_config); + this->disable_all_audio_events = false; this->disabled_audio_events.clear(); for (const auto& event : StrSplit(disabled_audio_events, ',')) { auto stripped = StripAsciiWhitespace(event); - if (!stripped.empty()) { + if (AsciiStrToUpper(std::string{stripped}) == "ALL") { + this->disable_all_audio_events = true; + } else if (!stripped.empty()) { this->disabled_audio_events.emplace_back(stripped); } } - this->installAudioEventHooks = this->trace_audio_events || !this->disabled_audio_events.empty(); + this->installAudioEventHooks = + this->trace_audio_events || this->disable_all_audio_events || !this->disabled_audio_events.empty(); + this->alert_victory = + get_notification_sound(config, parsed, "alert_victory", DCA::alert_victory, write_config); + this->alert_defeat = get_notification_sound(config, parsed, "alert_defeat", DCA::alert_defeat, write_config); + this->alert_armada_created = + get_notification_sound(config, parsed, "alert_armada_created", DCA::alert_armada_created, write_config); + this->alert_armada_battle_won = + get_notification_sound(config, parsed, "alert_armada_battle_won", DCA::alert_armada_battle_won, write_config); + this->alert_armada_battle_lost = + get_notification_sound(config, parsed, "alert_armada_battle_lost", DCA::alert_armada_battle_lost, write_config); + static_assert(DCA::alert_fleet_events.size() == kFleetNotificationCatalog.size()); + for (const auto& entry : kFleetNotificationCatalog) { + const auto index = fleet_notification_index(entry.kind); + this->fleet_notification_sounds[index] = + get_notification_sound(config, parsed, entry.audio_config_name, DCA::alert_fleet_events[index], write_config); + } + if (!this->installToastBannerHooks + && (this->alert_victory != NotificationSound::None || this->alert_defeat != NotificationSound::None + || this->alert_armada_created != NotificationSound::None + || this->alert_armada_battle_won != NotificationSound::None + || this->alert_armada_battle_lost != NotificationSound::None)) { + spdlog::warn("audio alerts require patches.toastbannerhooks = true"); + } this->auto_open_bulk_claim_flyout = get_config_or_default(config, parsed, "ui", "auto_open_bulk_claim_flyout", DCU::auto_open_bulk_claim_flyout, write_config); + this->allow_officer_preset_reordering = get_config_or_default( + config, parsed, "ui", "allow_officer_preset_reordering", DCU::allow_officer_preset_reordering, write_config); read_daily_bulk_claim_factions(config, parsed, this->daily_bulk_claim_factions, DCU::daily_bulk_claim_factions, write_config); @@ -1185,6 +1331,45 @@ void Config::Load() spdlog::debug("Final notify banner types: {}", notifyString); parsed["ui"].as_table()->insert_or_assign("notify_banner_types", notifyString); + auto notify_fleet_events_str = get_config_or_default(config, parsed, "ui", "notify_fleet_events", + DCU::notify_fleet_events, write_log); + this->notify_fleet_events = 0; + for (const auto& event : StrSplit(notify_fleet_events_str, ',')) { + const std::string trimmed{StripAsciiWhitespace(event)}; + if (trimmed.empty()) { + continue; + } + const auto normalized = AsciiStrToUpper(trimmed); + if (normalized == "ALL") { + this->notify_fleet_events = kAllFleetNotifications; + break; + } + bool matched = false; + for (const auto& entry : kFleetNotificationCatalog) { + if (normalized == AsciiStrToUpper(entry.config_name)) { + this->notify_fleet_events |= fleet_notification_bit(entry.kind); + matched = true; + break; + } + } + if (!matched) { + spdlog::warn("Unknown fleet notification event '{}'; ignoring it", trimmed); + } + } + + std::string fleet_events_string; + for (const auto& entry : kFleetNotificationCatalog) { + if ((this->notify_fleet_events & fleet_notification_bit(entry.kind)) == 0) { + continue; + } + if (!fleet_events_string.empty()) { + fleet_events_string.append(", "); + } + fleet_events_string.append(entry.config_name); + } + spdlog::debug("Final fleet notification events: {}", fleet_events_string); + parsed["ui"].as_table()->insert_or_assign("notify_fleet_events", fleet_events_string); + spdlog::debug(""); parse_config_shortcut(config, parsed, "move_left", GameFunction::MoveLeft, DCSH::move_left); @@ -1226,6 +1411,8 @@ void Config::Load() parse_config_shortcut(config, parsed, "show_chatside1", GameFunction::ShowChatSide1, DCSH::show_chatside1); parse_config_shortcut(config, parsed, "show_chatside2", GameFunction::ShowChatSide2, DCSH::show_chatside2); parse_config_shortcut(config, parsed, "show_galaxy", GameFunction::ShowGalaxy, DCSH::show_galaxy); + parse_config_shortcut_aliases(config, parsed, "native_shortcut_galaxy", GameFunction::NativeShortcutGalaxy, + DCSH::native_shortcut_galaxy, {"show_galaxy_native"}); parse_config_shortcut(config, parsed, "show_system", GameFunction::ShowSystem, DCSH::show_system); parse_config_shortcut(config, parsed, "zoom_preset1", GameFunction::ZoomPreset1, DCSH::zoom_preset1); parse_config_shortcut(config, parsed, "zoom_preset2", GameFunction::ZoomPreset2, DCSH::zoom_preset2); @@ -1261,6 +1448,8 @@ void Config::Load() parse_config_shortcut(config, parsed, "show_commander", GameFunction::ShowCommander, DCSH::show_commander); parse_config_shortcut(config, parsed, "show_daily", GameFunction::ShowDaily, DCSH::show_daily); parse_config_shortcut(config, parsed, "show_events", GameFunction::ShowEvents, DCSH::show_events); + parse_config_shortcut(config, parsed, "native_shortcut_events", GameFunction::NativeShortcutEvents, + DCSH::native_shortcut_events); parse_config_shortcut(config, parsed, "show_exocomp", GameFunction::ShowExoComp, DCSH::show_exocomp); parse_config_shortcut(config, parsed, "show_factions", GameFunction::ShowFactions, DCSH::show_factions); parse_config_shortcut(config, parsed, "show_inventory", GameFunction::ShowInventory, DCSH::show_inventory); @@ -1268,6 +1457,8 @@ void Config::Load() parse_config_shortcut(config, parsed, "show_research", GameFunction::ShowResearch, DCSH::show_research); parse_config_shortcut(config, parsed, "show_scrapyard", GameFunction::ShowScrapYard, DCSH::show_scrapyard); parse_config_shortcut(config, parsed, "show_settings", GameFunction::ShowSettings, DCSH::show_settings); + parse_config_shortcut(config, parsed, "toggle_shortcut_hints", GameFunction::ToggleShortcutHints, + DCSH::toggle_shortcut_hints); parse_config_shortcut(config, parsed, "show_officers", GameFunction::ShowOfficers, DCSH::show_officers); parse_config_shortcut(config, parsed, "show_qtrials", GameFunction::ShowQTrials, DCSH::show_qtrials); parse_config_shortcut(config, parsed, "show_refinery", GameFunction::ShowRefinery, DCSH::show_refinery); diff --git a/mods/src/config.h b/mods/src/config.h index 43f5c3913..52fead7c7 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -1,5 +1,7 @@ #pragma once +#include "patches/fleet_notification_types.h" + #include #include #include @@ -8,6 +10,8 @@ #include +#include "patches/notification_audio.h" + #if _WIN32 #include #endif @@ -116,6 +120,18 @@ enum class InstantWarpConfirmation { Jump, }; +enum class FleetLabelDetail { + Native, + Expanded, + Compact, + Threshold, +}; + +struct FleetLabelProfile { + FleetLabelDetail detail; + float zoom_threshold; +}; + // Part of UI Scale void ApplyUiShipScaleToLoadedShips(float old_multiplier, float new_multiplier); @@ -153,6 +169,8 @@ class Config final float ui_scale_viewer; float zoom; float fr_scale; + FleetLabelProfile zoom_label_player; + FleetLabelProfile zoom_label_non_player; bool allow_cursor; bool free_resize; bool adjust_scale_res; @@ -182,6 +200,8 @@ class Config final bool borderless_fullscreen; std::vector disabled_banner_types; std::vector notify_banner_types; + FleetNotificationMask notify_fleet_events = 0; + std::array(FleetNotificationKind::Count)> fleet_notification_sounds{}; int extend_chest_purchase_max; int extend_donation_max; @@ -190,13 +210,21 @@ class Config final bool disable_preview_locate; bool disable_preview_recall; bool disable_escape_exit; + int escape_exit_timer; bool disable_galaxy_chat; bool disable_veil_chat; bool disable_first_popup; bool disable_toast_banners; bool trace_audio_events; + bool disable_all_audio_events; std::vector disabled_audio_events; + NotificationSound alert_victory; + NotificationSound alert_defeat; + NotificationSound alert_armada_created; + NotificationSound alert_armada_battle_won; + NotificationSound alert_armada_battle_lost; bool auto_open_bulk_claim_flyout; + bool allow_officer_preset_reordering; bool auto_confirm_ft_upgrade; std::vector daily_bulk_claim_factions; @@ -240,6 +268,7 @@ class Config final bool installToastBannerHooks; bool installPanHooks; bool installHotkeyHooks; + bool installFleetWatchHooks; bool installFreeResizeHooks; bool installTempCrashFixes; bool installTestPatches; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index b22cad5b8..5ec4110af 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace DefaultConfig { namespace Buffs @@ -9,8 +11,16 @@ namespace Buffs namespace Audio { - constexpr const char* disabled_events = ""; - constexpr bool trace_events = false; + constexpr const char* disabled_events = ""; + constexpr bool trace_events = false; + constexpr const char* alert_victory = "none"; + constexpr const char* alert_defeat = "none"; + constexpr const char* alert_armada_created = "none"; + constexpr const char* alert_armada_battle_won = "none"; + constexpr const char* alert_armada_battle_lost = "none"; + constexpr std::array alert_fleet_events{ + "none", "none", "none", "none", "none", "none", "none", + }; } // namespace Audio namespace SystemConfig @@ -33,6 +43,10 @@ namespace Graphics { constexpr bool borderless_fullscreen = true; constexpr bool allow_cursor = true; + constexpr const char* zoom_label_player_detail = "native"; + constexpr auto zoom_label_player_threshold = 0.5; + constexpr const char* zoom_label_non_player_detail = "native"; + constexpr auto zoom_label_non_player_threshold = 0.5; constexpr auto default_system_zoom = 1750; constexpr bool free_resize = true; constexpr auto keyboard_zoom_speed = 350; @@ -84,6 +98,7 @@ namespace Patches constexpr bool giftsbulkclaimhooks = true; constexpr bool dailyfactionbulkclaimhooks = true; constexpr bool focussearch = true; + constexpr bool fleetwatchhooks = true; constexpr bool cargoformathooks = true; // on by default: cargo number precision override constexpr bool officersorthooks = true; // restore Below Deck Ability sort option constexpr bool pinnedshiphooks = true; // pin configured ships to front of fleet dock sort @@ -129,6 +144,10 @@ namespace Shortcuts constexpr const char* set_zoom_preset3 = "SHIFT-F3"; constexpr const char* set_zoom_preset4 = "SHIFT-F4"; constexpr const char* set_zoom_preset5 = "SHIFT-F5"; + + constexpr const char* native_shortcut_events = "CTRL-E"; + constexpr const char* native_shortcut_galaxy = "CTRL-G"; + constexpr const char* show_alliance = "ALT-'"; constexpr const char* show_alliance_armada = "CTRL-'"; constexpr const char* show_alliance_help = "SHIFT-'"; @@ -158,6 +177,7 @@ namespace Shortcuts constexpr const char* show_stationexterior = "SHIFT-G"; constexpr const char* show_stationinterior = "SHIFT-H"; constexpr const char* show_system = "H"; + constexpr const char* toggle_shortcut_hints = "NONE"; constexpr const char* toggle_cargo_armada = "ALT-5"; constexpr const char* toggle_cargo_default = "ALT-1"; constexpr const char* toggle_cargo_hostile = "ALT-4"; @@ -219,9 +239,13 @@ namespace UI constexpr bool auto_confirm_discovery = true; constexpr bool auto_confirm_ft_upgrade = false; constexpr bool auto_open_bulk_claim_flyout = false; + constexpr bool allow_officer_preset_reordering = false; constexpr const char* daily_bulk_claim_factions = ""; constexpr bool daily_bulk_claim_toggle_default_on = false; constexpr bool disable_escape_exit = true; + // Maximum gap between Escape presses that opens the exit prompt. + // 0 disables double-tap and preserves the existing blocked behavior. + constexpr auto escape_exit_timer = 0; constexpr bool disable_first_popup = false; constexpr bool disable_galaxy_chat = false; constexpr bool disable_move_keys = false; @@ -242,6 +266,7 @@ namespace UI constexpr const char* instant_warp_always_ask = ""; constexpr const char* pinned_ships = ""; constexpr const char* notify_banner_types = ""; + constexpr const char* notify_fleet_events = ""; constexpr auto extend_chest_purchase_max = 160; constexpr auto extend_donation_max = 80; constexpr bool extend_donation_slider = true; diff --git a/mods/src/notification_audio_mac.mm b/mods/src/notification_audio_mac.mm new file mode 100644 index 000000000..cae47a9eb --- /dev/null +++ b/mods/src/notification_audio_mac.mm @@ -0,0 +1,27 @@ +#ifdef __APPLE__ + +#include "patches/notification_audio_platform.h" + +#import + +bool notification_audio_platform_play(const uint8_t* data, size_t size) +{ + @autoreleasepool { + static NSSound* current_sound = nil; + + NSData* sound_data = [NSData dataWithBytesNoCopy:const_cast(data) length:size freeWhenDone:NO]; + NSSound* sound = [[NSSound alloc] initWithData:sound_data]; + if (!sound) return false; + + [current_sound stop]; +#if __has_feature(objc_arc) + current_sound = sound; +#else + [current_sound release]; + current_sound = sound; +#endif + return [current_sound play]; + } +} + +#endif diff --git a/mods/src/patches/fleet_notification_types.h b/mods/src/patches/fleet_notification_types.h new file mode 100644 index 000000000..269a92426 --- /dev/null +++ b/mods/src/patches/fleet_notification_types.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include + +enum class FleetNotificationKind : uint8_t { + ArrivedInSystem = 0, + ArrivedAtDestination, + StartedMining, + NodeDepleted, + Docked, + RepairComplete, + MinerOpc, + Count, +}; + +using FleetNotificationMask = uint32_t; + +struct FleetNotificationCatalogEntry { + FleetNotificationKind kind; + std::string_view config_name; + std::string_view audio_config_name; +}; + +inline constexpr std::array kFleetNotificationCatalog{ + FleetNotificationCatalogEntry{FleetNotificationKind::ArrivedInSystem, "ArrivedInSystem", + "alert_fleet_arrived_in_system"}, + FleetNotificationCatalogEntry{FleetNotificationKind::ArrivedAtDestination, "ArrivedAtDestination", + "alert_fleet_arrived_at_destination"}, + FleetNotificationCatalogEntry{FleetNotificationKind::StartedMining, "StartedMining", + "alert_fleet_started_mining"}, + FleetNotificationCatalogEntry{FleetNotificationKind::NodeDepleted, "NodeDepleted", + "alert_fleet_node_depleted"}, + FleetNotificationCatalogEntry{FleetNotificationKind::Docked, "Docked", "alert_fleet_docked"}, + FleetNotificationCatalogEntry{FleetNotificationKind::RepairComplete, "RepairComplete", + "alert_fleet_repair_complete"}, + FleetNotificationCatalogEntry{FleetNotificationKind::MinerOpc, "MinerOPC", "alert_fleet_miner_opc"}, +}; + +static_assert(kFleetNotificationCatalog.size() == static_cast(FleetNotificationKind::Count)); + +constexpr FleetNotificationMask fleet_notification_bit(FleetNotificationKind kind) +{ return FleetNotificationMask{1} << static_cast(kind); } + +constexpr std::size_t fleet_notification_index(FleetNotificationKind kind) +{ return static_cast(kind); } + +constexpr FleetNotificationMask kAllFleetNotifications = [] { + FleetNotificationMask result = 0; + for (const auto& entry : kFleetNotificationCatalog) { + result |= fleet_notification_bit(entry.kind); + } + return result; +}(); diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc new file mode 100644 index 000000000..89f1256a9 --- /dev/null +++ b/mods/src/patches/fleet_watch.cc @@ -0,0 +1,1022 @@ +#include "patches/fleet_watch.h" + +#include "config.h" +#include "errormsg.h" +#include "patches/fleet_notification_types.h" +#include "patches/notification_audio.h" +#include "patches/notification_service.h" +#include "str_utils.h" + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int kFleetSlotCount = 10; +constexpr int64_t kFollowThroughIntervalMs = 250; +constexpr int64_t kFollowThroughBackoffMs = 5'000; +constexpr int64_t kFollowThroughFastPeriodMs = 30'000; +constexpr int64_t kFollowThroughLifetimeMs = 24LL * 60 * 60 * 1'000; +constexpr int64_t kMiningWatchIntervalMs = 5'000; +constexpr int64_t kSeedFastIntervalMs = 1'000; +constexpr int64_t kSeedBackoffIntervalMs = 5'000; +constexpr int64_t kSeedFastPeriodMs = 30'000; +constexpr int64_t kSeedLifetimeMs = 120'000; +constexpr int64_t kSeedStabilizationMs = 5'000; +constexpr int64_t kSeedMaxStabilizationMs = 30'000; +constexpr int64_t kManagerProbeIntervalMs = 250; +constexpr int64_t kDuplicateSuppressionMs = 2'000; + +constexpr std::size_t kFleetNotificationCount = static_cast(FleetNotificationKind::Count); +constexpr uint16_t kAllFleetSlotsMask = (1U << kFleetSlotCount) - 1; + +struct FleetSnapshot { + uint64_t fleet_id = 0; + int64_t resource_id = 0; + FleetState state = FleetState::Unknown; + bool occupied = false; + bool opc_known = false; + bool opc = false; + int64_t follow_through_started_ms = 0; +}; + +struct FleetNotificationStamp { + uint64_t fleet_id = 0; + int64_t last_emitted_ms = 0; + bool occupied = false; +}; + +using TransitionPredicate = bool (*)(FleetState, FleetState); + +struct FleetTransitionRule { + FleetNotificationKind kind; + TransitionPredicate matches; +}; + +std::array s_slots{}; +std::array, kFleetSlotCount> s_last_emitted_ms{}; +std::array s_node_depleted_stamps{}; +FleetNotificationMask s_enabled_notifications = 0; +bool s_state_observation_enabled = false; +bool s_seed_pending = false; +bool s_seed_has_observation = false; +bool s_seed_manager_backed = false; +bool s_seed_ready_candidate = false; +bool s_seed_candidate_forced = false; +bool s_seed_candidate_manager_backed = false; +int64_t s_last_follow_through_scan_ms = 0; +int64_t s_last_mining_scan_ms = 0; +int64_t s_last_seed_attempt_ms = 0; +int64_t s_seed_started_ms = 0; +int64_t s_seed_first_observed_ms = 0; +int64_t s_seed_last_change_ms = 0; +int64_t s_last_manager_probe_ms = 0; +int s_seed_candidate_count = 0; +int s_mining_watch_count = 0; +int s_follow_through_watch_count = 0; +int s_manager_probe_slot = 0; +uint16_t s_widget_opc_sample_mask = 0; + +int64_t now_milliseconds(); + +void invalidate_seed_observation() +{ + s_slots = {}; + s_last_emitted_ms = {}; + s_seed_has_observation = false; + s_seed_manager_backed = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_last_manager_probe_ms = 0; + s_seed_candidate_count = 0; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; + s_manager_probe_slot = 0; + s_widget_opc_sample_mask = 0; +} + +void restart_seed_stabilization(int slot_index, uint64_t fleet_id, bool preserve_snapshots = false) +{ + const auto now_ms = now_milliseconds(); + if (!preserve_snapshots) { + s_slots = {}; + s_last_emitted_ms = {}; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; + } + s_seed_pending = true; + s_seed_has_observation = false; + s_seed_manager_backed = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_last_seed_attempt_ms = 0; + s_seed_started_ms = now_ms; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_last_manager_probe_ms = 0; + s_seed_candidate_count = 0; + s_manager_probe_slot = 0; + s_widget_opc_sample_mask = 0; + spdlog::debug( + "[FleetWatch] fleet topology changed; restarting initial-state stabilization slot={} fleet={} preserve={}", + slot_index, fleet_id, preserve_snapshots); +} + +int64_t now_milliseconds() +{ + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +bool notification_enabled(FleetNotificationKind kind) +{ return (s_enabled_notifications & fleet_notification_bit(kind)) != 0; } + +constexpr FleetNotificationMask fleet_events_to_observe(FleetNotificationMask system_notifications, + FleetNotificationMask audible_notifications, + bool system_notifications_supported) +{ return audible_notifications | (system_notifications_supported ? system_notifications : 0); } + +constexpr auto kArrivalNotification = fleet_notification_bit(FleetNotificationKind::ArrivedInSystem); +static_assert(fleet_events_to_observe(0, kArrivalNotification, false) == kArrivalNotification); +static_assert(fleet_events_to_observe(kArrivalNotification, 0, true) == kArrivalNotification); +static_assert(fleet_events_to_observe(kArrivalNotification, 0, false) == 0); + +bool allow_notification(int slot_index, FleetNotificationKind kind) +{ + if (slot_index < 0 || slot_index >= kFleetSlotCount) { + return true; + } + + const auto now = now_milliseconds(); + auto& last = s_last_emitted_ms[slot_index][static_cast(kind)]; + if (last != 0 && now - last < kDuplicateSuppressionMs) { + return false; + } + last = now; + return true; +} + +constexpr bool state_requires_follow_through(FleetNotificationMask enabled, FleetState state) +{ + constexpr auto arrival_events = fleet_notification_bit(FleetNotificationKind::ArrivedInSystem) + | fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination); + constexpr auto post_warp_events = arrival_events | fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked) + | fleet_notification_bit(FleetNotificationKind::MinerOpc); + constexpr auto post_impulse_events = fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination) + | fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked) + | fleet_notification_bit(FleetNotificationKind::MinerOpc); + constexpr auto post_activity_events = fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked) + | fleet_notification_bit(FleetNotificationKind::MinerOpc); + + switch (state) { + case FleetState::WarpCharging: + case FleetState::Warping: + return (enabled & post_warp_events) != 0; + case FleetState::Impulsing: + return (enabled & post_impulse_events) != 0; + case FleetState::Battling: + case FleetState::Capturing: + return (enabled & post_activity_events) != 0; + case FleetState::Repairing: + return (enabled & fleet_notification_bit(FleetNotificationKind::RepairComplete)) != 0; + default: + return false; + } +} + +bool state_requires_enabled_follow_through(FleetState state) +{ return state_requires_follow_through(s_enabled_notifications, state); } + +static_assert(state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::ArrivedInSystem), + FleetState::Warping)); +static_assert(!state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::ArrivedInSystem), + FleetState::Impulsing)); +static_assert(state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination), + FleetState::Impulsing)); +static_assert(state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::StartedMining), + FleetState::Battling)); +static_assert(state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::MinerOpc), + FleetState::Battling)); +static_assert(!state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::ArrivedInSystem), + FleetState::Battling)); +static_assert(state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::RepairComplete), + FleetState::Repairing)); + +constexpr bool state_is_destination(FleetState state) +{ + switch (state) { + case FleetState::IdleInSpace: + case FleetState::CanRecall: + case FleetState::CanEngage: + case FleetState::CanLocate: + case FleetState::Deployed: + case FleetState::Capturing: + return true; + default: + return false; + } +} + +constexpr bool state_can_dock_from_space(FleetState state) +{ + switch (state) { + case FleetState::IdleInSpace: + case FleetState::Mining: + case FleetState::Battling: + case FleetState::WarpCharging: + case FleetState::Warping: + case FleetState::Impulsing: + case FleetState::Capturing: + case FleetState::CanRecall: + case FleetState::CanEngage: + case FleetState::Deployed: + case FleetState::CanLocate: + return true; + default: + return false; + } +} + +constexpr bool arrived_in_system(FleetState old_state, FleetState new_state) +{ return old_state == FleetState::Warping && new_state == FleetState::Impulsing; } + +constexpr bool arrived_at_destination(FleetState old_state, FleetState new_state) +{ return old_state == FleetState::Impulsing && state_is_destination(new_state); } + +constexpr bool started_mining(FleetState old_state, FleetState new_state) +{ return old_state != FleetState::Mining && new_state == FleetState::Mining; } + +constexpr bool docked(FleetState old_state, FleetState new_state) +{ + return old_state != FleetState::Repairing && new_state == FleetState::Docked && state_can_dock_from_space(old_state); +} + +constexpr bool repair_complete(FleetState old_state, FleetState new_state) +{ return old_state == FleetState::Repairing && new_state == FleetState::Docked; } + +constexpr std::array kTransitionRules{ + FleetTransitionRule{FleetNotificationKind::ArrivedInSystem, arrived_in_system}, + FleetTransitionRule{FleetNotificationKind::ArrivedAtDestination, arrived_at_destination}, + FleetTransitionRule{FleetNotificationKind::StartedMining, started_mining}, + FleetTransitionRule{FleetNotificationKind::Docked, docked}, + FleetTransitionRule{FleetNotificationKind::RepairComplete, repair_complete}, +}; + +static_assert(arrived_in_system(FleetState::Warping, FleetState::Impulsing)); +static_assert(arrived_at_destination(FleetState::Impulsing, FleetState::IdleInSpace)); +static_assert(started_mining(FleetState::IdleInSpace, FleetState::Mining)); +static_assert(docked(FleetState::Impulsing, FleetState::Docked)); +static_assert(!docked(FleetState::Repairing, FleetState::Docked)); +static_assert(repair_complete(FleetState::Repairing, FleetState::Docked)); + +std::string normalize_name(std::string name) +{ + constexpr std::string_view live_suffix = "_LIVE"; + if (name.ends_with(live_suffix)) { + name.erase(name.size() - live_suffix.size()); + } + + for (auto& character : name) { + if (character == '_') { + character = ' '; + } + } + return name; +} + +std::string ship_name(FleetPlayerData* fleet) +{ + auto* hull = fleet ? fleet->Hull : nullptr; + return hull && hull->Name ? normalize_name(to_string(hull->Name)) : std::string{}; +} + +std::string resource_name(int64_t resource_id) +{ + if (resource_id == 0) { + return {}; + } + + auto* manager = SpecManager::Instance(); + auto* spec = manager ? manager->GetResourceSpec(resource_id) : nullptr; + auto name = spec && spec->Name ? normalize_name(to_string(spec->Name)) : std::string{}; + constexpr std::string_view resource_prefix = "Resource "; + if (name.starts_with(resource_prefix)) { + name.erase(0, resource_prefix.size()); + } + return name; +} + +std::string notification_subject(FleetPlayerData* fleet) +{ + auto name = ship_name(fleet); + return name.empty() ? "fleet" : name; +} + +std::string cargo_text(FleetPlayerData* fleet) +{ + const auto fill = fleet ? fleet->CargoResourceFillLevel : -1.0f; + if (!std::isfinite(fill) || fill < 0.0f) { + return {}; + } + const auto percent = static_cast(std::lround(std::clamp(fill, 0.0f, 1.0f) * 100.0f)); + return " Current cargo: " + std::to_string(percent) + "%."; +} + +FleetPlayerData* fleet_for_slot(int slot_index) +{ + auto* manager = FleetsManager::Instance(); + return manager && manager->HasFleetService() && slot_index >= 0 && slot_index < kFleetSlotCount + ? manager->GetFleetPlayerData(slot_index) + : nullptr; +} + +FleetPlayerData* fleet_for_snapshot(int slot_index, uint64_t fleet_id) +{ + auto* fleet = fleet_for_slot(slot_index); + return fleet && fleet->Id == fleet_id ? fleet : nullptr; +} + +void emit_notification(FleetNotificationKind kind, std::string_view title, std::string body) +{ + if ((Config::Get().notify_fleet_events & fleet_notification_bit(kind)) != 0) { + notification_emit(title, body); + } + + const auto index = fleet_notification_index(kind); + const auto sound = Config::Get().fleet_notification_sounds[index]; + if (sound == NotificationSound::None) { + return; + } + + spdlog::info("[FleetWatch] audio event={} sound={}", kFleetNotificationCatalog[index].config_name, + notification_sound_name(sound)); + notification_audio_play(sound); +} + +void emit_transition(FleetNotificationKind kind, FleetPlayerData* fleet, int64_t resource_id) +{ + const auto subject = notification_subject(fleet); + switch (kind) { + case FleetNotificationKind::ArrivedInSystem: + emit_notification(kind, "Fleet Arrived", "Your " + subject + " has arrived in-system"); + break; + case FleetNotificationKind::ArrivedAtDestination: + emit_notification(kind, "Fleet Arrived", "Your " + subject + " has reached its destination"); + break; + case FleetNotificationKind::StartedMining: { + const auto resource = resource_name(resource_id); + const auto detail = resource.empty() ? std::string{} : " " + resource; + emit_notification(kind, "Fleet Mining", "Your " + subject + " started mining" + detail); + break; + } + case FleetNotificationKind::Docked: + emit_notification(kind, "Fleet Docked", "Your " + subject + " docked"); + break; + case FleetNotificationKind::RepairComplete: + emit_notification(kind, "Repair Complete", "Your " + subject + " finished repairs"); + break; + default: + break; + } +} + +constexpr bool cargo_is_opc(double current_value, double protected_limit) +{ return current_value > protected_limit; } + +static_assert(!cargo_is_opc(99.0, 100.0)); +static_assert(!cargo_is_opc(100.0, 100.0)); +static_assert(cargo_is_opc(101.0, 100.0)); + +bool read_opc(FleetPlayerData* fleet, bool& known) +{ + known = false; + auto* cargo_hold = fleet ? fleet->CargoHoldData : nullptr; + auto* unprotected = cargo_hold ? cargo_hold->UnprotectedCargoProgress : nullptr; + if (!unprotected) { + return false; + } + + const auto current_value = unprotected->CurrentValue; + const auto protected_limit = unprotected->MinValue; + if (!std::isfinite(current_value) || !std::isfinite(protected_limit)) { + return false; + } + + known = true; + return cargo_is_opc(current_value, protected_limit); +} + +bool allow_node_depleted_notification(uint64_t fleet_id) +{ + const auto now = now_milliseconds(); + FleetNotificationStamp* oldest = &s_node_depleted_stamps.front(); + for (auto& stamp : s_node_depleted_stamps) { + if (stamp.occupied && stamp.fleet_id == fleet_id) { + if (now - stamp.last_emitted_ms < kDuplicateSuppressionMs) { + return false; + } + stamp.last_emitted_ms = now; + return true; + } + if (!stamp.occupied) { + oldest = &stamp; + break; + } + if (stamp.last_emitted_ms < oldest->last_emitted_ms) { + oldest = &stamp; + } + } + + *oldest = FleetNotificationStamp{fleet_id, now, true}; + return true; +} + +int resolve_slot(FleetPlayerData* fleet, int requested_slot) +{ + if (!fleet) { + return -1; + } + if (requested_slot >= 0 && requested_slot < kFleetSlotCount) { + return requested_slot; + } + + for (int index = 0; index < kFleetSlotCount; ++index) { + if (s_slots[index].occupied && s_slots[index].fleet_id == fleet->Id) { + return index; + } + } + + const auto model_slot = fleet->Index; + if (model_slot >= 0 && model_slot < kFleetSlotCount) { + return model_slot; + } + return -1; +} + +void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifications, bool sample_opc, + std::string_view source) +{ + const bool widget_observation = requested_slot < 0; + const auto slot_index = resolve_slot(fleet, requested_slot); + if (!fleet || slot_index < 0) { + return; + } + + auto& previous = s_slots[slot_index]; + const auto fleet_id = fleet->Id; + const auto current = fleet->CurrentState; + const bool same_fleet = previous.occupied && previous.fleet_id == fleet_id; + if (!same_fleet && (!s_seed_pending || s_seed_has_observation)) { + const bool preserve_snapshots = widget_observation && !s_seed_manager_backed; + restart_seed_stabilization(slot_index, fleet_id, preserve_snapshots); + } + allow_notifications = allow_notifications && !s_seed_pending; + const bool resource_context_enabled = notification_enabled(FleetNotificationKind::StartedMining) + || notification_enabled(FleetNotificationKind::NodeDepleted); + auto* mining_data = resource_context_enabled && current == FleetState::Mining ? fleet->MiningData : nullptr; + const auto resource_id = mining_data ? mining_data->ResourceId : (same_fleet ? previous.resource_id : 0); + const bool miner_opc_enabled = notification_enabled(FleetNotificationKind::MinerOpc); + bool opc_known = same_fleet && previous.state == FleetState::Mining ? previous.opc_known : false; + bool opc = same_fleet && previous.state == FleetState::Mining ? previous.opc : false; + if (miner_opc_enabled && current == FleetState::Mining + && (sample_opc || !same_fleet || previous.state != FleetState::Mining)) { + bool sampled_known = false; + const bool sampled_opc = read_opc(fleet, sampled_known); + if (sampled_known) { + opc_known = true; + opc = sampled_opc; + } + } else if (current != FleetState::Mining) { + opc_known = false; + opc = false; + } + + const bool previously_watched = + miner_opc_enabled && previous.occupied && previous.state == FleetState::Mining && !previous.opc; + const bool previously_following = previous.occupied && previous.follow_through_started_ms != 0; + const bool seed_state_changed = !same_fleet || previous.state != current || previous.resource_id != resource_id + || previous.opc_known != opc_known || previous.opc != opc; + if (!same_fleet) { + s_last_emitted_ms[slot_index] = {}; + } + if (same_fleet && allow_notifications && previous.state != current) { + for (const auto& rule : kTransitionRules) { + if (notification_enabled(rule.kind) && rule.matches(previous.state, current)) { + if (!allow_notification(slot_index, rule.kind)) { + spdlog::debug("[FleetWatch] source={} suppressed duplicate event={} slot={} fleet={}", source, + kFleetNotificationCatalog[static_cast(rule.kind)].config_name, slot_index, + fleet_id); + continue; + } + spdlog::debug("[FleetWatch] source={} event={} slot={} fleet={} oldState={} newState={}", source, + kFleetNotificationCatalog[static_cast(rule.kind)].config_name, slot_index, fleet_id, + static_cast(previous.state), static_cast(current)); + emit_transition(rule.kind, fleet, resource_id); + } + } + } + + if (same_fleet && allow_notifications && miner_opc_enabled && previous.state == FleetState::Mining + && current == FleetState::Mining && previous.opc_known && opc_known && !previous.opc && opc) { + if (!allow_notification(slot_index, FleetNotificationKind::MinerOpc)) { + spdlog::debug("[FleetWatch] source={} suppressed duplicate event=MinerOPC slot={} fleet={}", source, slot_index, + fleet_id); + } else { + const auto subject = notification_subject(fleet); + spdlog::debug("[FleetWatch] source={} event=MinerOPC slot={} fleet={}", source, slot_index, fleet_id); + emit_notification(FleetNotificationKind::MinerOpc, "Miner Is OPC", + "Your " + subject + " is now over protected cargo." + cargo_text(fleet)); + } + } + + int64_t follow_through_started_ms = 0; + if (state_requires_enabled_follow_through(current)) { + if (same_fleet && state_requires_enabled_follow_through(previous.state)) { + follow_through_started_ms = previous.follow_through_started_ms; + } else { + follow_through_started_ms = now_milliseconds(); + s_last_follow_through_scan_ms = 0; + spdlog::debug("[FleetWatch] follow-through scan armed source={} slot={} fleet={}", source, slot_index, fleet_id); + } + } + + previous = FleetSnapshot{fleet_id, resource_id, current, true, opc_known, opc, follow_through_started_ms}; + const bool currently_watched = miner_opc_enabled && current == FleetState::Mining && !opc; + if (previously_watched != currently_watched) { + if (currently_watched && s_mining_watch_count == 0) { + s_last_mining_scan_ms = now_milliseconds(); + } + s_mining_watch_count += currently_watched ? 1 : -1; + } + const bool currently_following = follow_through_started_ms != 0; + if (previously_following != currently_following) { + s_follow_through_watch_count += currently_following ? 1 : -1; + } + if (s_seed_pending && s_seed_has_observation && seed_state_changed) { + s_seed_last_change_ms = now_milliseconds(); + } + if (s_seed_pending && seed_state_changed) { + s_seed_ready_candidate = false; + } +} + +int occupied_snapshot_count() +{ + return static_cast( + std::count_if(s_slots.begin(), s_slots.end(), [](const auto& slot) { return slot.occupied; })); +} + +bool manager_baseline_matches(int& changed_slot, uint64_t& changed_fleet) +{ + auto* manager = FleetsManager::Instance(); + if (!manager || !manager->HasFleetService()) { + changed_slot = -1; + changed_fleet = 0; + return false; + } + + const int index = s_manager_probe_slot; + auto* fleet = manager->GetFleetPlayerData(index); + changed_slot = index; + changed_fleet = fleet ? fleet->Id : 0; + s_manager_probe_slot = (index + 1) % kFleetSlotCount; + const bool now_occupied = fleet != nullptr; + return s_slots[index].occupied == now_occupied && (!now_occupied || s_slots[index].fleet_id == changed_fleet); +} + +bool manager_slot_matches(int slot_index, uint64_t fleet_id) +{ + auto* manager = FleetsManager::Instance(); + auto* fleet = manager && manager->HasFleetService() && slot_index >= 0 && slot_index < kFleetSlotCount + ? manager->GetFleetPlayerData(slot_index) + : nullptr; + return fleet && fleet->Id == fleet_id; +} + +void begin_seed_observation(int observed_count, bool manager_backed, int64_t now_ms, std::string_view source) +{ + s_seed_has_observation = true; + s_seed_manager_backed = manager_backed; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_seed_first_observed_ms = now_ms; + s_seed_last_change_ms = now_ms; + s_seed_candidate_count = 0; + spdlog::debug("[FleetWatch] observed {} fleet slots via {}; waiting for stable initial state", observed_count, + source); +} + +struct ScanResult { + int observed_count = 0; + int first_changed_slot = -1; + uint64_t first_changed_fleet = 0; + bool topology_changed = false; +}; + +ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_view source) +{ + ScanResult result; + auto* manager = FleetsManager::Instance(); + if (!manager || !manager->HasFleetService()) { + if (allow_notifications && !s_seed_pending && s_seed_manager_backed) { + restart_seed_stabilization(-1, 0); + } + return result; + } + std::array fleets{}; + for (int index = 0; index < kFleetSlotCount; ++index) { + fleets[index] = manager->GetFleetPlayerData(index); + if (fleets[index]) { + ++result.observed_count; + } + const bool now_occupied = fleets[index] != nullptr; + const auto fleet_id = now_occupied ? fleets[index]->Id : 0; + const bool changed = + s_slots[index].occupied != now_occupied || (now_occupied && s_slots[index].fleet_id != fleet_id); + if (changed && !result.topology_changed) { + result.topology_changed = true; + result.first_changed_slot = index; + result.first_changed_fleet = fleet_id; + } + } + + if (result.observed_count == 0 && (s_seed_pending || !s_seed_manager_backed)) { + result.topology_changed = false; + result.first_changed_slot = -1; + result.first_changed_fleet = 0; + return result; + } + + const bool observed_baseline = s_seed_has_observation || !s_seed_pending; + const bool topology_requires_restart = result.topology_changed && observed_baseline; + const bool source_requires_restart = !s_seed_manager_backed && observed_baseline; + if (topology_requires_restart || source_requires_restart) { + if (result.first_changed_slot < 0) { + for (int index = 0; index < kFleetSlotCount; ++index) { + if (fleets[index]) { + result.first_changed_slot = index; + result.first_changed_fleet = fleets[index]->Id; + break; + } + } + } + restart_seed_stabilization(result.first_changed_slot, result.first_changed_fleet); + result.topology_changed = true; + allow_notifications = false; + } + + for (int index = 0; index < kFleetSlotCount; ++index) { + auto* fleet = fleets[index]; + if (!fleet) { + if (s_seed_pending && s_seed_has_observation && s_slots[index].occupied) { + s_seed_last_change_ms = now_milliseconds(); + s_seed_ready_candidate = false; + } + if (notification_enabled(FleetNotificationKind::MinerOpc) && s_slots[index].occupied + && s_slots[index].state == FleetState::Mining && !s_slots[index].opc) { + --s_mining_watch_count; + } + if (s_slots[index].occupied && s_slots[index].follow_through_started_ms != 0) { + --s_follow_through_watch_count; + } + s_slots[index] = {}; + s_last_emitted_ms[index] = {}; + continue; + } + + observe_fleet(fleet, index, allow_notifications, sample_opc, source); + } + if (result.observed_count > 0) { + s_seed_manager_backed = true; + s_widget_opc_sample_mask = 0; + } + return result; +} +} // namespace + +bool fleet_watch_uses_state_observation() +{ return s_state_observation_enabled; } + +bool fleet_watch_uses_node_depleted_hook() +{ return notification_enabled(FleetNotificationKind::NodeDepleted); } + +void fleet_watch_init() +{ + constexpr auto follow_through_events = fleet_notification_bit(FleetNotificationKind::ArrivedInSystem) + | fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination) + | fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked) + | fleet_notification_bit(FleetNotificationKind::RepairComplete); + constexpr auto state_events = follow_through_events | fleet_notification_bit(FleetNotificationKind::MinerOpc); + FleetNotificationMask audible_notifications = 0; + for (const auto& entry : kFleetNotificationCatalog) { + if (Config::Get().fleet_notification_sounds[fleet_notification_index(entry.kind)] != NotificationSound::None) { + audible_notifications |= fleet_notification_bit(entry.kind); + } + } +#if _WIN32 + constexpr bool system_notifications_supported = true; +#else + constexpr bool system_notifications_supported = false; +#endif + s_enabled_notifications = + fleet_events_to_observe(Config::Get().notify_fleet_events, audible_notifications, system_notifications_supported); + s_state_observation_enabled = (s_enabled_notifications & state_events) != 0; + s_slots = {}; + s_last_emitted_ms = {}; + s_node_depleted_stamps = {}; + s_seed_pending = fleet_watch_uses_state_observation(); + s_seed_has_observation = false; + s_seed_manager_backed = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_last_follow_through_scan_ms = 0; + s_last_mining_scan_ms = 0; + s_last_seed_attempt_ms = 0; + s_seed_started_ms = 0; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_last_manager_probe_ms = 0; + s_seed_candidate_count = 0; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; + s_manager_probe_slot = 0; + s_widget_opc_sample_mask = 0; +} + +void fleet_watch_observe_widget(FleetPlayerData* fleet) +{ + if (!fleet || !fleet_watch_uses_state_observation()) { + return; + } + + const auto slot_index = resolve_slot(fleet, -1); + const bool sample_opc = slot_index >= 0 && (s_widget_opc_sample_mask & (1U << slot_index)) != 0; + const bool transition_candidate = slot_index >= 0 && s_slots[slot_index].occupied + && s_slots[slot_index].fleet_id == fleet->Id + && s_slots[slot_index].state != fleet->CurrentState; + if (!s_seed_pending && s_seed_manager_backed && (transition_candidate || sample_opc) + && !manager_slot_matches(slot_index, fleet->Id)) { + restart_seed_stabilization(slot_index, fleet->Id); + } + observe_fleet(fleet, -1, !s_seed_pending, sample_opc, "fleet-state-widget"); + if (sample_opc) { + s_widget_opc_sample_mask &= ~(1U << slot_index); + } +} + +void fleet_watch_observe_node_depleted(int64_t fleet_id) +{ + if (!fleet_watch_uses_node_depleted_hook()) { + return; + } + + const auto fleet_key = static_cast(fleet_id); + if (!allow_node_depleted_notification(fleet_key)) { + spdlog::debug("[FleetWatch] suppressed duplicate event=NodeDepleted fleet={}", fleet_key); + return; + } + + for (int index = 0; index < kFleetSlotCount; ++index) { + const auto& snapshot = s_slots[index]; + if (!snapshot.occupied || snapshot.fleet_id != fleet_key) { + continue; + } + + auto* fleet = fleet_for_snapshot(index, snapshot.fleet_id); + const auto subject = notification_subject(fleet); + const auto resource = resource_name(snapshot.resource_id); + const auto detail = resource.empty() ? std::string{" its node"} : " its " + resource + " node"; + emit_notification(FleetNotificationKind::NodeDepleted, "Node Depleted", + subject + " depleted" + detail + "." + cargo_text(fleet)); + return; + } + + for (int index = 0; index < kFleetSlotCount; ++index) { + auto* fleet = fleet_for_slot(index); + if (!fleet || fleet->Id != fleet_key) { + continue; + } + + auto* mining_data = fleet->MiningData; + const auto resource = resource_name(mining_data ? mining_data->ResourceId : 0); + const auto detail = resource.empty() ? std::string{" its node"} : " its " + resource + " node"; + emit_notification(FleetNotificationKind::NodeDepleted, "Node Depleted", + notification_subject(fleet) + " depleted" + detail + "." + cargo_text(fleet)); + return; + } + + emit_notification(FleetNotificationKind::NodeDepleted, "Node Depleted", "A mining fleet depleted its node."); +} + +void fleet_watch_tick() +{ + if (!fleet_watch_uses_state_observation()) { + return; + } + if (!s_seed_pending && s_seed_manager_backed) { + const auto now_ms = now_milliseconds(); + if (s_last_manager_probe_ms == 0 || now_ms - s_last_manager_probe_ms >= kManagerProbeIntervalMs) { + s_last_manager_probe_ms = now_ms; + int changed_slot = -1; + uint64_t changed_fleet = 0; + if (!manager_baseline_matches(changed_slot, changed_fleet)) { + restart_seed_stabilization(changed_slot, changed_fleet); + return; + } + } + } + if (!s_seed_pending && s_follow_through_watch_count == 0 && s_mining_watch_count == 0) { + return; + } + + const auto now_ms = now_milliseconds(); + if (s_seed_pending) { + if (s_seed_started_ms == 0) { + s_seed_started_ms = now_ms; + } + const auto seed_lifetime = now_ms - s_seed_started_ms; + if (seed_lifetime >= kSeedLifetimeMs && !s_seed_has_observation) { + s_seed_pending = false; + s_seed_manager_backed = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_slots = {}; + s_last_emitted_ms = {}; + s_last_follow_through_scan_ms = 0; + s_last_mining_scan_ms = 0; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; + spdlog::warn("[FleetWatch] initial seed stopped after {}ms without a stable fleet baseline", kSeedLifetimeMs); + } else { + const auto seed_interval = + s_seed_has_observation || seed_lifetime < kSeedFastPeriodMs ? kSeedFastIntervalMs : kSeedBackoffIntervalMs; + if (s_last_seed_attempt_ms == 0 || now_ms - s_last_seed_attempt_ms >= seed_interval) { + s_last_seed_attempt_ms = now_ms; + const auto seed = scan_fleets(false, true, "initial-seed"); + const bool manager_backed = seed.observed_count > 0; + const bool widget_backed = !s_seed_has_observation || !s_seed_manager_backed; + const int observed_count = + manager_backed ? seed.observed_count : (widget_backed ? occupied_snapshot_count() : 0); + if (observed_count == 0) { + invalidate_seed_observation(); + if (seed_lifetime >= kSeedLifetimeMs) { + s_seed_pending = false; + s_last_follow_through_scan_ms = 0; + s_last_mining_scan_ms = 0; + spdlog::warn("[FleetWatch] initial seed stopped after {}ms without an available fleet baseline", + kSeedLifetimeMs); + } + } else { + if (!s_seed_has_observation || s_seed_manager_backed != manager_backed) { + begin_seed_observation(observed_count, manager_backed, now_ms, + manager_backed ? "fleet-manager" : "fleet-state-widget"); + } else { + const bool quiet = now_ms - s_seed_last_change_ms >= kSeedStabilizationMs; + const bool forced = now_ms - s_seed_first_observed_ms >= kSeedMaxStabilizationMs; + if (quiet || forced) { + s_seed_ready_candidate = true; + s_seed_candidate_forced = forced && !quiet; + s_seed_candidate_manager_backed = manager_backed; + s_seed_candidate_count = observed_count; + } + } + } + } + } + } + + if (s_seed_pending) { + return; + } + + bool follow_through_due = false; + bool follow_through_fast = false; + for (int index = 0; index < kFleetSlotCount; ++index) { + auto& slot = s_slots[index]; + if (!slot.occupied || slot.follow_through_started_ms == 0) { + continue; + } + const auto lifetime = now_ms - slot.follow_through_started_ms; + if (lifetime >= kFollowThroughLifetimeMs) { + slot.follow_through_started_ms = 0; + --s_follow_through_watch_count; + spdlog::warn("[FleetWatch] follow-through scan expired slot={} fleet={} after 24 hours", index, slot.fleet_id); + } else if (lifetime < kFollowThroughFastPeriodMs) { + follow_through_fast = true; + } + } + if (s_follow_through_watch_count > 0) { + const auto interval = follow_through_fast ? kFollowThroughIntervalMs : kFollowThroughBackoffMs; + follow_through_due = s_last_follow_through_scan_ms == 0 || now_ms - s_last_follow_through_scan_ms >= interval; + } + + const bool mining_due = s_mining_watch_count > 0 + && (s_last_mining_scan_ms == 0 || now_ms - s_last_mining_scan_ms >= kMiningWatchIntervalMs); + if (!follow_through_due && !mining_due) { + return; + } + + const bool had_follow_through = s_follow_through_watch_count > 0; + const auto scan = scan_fleets(true, mining_due, follow_through_due ? "follow-through" : "mining-watch"); + if (follow_through_due) { + s_last_follow_through_scan_ms = now_ms; + if (had_follow_through && s_follow_through_watch_count == 0) { + spdlog::debug("[FleetWatch] follow-through scan settled"); + } + } + if (mining_due) { + if (scan.observed_count == 0 && !s_seed_manager_backed) { + s_widget_opc_sample_mask = kAllFleetSlotsMask; + } + s_last_mining_scan_ms = now_ms; + } +} + +void fleet_watch_after_update() +{ + if (!s_seed_pending) { + return; + } + if (!s_seed_has_observation) { + const auto observed_count = occupied_snapshot_count(); + if (observed_count > 0) { + begin_seed_observation(observed_count, s_seed_manager_backed, now_milliseconds(), + s_seed_manager_backed ? "fleet-manager" : "fleet-state-widget"); + } + return; + } + if (!s_seed_ready_candidate && !s_seed_candidate_forced) { + return; + } + + const bool forced = s_seed_candidate_forced; + int observed_count = occupied_snapshot_count(); + const auto seed = scan_fleets(false, true, "seed-finalize"); + if (seed.topology_changed) { + return; + } + if (seed.observed_count > 0) { + observed_count = seed.observed_count; + if (!forced && !s_seed_ready_candidate) { + return; + } + } else if (s_seed_candidate_manager_backed) { + invalidate_seed_observation(); + return; + } else { + if (observed_count == 0) { + s_seed_has_observation = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; + return; + } + if (!forced && !s_seed_ready_candidate) { + return; + } + } + + const auto now_ms = now_milliseconds(); + s_seed_pending = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_count = observed_count; + s_last_follow_through_scan_ms = s_follow_through_watch_count > 0 ? now_ms : 0; + s_last_mining_scan_ms = s_mining_watch_count > 0 ? now_ms : 0; + if (forced) { + spdlog::warn("[FleetWatch] seeded {} fleet slots after {}ms without a quiet window", s_seed_candidate_count, + kSeedMaxStabilizationMs); + } else { + spdlog::debug("[FleetWatch] seeded {} stable fleet slots after {}ms quiet window", s_seed_candidate_count, + kSeedStabilizationMs); + } +} diff --git a/mods/src/patches/fleet_watch.h b/mods/src/patches/fleet_watch.h new file mode 100644 index 000000000..49d43e168 --- /dev/null +++ b/mods/src/patches/fleet_watch.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +struct FleetPlayerData; + +void fleet_watch_init(); +void fleet_watch_tick(); +void fleet_watch_after_update(); +void fleet_watch_observe_widget(FleetPlayerData* fleet); +void fleet_watch_observe_node_depleted(int64_t fleet_id); + +bool fleet_watch_uses_state_observation(); +bool fleet_watch_uses_node_depleted_hook(); diff --git a/mods/src/patches/gamefunctions.h b/mods/src/patches/gamefunctions.h index bd4b0fb50..fb9680112 100644 --- a/mods/src/patches/gamefunctions.h +++ b/mods/src/patches/gamefunctions.h @@ -40,12 +40,15 @@ enum GameFunction { ShowStationInterior, ShoWStationExterior, ShowGalaxy, + NativeShortcutGalaxy, ShowSystem, ShowChat, ShowChatSide1, ShowChatSide2, ShowEvents, + NativeShortcutEvents, ShowSettings, + ToggleShortcutHints, ZoomPreset1, ZoomPreset2, ZoomPreset3, diff --git a/mods/src/patches/notification_audio.cc b/mods/src/patches/notification_audio.cc new file mode 100644 index 000000000..64cdd0368 --- /dev/null +++ b/mods/src/patches/notification_audio.cc @@ -0,0 +1,239 @@ +#include "patches/notification_audio.h" + +#include "patches/notification_audio_platform.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if _WIN32 +#include +#include +#endif + +namespace +{ +struct ToneSegment { + double frequency_hz; + int duration_ms; +}; + +constexpr int kSampleRate = 44100; +constexpr double kTwoPi = 6.28318530717958647692; +constexpr double kAmplitude = 0.26; + +constexpr std::array kDefaultPattern{{{740.0, 70}, {0.0, 22}, {880.0, 85}, {0.0, 18}}}; +constexpr std::array kInfoPattern{{{659.0, 80}, {0.0, 24}, {880.0, 110}}}; +constexpr std::array kSuccessPattern{{{587.0, 70}, {0.0, 18}, {740.0, 70}, {0.0, 18}, {988.0, 120}}}; +constexpr std::array kWarningPattern{{{622.0, 90}, {0.0, 36}, {466.0, 110}, {0.0, 28}, {466.0, 90}}}; +constexpr std::array kAlarmPattern{{{880.0, 90}, {0.0, 42}, {880.0, 90}, {0.0, 42}, {698.0, 160}}}; +constexpr std::array kArrivalPattern{{{523.0, 65}, {0.0, 18}, {659.0, 70}, {0.0, 18}, {1046.0, 125}}}; +constexpr std::array kSoftPattern{{{523.0, 90}, {0.0, 26}, {659.0, 110}}}; +constexpr std::array kPingPattern{{{1046.0, 95}}}; +constexpr std::array kRepairPattern{{{440.0, 70}, {0.0, 18}, {554.0, 70}, {0.0, 18}, {740.0, 140}}}; + +std::once_flag s_sound_buffers_once; +std::array, static_cast(NotificationSound::Count)> s_sound_buffers; + +std::string normalize_sound_name(std::string_view value) +{ + std::string normalized; + normalized.reserve(value.size()); + for (const auto ch : value) { + if (ch >= 'A' && ch <= 'Z') { + normalized.push_back(static_cast(ch - 'A' + 'a')); + } else if (ch == '-' || ch == ' ') { + normalized.push_back('_'); + } else { + normalized.push_back(ch); + } + } + return normalized; +} + +std::span sound_pattern(NotificationSound sound) +{ + switch (sound) { + case NotificationSound::Default: + return kDefaultPattern; + case NotificationSound::Info: + return kInfoPattern; + case NotificationSound::Success: + return kSuccessPattern; + case NotificationSound::Warning: + return kWarningPattern; + case NotificationSound::Alarm: + return kAlarmPattern; + case NotificationSound::Arrival: + return kArrivalPattern; + case NotificationSound::Soft: + return kSoftPattern; + case NotificationSound::Ping: + return kPingPattern; + case NotificationSound::Repair: + return kRepairPattern; + default: + return {}; + } +} + +void append_u16(std::vector& buffer, uint16_t value) +{ + buffer.push_back(static_cast(value & 0xff)); + buffer.push_back(static_cast((value >> 8) & 0xff)); +} + +void append_u32(std::vector& buffer, uint32_t value) +{ + buffer.push_back(static_cast(value & 0xff)); + buffer.push_back(static_cast((value >> 8) & 0xff)); + buffer.push_back(static_cast((value >> 16) & 0xff)); + buffer.push_back(static_cast((value >> 24) & 0xff)); +} + +void append_ascii(std::vector& buffer, std::string_view value) +{ buffer.insert(buffer.end(), value.begin(), value.end()); } + +std::vector build_wav(std::span pattern) +{ + uint32_t sample_count = 0; + for (const auto& segment : pattern) { + sample_count += static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); + } + + constexpr uint16_t channels = 1; + constexpr uint16_t bits_per_sample = 16; + const uint32_t data_bytes = sample_count * channels * (bits_per_sample / 8); + + std::vector buffer; + buffer.reserve(44 + data_bytes); + append_ascii(buffer, "RIFF"); + append_u32(buffer, 36 + data_bytes); + append_ascii(buffer, "WAVE"); + append_ascii(buffer, "fmt "); + append_u32(buffer, 16); + append_u16(buffer, 1); + append_u16(buffer, channels); + append_u32(buffer, kSampleRate); + append_u32(buffer, kSampleRate * channels * (bits_per_sample / 8)); + append_u16(buffer, channels * (bits_per_sample / 8)); + append_u16(buffer, bits_per_sample); + append_ascii(buffer, "data"); + append_u32(buffer, data_bytes); + + double phase = 0.0; + for (const auto& segment : pattern) { + const auto segment_samples = static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); + const auto step = segment.frequency_hz > 0.0 ? kTwoPi * segment.frequency_hz / kSampleRate : 0.0; + for (int index = 0; index < segment_samples; ++index) { + const auto fade_in = std::min(1.0, static_cast(index) / 80.0); + const auto fade_out = std::min(1.0, static_cast(segment_samples - index) / 120.0); + const auto sample = segment.frequency_hz > 0.0 ? std::sin(phase) * kAmplitude * fade_in * fade_out : 0.0; + append_u16(buffer, static_cast(static_cast(sample * 32767.0))); + phase += step; + if (phase > kTwoPi) { + phase -= kTwoPi; + } + } + } + return buffer; +} + +void initialize_sound_buffers() +{ + for (size_t index = 0; index < s_sound_buffers.size(); ++index) { + const auto pattern = sound_pattern(static_cast(index)); + if (!pattern.empty()) { + s_sound_buffers[index] = build_wav(pattern); + } + } +} +} // namespace + +const char* notification_sound_name(NotificationSound sound) +{ + switch (sound) { + case NotificationSound::None: + return "none"; + case NotificationSound::Default: + return "default"; + case NotificationSound::Info: + return "info"; + case NotificationSound::Success: + return "success"; + case NotificationSound::Warning: + return "warning"; + case NotificationSound::Alarm: + return "alarm"; + case NotificationSound::Arrival: + return "arrival"; + case NotificationSound::Soft: + return "soft"; + case NotificationSound::Ping: + return "ping"; + case NotificationSound::Repair: + return "repair"; + default: + return "none"; + } +} + +std::optional notification_sound_from_name(std::string_view name) +{ + const auto normalized = normalize_sound_name(name); + if (normalized == "none" || normalized == "false" || normalized == "off" || normalized == "disabled" + || normalized == "silent" || normalized == "0") + return NotificationSound::None; + if (normalized == "default") + return NotificationSound::Default; + if (normalized == "info") + return NotificationSound::Info; + if (normalized == "success" || normalized == "victory") + return NotificationSound::Success; + if (normalized == "warning" || normalized == "warn") + return NotificationSound::Warning; + if (normalized == "alarm" || normalized == "attack") + return NotificationSound::Alarm; + if (normalized == "arrival" || normalized == "arrive") + return NotificationSound::Arrival; + if (normalized == "soft" || normalized == "quiet") + return NotificationSound::Soft; + if (normalized == "ping") + return NotificationSound::Ping; + if (normalized == "repair" || normalized == "repaired") + return NotificationSound::Repair; + return std::nullopt; +} + +void notification_audio_play(NotificationSound sound) +{ + if (sound == NotificationSound::None) + return; + + std::call_once(s_sound_buffers_once, initialize_sound_buffers); + const auto index = static_cast(sound); + if (index >= s_sound_buffers.size() || s_sound_buffers[index].empty()) + return; + + const auto& buffer = s_sound_buffers[index]; + if (!notification_audio_platform_play(buffer.data(), buffer.size())) { + spdlog::warn("[NotifyAudio] Failed to play '{}' cue", notification_sound_name(sound)); + return; + } + spdlog::info("[NotifyAudio] Played '{}' cue", notification_sound_name(sound)); +} + +#if _WIN32 +bool notification_audio_platform_play(const uint8_t* data, size_t) +{ return PlaySoundW(reinterpret_cast(data), nullptr, SND_ASYNC | SND_MEMORY | SND_NODEFAULT) != FALSE; } +#elif !defined(__APPLE__) +bool notification_audio_platform_play(const uint8_t*, size_t) +{ return false; } +#endif diff --git a/mods/src/patches/notification_audio.h b/mods/src/patches/notification_audio.h new file mode 100644 index 000000000..8afd5347a --- /dev/null +++ b/mods/src/patches/notification_audio.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +enum class NotificationSound : uint8_t { + None = 0, + Default, + Info, + Success, + Warning, + Alarm, + Arrival, + Soft, + Ping, + Repair, + Count, +}; + +[[nodiscard]] const char* notification_sound_name(NotificationSound sound); +[[nodiscard]] std::optional notification_sound_from_name(std::string_view name); +void notification_audio_play(NotificationSound sound); diff --git a/mods/src/patches/notification_audio_platform.h b/mods/src/patches/notification_audio_platform.h new file mode 100644 index 000000000..3ee8a973c --- /dev/null +++ b/mods/src/patches/notification_audio_platform.h @@ -0,0 +1,6 @@ +#pragma once + +#include +#include + +[[nodiscard]] bool notification_audio_platform_play(const uint8_t* data, size_t size); diff --git a/mods/src/patches/notification_service.cc b/mods/src/patches/notification_service.cc index 54edf2b39..d652880a6 100644 --- a/mods/src/patches/notification_service.cc +++ b/mods/src/patches/notification_service.cc @@ -1,5 +1,6 @@ #include "patches/notification_service.h" #include "patches/battle_notify_parser.h" +#include "patches/notification_audio.h" #include "config.h" #include "str_utils.h" @@ -11,6 +12,7 @@ #include #include +#include #if _WIN32 #include @@ -24,6 +26,7 @@ static const MethodInfo* s_localize_ltc = nullptr; // LanguageManager.Localize(out string, LocaleTextContext) — instance static const MethodInfo* s_locale_utils_localize = nullptr; // LocaleUtilities.Localize(LocaleTextContext, bool, bool) — static static const MethodInfo* s_object_tostring = nullptr; +static bool s_initialized = false; // --------------------------------------------------------------------------- // Toast state → human-readable title @@ -93,6 +96,19 @@ static const char* toast_state_title(int state) } } +static NotificationSound toast_state_sound(int state) +{ + const auto& config = Config::Get(); + switch (state) { + case Victory: return config.alert_victory; + case Defeat: return config.alert_defeat; + case ArmadaCreated: return config.alert_armada_created; + case ArmadaBattleWon: return config.alert_armada_battle_won; + case ArmadaBattleLost: return config.alert_armada_battle_lost; + default: return NotificationSound::None; + } +} + // --------------------------------------------------------------------------- // Platform notification delivery // --------------------------------------------------------------------------- @@ -449,6 +465,11 @@ static std::string strip_unity_rich_text(const std::string& s) void notification_init() { + if (s_initialized) { + return; + } + s_initialized = true; + // Resolve LanguageManager::Localize(out string, LocaleTextContext) — the // 2-parameter overload that takes an LTC and returns a localized string. auto lm_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.Localization", "LanguageManager"); @@ -509,17 +530,35 @@ void notification_init() } catch (...) { spdlog::warn("[Notify] Windows notification service failed (unknown error)"); } +#else +#if __APPLE__ + spdlog::info("[Notify] macOS audio notification service ready"); #else spdlog::info("[Notify] Notification service: platform not supported (no-op)"); #endif +#endif } -void notification_handle_toast(Toast* toast) +void notification_emit(std::string_view title, std::string_view body) { -#if !_WIN32 - return; // No notification delivery on non-Windows platforms yet +#if _WIN32 + const std::string owned_title{title}; + const std::string owned_body{body}; + show_system_notification(owned_title.c_str(), owned_body.c_str()); #else + (void)title; + (void)body; +#endif +} + +void notification_handle_toast(Toast* toast) +{ + if (!toast) return; + auto state = toast->get_State(); + notification_audio_play(toast_state_sound(state)); + +#if _WIN32 // Check if this toast type is in the user's notify list const auto& notify_types = Config::Get().notify_banner_types; diff --git a/mods/src/patches/notification_service.h b/mods/src/patches/notification_service.h index 2ebad087a..11fd5dcd9 100644 --- a/mods/src/patches/notification_service.h +++ b/mods/src/patches/notification_service.h @@ -1,10 +1,15 @@ #pragma once +#include + struct Toast; // Initialize the notification service (resolve IL2CPP methods, init platform). -// Call once during InstallToastBannerHooks(). +// Idempotent; call from each feature installer that emits notifications. void notification_init(); +// Deliver a notification created by a mod feature rather than a Scopely toast. +void notification_emit(std::string_view title, std::string_view body); + // Called from toast hooks — checks config, formats, and delivers notification. void notification_handle_toast(Toast* toast); diff --git a/mods/src/patches/parts/audio_events.cc b/mods/src/patches/parts/audio_events.cc index 1d2c2c0e2..7e276d1cf 100644 --- a/mods/src/patches/parts/audio_events.cc +++ b/mods/src/patches/parts/audio_events.cc @@ -13,6 +13,40 @@ struct FabricEventManager { }; +namespace +{ +enum class FabricEventAction : int { + PlaySound = 0, + StopSound = 1, + UnpauseSound = 3, + AdvanceSequence = 16, + StopAll = 21, + UnloadAudio = 23, + PlayScheduled = 33, +}; + +constexpr bool can_start_audio(int event_action) +{ + switch (static_cast(event_action)) { + case FabricEventAction::PlaySound: + case FabricEventAction::UnpauseSound: + case FabricEventAction::AdvanceSequence: + case FabricEventAction::PlayScheduled: + return true; + default: + return false; + } +} + +constexpr bool should_suppress_audio_event(bool disable_all, bool event_is_disabled, int event_action) +{ return disable_all ? can_start_audio(event_action) : event_is_disabled; } + +static_assert(should_suppress_audio_event(true, false, static_cast(FabricEventAction::PlaySound))); +static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::StopSound))); +static_assert(should_suppress_audio_event(false, true, static_cast(FabricEventAction::StopAll))); +static_assert(!should_suppress_audio_event(true, true, static_cast(FabricEventAction::UnloadAudio))); +} // namespace + // Fabric.EventManager.PostEvent(string, EventAction, object, GameObject, // InitialiseParameters, bool, OnEventNotify) bool FabricEventManager_PostEvent_Hook(auto original, FabricEventManager* _this, Il2CppString* event_name, @@ -25,14 +59,16 @@ bool FabricEventManager_PostEvent_Hook(auto original, FabricEventManager* _this, add_to_queue, on_event_notify); } - const auto event = to_string(event_name); + const auto event = to_string(event_name); auto& config = Config::Get(); if (config.trace_audio_events) { spdlog::info("Audio event: {}", event); } - if (std::ranges::find(config.disabled_audio_events, event) != config.disabled_audio_events.end()) { + const bool event_is_disabled = + std::ranges::find(config.disabled_audio_events, event) != config.disabled_audio_events.end(); + if (should_suppress_audio_event(config.disable_all_audio_events, event_is_disabled, event_action)) { spdlog::debug("Suppressed audio event: {}", event); return false; } @@ -49,10 +85,19 @@ void InstallAudioEventHooks() return; } - // The shorter PostEvent overloads funnel into this seven-argument overload. - const auto method = helper.GetMethodInfo("PostEvent", 7); + // The shorter string PostEvent overloads funnel into this seven-argument overload. Select it by type because + // Fabric also has a seven-argument integer-ID overload with an incompatible native signature. + auto string_event_filter = [](int count, const Il2CppType** params) { + return count == 7 && params != nullptr && params[0] != nullptr && params[5] != nullptr + && params[0]->type == IL2CPP_TYPE_STRING && params[5]->type == IL2CPP_TYPE_BOOLEAN; + }; + const auto method = helper.GetMethodInfoSpecial("PostEvent", string_event_filter); if (method == nullptr || method->methodPointer == nullptr) { - ErrorMsg::MissingMethod("EventManager", "PostEvent"); + ErrorMsg::MissingMethod("EventManager", "PostEvent(string, ..., bool, ...)"); + return; + } + if (method->return_type == nullptr || method->return_type->type != IL2CPP_TYPE_BOOLEAN) { + spdlog::error("Fabric EventManager string PostEvent route has an unexpected return type"); return; } diff --git a/mods/src/patches/parts/fleet_watch.cc b/mods/src/patches/parts/fleet_watch.cc new file mode 100644 index 000000000..14c6aefcd --- /dev/null +++ b/mods/src/patches/parts/fleet_watch.cc @@ -0,0 +1,75 @@ +#include "patches/fleet_watch.h" + +#include "errormsg.h" +#include "patches/notification_service.h" +#include "patches/screen_update_hook.h" + +#include +#include + +#include + +#include + +namespace +{ +FleetPlayerData* fleet_widget_context(void* self) +{ + if (!self) { + return nullptr; + } + + static auto get_context = + IL2CppClassHelper{reinterpret_cast(self)->klass}.GetMethod("get_Context", + 0); + return get_context ? get_context(self) : nullptr; +} + +void FleetStateWidget_SetWidgetData_Hook(auto original, void* self) +{ + original(self); + fleet_watch_observe_widget(fleet_widget_context(self)); +} + +void ToastFleetObserver_HandleMiningDepleted_Hook(auto original, void* self, int64_t fleet_id) +{ + original(self, fleet_id); + fleet_watch_observe_node_depleted(fleet_id); +} +} // namespace + +void InstallFleetWatchHooks() +{ +#if !defined(_WIN32) && !defined(__APPLE__) + return; +#endif + + fleet_watch_init(); + if (!fleet_watch_uses_state_observation() && !fleet_watch_uses_node_depleted_hook()) { + return; + } + notification_init(); + + if (fleet_watch_uses_state_observation()) { + install_screen_manager_update_hook(); + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "FleetStateWidget"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("HUD", "FleetStateWidget"); + } else if (auto method = helper.GetMethod("SetWidgetData"); method) { + SPUD_STATIC_DETOUR(method, FleetStateWidget_SetWidgetData_Hook); + } else { + ErrorMsg::MissingMethod("FleetStateWidget", "SetWidgetData"); + } + } + + if (fleet_watch_uses_node_depleted_hook()) { + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "ToastFleetObserver"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("HUD", "ToastFleetObserver"); + } else if (auto method = helper.GetMethod("HandleMiningDepleted", 1); method) { + SPUD_STATIC_DETOUR(method, ToastFleetObserver_HandleMiningDepleted_Hook); + } else { + ErrorMsg::MissingMethod("ToastFleetObserver", "HandleMiningDepleted"); + } + } +} diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index f04ddafb1..c6a932b6b 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -34,14 +34,19 @@ #include "prime/ScanEngageButtonsWidget.h" #include "prime/ScreenManager.h" #include "prime/SelectableList.h" +#include "prime/ShortcutsManager.h" #include "patches/key.h" +#include "patches/fleet_watch.h" #include "patches/mapkey.h" +#include "patches/screen_update_hook.h" #include "patches/parts/daily_faction_bulk_claim.h" #include "patches/parts/focus_search.h" +#include "str_utils.h" #include +#include #include #include @@ -52,6 +57,333 @@ static bool reset_focus_next_frame = false; static int show_info_pending = 0; +static const MethodInfo* on_events_action = nullptr; +static const MethodInfo* on_galaxy_action = nullptr; + +struct InputActionCallbackContext { + void* state; + int32_t action_index; +}; + +static_assert(sizeof(InputActionCallbackContext) == 16); + +bool InvokeNativeShortcut(const MethodInfo* method, const char* action_name) +{ + auto* shortcuts_manager = ShortcutsManager::Instance(); + if (!shortcuts_manager || !method) { + spdlog::warn("[Hotkeys] native {} shortcut is unavailable", action_name); + return false; + } + + // These callbacks do not inspect CallbackContext, but runtime_invoke still requires storage for the value-type + // argument. Using the managed invoker also avoids platform-specific by-value ABI assumptions. + InputActionCallbackContext context{}; + void* args[]{&context}; + Il2CppException* exception = nullptr; + il2cpp_runtime_invoke(method, shortcuts_manager, args, &exception); + if (exception) { + spdlog::warn("[Hotkeys] native {} shortcut raised exception={}", action_name, static_cast(exception)); + return false; + } + + return true; +} + +using GetShowKeybindingsFn = bool(void*); +using SetShowKeybindingsFn = void(void*, bool); +using CanUseShortcutsFn = bool(); +using ClearTextOverrideFn = void(void*); +using UpdateShortcutHintTextFn = void(void*); +using GetInputActionFn = void*(void*); +using GetInputActionNameFn = Il2CppString*(void*); +using OverrideLocalizedTextFn = void(void*, Il2CppString*); +using UpdateShortcutHintVisibilityFn = void(void*, bool); + +static GetShowKeybindingsFn* get_show_keybindings = nullptr; +static SetShowKeybindingsFn* set_show_keybindings = nullptr; +static CanUseShortcutsFn* can_use_shortcuts = nullptr; +static ClearTextOverrideFn* clear_text_override = nullptr; +static GetInputActionFn* get_input_action = nullptr; +static GetInputActionNameFn* get_input_action_name = nullptr; +static OverrideLocalizedTextFn* override_localized_text = nullptr; +static UpdateShortcutHintTextFn* original_shortcut_hint_update_text = nullptr; +static ptrdiff_t shortcut_hint_input_action_offset = 0; +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 SetNativeShortcutHintsVisible(bool visible) +{ + auto* shortcuts_manager = ShortcutsManager::Instance(); + if (!shortcuts_manager || !get_show_keybindings || !set_show_keybindings) { + spdlog::warn("[Hotkeys] native shortcut hints are unavailable"); + return false; + } + + if (get_show_keybindings(shortcuts_manager) != visible) + set_show_keybindings(shortcuts_manager, visible); + + return true; +} + +bool ToggleNativeShortcutHints() +{ + auto* shortcuts_manager = ShortcutsManager::Instance(); + if (!shortcut_hints_ready || !shortcuts_manager || !get_show_keybindings || !set_show_keybindings || + !can_use_shortcuts) { + spdlog::warn("[Hotkeys] native shortcut hints are unavailable"); + return false; + } + + // Match the native OnShowKeybindingsAction gate while avoiding a fabricated InputAction.CallbackContext. + if (!can_use_shortcuts()) + return false; + + set_show_keybindings(shortcuts_manager, !get_show_keybindings(shortcuts_manager)); + + return true; +} + +std::string CompactShortcutToken(std::string_view token, bool is_primary_key) +{ + // Use AutoHotkey's established ASCII modifier notation so the native game's limited font can render every badge. + if (token == "SHIFT") + return "+"; + if (token == "CTRL") + return "^"; + if (token == "ALT" || token == "ALTGR") + return "!"; + if (token == "APPLE" || token == "CMD" || token == "WIN") + return "#"; + if (token == "LSHIFT") + return "<+"; + if (token == "RSHIFT") + return ">+"; + if (token == "LCTRL") + return "<^"; + if (token == "RCTRL") + return ">^"; + if (token == "LALT") + return "!"; + if (token == "LAPPLE" || token == "LCOM" || token == "LWIN") + return "<#"; + if (token == "RAPPLE" || token == "RCOM" || token == "RWIN") + return ">#"; + if (is_primary_key && token == "+") + return "PLS"; + if (is_primary_key && token == "^") + return "CAR"; + if (is_primary_key && token == "!") + return "EXC"; + if (is_primary_key && token == "#") + return "HSH"; + if (token == "SPACE") + return "SPC"; + if (token == "MOUSE0") + return "M0"; + if (token == "MOUSE1") + return "M1"; + if (token == "MOUSE2") + return "M2"; + if (token == "MOUSE3") + return "M3"; + if (token == "MOUSE4") + return "M4"; + if (token == "MOUSE5") + return "M5"; + if (token == "MOUSE6") + return "M6"; + if (token == "ENTER" || token == "RETURN") + return "ENT"; + if (token == "ESCAPE") + return "ESC"; + if (token == "TAB") + return "TAB"; + if (token == "BACKSPACE") + return "BS"; + if (token == "DELETE") + return "DEL"; + if (token == "MINUS") + return "-"; + if (token == "EQUAL") + return "="; + if (token == "LEFT") + return "LT"; + if (token == "RIGHT") + return "RT"; + if (token == "UP") + return "UP"; + if (token == "DOWN") + return "DN"; + if (token == "PGUP") + return "PU"; + if (token == "PGDOWN") + return "PD"; + if (token == "HOME") + return "HM"; + if (token == "END") + return "END"; + if (token == "NONE") + return "-"; + return std::string(token); +} + +std::string CompactShortcutForHint(std::string_view shortcuts) +{ + if (shortcuts.empty()) { + return "-"; + } + + // A native badge only has room for one chord, so show the first configured alternative. + if (const auto alternative = shortcuts.find(" | "); alternative != std::string_view::npos) { + shortcuts = shortcuts.substr(0, alternative); + } + + std::string compact; + for (size_t start = 0; start <= shortcuts.size();) { + const auto separator = shortcuts.find('-', start); + const auto end = separator == std::string_view::npos ? shortcuts.size() : separator; + compact.append(CompactShortcutToken(shortcuts.substr(start, end - start), separator == std::string_view::npos)); + if (separator == std::string_view::npos) { + break; + } + start = separator + 1; + } + return compact; +} + +GameFunction ModFunctionForNativeAction(std::string_view action_name) +{ + if (action_name == "interior_view") + return GameFunction::ShowStationInterior; + if (action_name == "exterior_view") + return GameFunction::ShoWStationExterior; + if (action_name == "system_view") + return GameFunction::ShowSystem; + if (action_name == "galaxy_view") + return GameFunction::NativeShortcutGalaxy; + if (action_name == "events") + return GameFunction::NativeShortcutEvents; + if (action_name == "ship_a") + return GameFunction::SelectShip1; + if (action_name == "ship_b") + return GameFunction::SelectShip2; + if (action_name == "ship_c") + return GameFunction::SelectShip3; + if (action_name == "ship_d") + return GameFunction::SelectShip4; + if (action_name == "ship_e") + return GameFunction::SelectShip5; + if (action_name == "ship_f") + return GameFunction::SelectShip6; + if (action_name == "ship_g") + return GameFunction::SelectShip7; + if (action_name == "ship_h") + return GameFunction::SelectShip8; + if (action_name == "alliance") + return GameFunction::ShowAlliance; + if (action_name == "chat") + return GameFunction::ShowChat; + if (action_name == "side_chat") + return GameFunction::ShowChatSide1; + if (action_name == "away_teams") + return GameFunction::ShowAwayTeam; + if (action_name == "missions") + return GameFunction::ShowMissions; + if (action_name == "daily_goals") + return GameFunction::ShowDaily; + if (action_name == "ship_locate") + return GameFunction::SelectCurrent; + if (action_name == "ship_manage") + return GameFunction::ShowShips; + if (action_name == "research") + return GameFunction::ShowResearch; + if (action_name == "consumables") + return GameFunction::ShowExoComp; + if (action_name == "ship_recall") + return GameFunction::ActionRecall; + if (action_name == "show_keybindings") + return GameFunction::ToggleShortcutHints; + if (action_name == "gifts") + return GameFunction::ShowGifts; + if (action_name == "help_alliance") + return GameFunction::ShowAllianceHelp; + if (action_name == "officers") + return GameFunction::ShowOfficers; + if (action_name == "factions") + return GameFunction::ShowFactions; + if (action_name == "items") + return GameFunction::ShowInventory; + if (action_name == "refinery") + return GameFunction::ShowRefinery; + if (action_name == "commanders") + return GameFunction::ShowCommander; + if (action_name == "challenges") + return GameFunction::ShowQTrials; + return GameFunction::Max; +} + +void ShortcutKeybindHint_UpdateText_Hook(auto original, void* _this) +{ + if (!_this || !shortcut_hints_ready) { + return original(_this); + } + + auto* text_localizer = + *reinterpret_cast(reinterpret_cast(_this) + shortcut_hint_text_localizer_offset); + if (!text_localizer) { + return original(_this); + } + + // UpdateText does not clear an existing localization override. Clear ours first so native, disabled, and unknown + // actions cannot retain a stale mod badge. + clear_text_override(text_localizer); + original(_this); + + const auto& config = Config::Get(); + if (!config.hotkeys_enabled || config.use_scopely_hotkeys) { + return; + } + + auto* input_action_reference = + *reinterpret_cast(reinterpret_cast(_this) + shortcut_hint_input_action_offset); + if (!input_action_reference) { + return; + } + + auto* action = get_input_action(input_action_reference); + auto* name = action ? get_input_action_name(action) : nullptr; + if (!name) { + return; + } + + const auto action_name = to_string(name); + const auto game_function = ModFunctionForNativeAction(action_name); + if (game_function == GameFunction::Max) { + override_localized_text(text_localizer, il2cpp_string_new("-")); + return; + } + + auto shortcuts = MapKey::GetShortcuts(game_function); + if (action_name == "side_chat" && shortcuts.empty()) { + shortcuts = MapKey::GetShortcuts(GameFunction::ShowChatSide2); + } + + auto shortcut = CompactShortcutForHint(shortcuts); + override_localized_text(text_localizer, il2cpp_string_new(shortcut.c_str())); +} + +void ShortcutKeybindHint_UpdateVisibility_Hook(auto original, void* _this, bool visible) +{ + original(_this, visible); + if (visible && shortcut_hints_ready && original_shortcut_hint_update_text) { + ShortcutKeybindHint_UpdateText_Hook(original_shortcut_hint_update_text, _this); + } +} + bool force_space_action_next_frame = false; void ChangeNavigationSection(SectionID sectionID); @@ -188,6 +520,13 @@ bool MoveShipSelectionInDock(bool goLeft) void ScreenManager_Update_Hook(auto original, ScreenManager* _this) { + fleet_watch_tick(); + if (!Config::Get().installHotkeyHooks) { + original(_this); + fleet_watch_after_update(); + return; + } + // This function is called every frame to update the screen manager. // Create a global clock to detect time elapsed static std::chrono::time_point select_clock = std::chrono::steady_clock::now(); @@ -196,6 +535,7 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) Key::ResetCache(); if (MapKey::IsDown(GameFunction::DisableHotKeys)) { + SetNativeShortcutHintsVisible(false); Config::Get().hotkeys_enabled = false; spdlog::warn("Setting hotkeys to DISABLED"); return; @@ -206,10 +546,18 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) } if (Config::Get().use_scopely_hotkeys && Config::Get().hotkeys_enabled) { - return original(_this); + original(_this); + fleet_watch_after_update(); + return; } if (!Config::Get().hotkeys_enabled) { + fleet_watch_after_update(); + return; + } + + if (MapKey::IsDown(GameFunction::ToggleShortcutHints)) { + ToggleNativeShortcutHints(); return; } @@ -399,6 +747,9 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) return GotoSection(SectionID::Shop_MainFactions); } else if (MapKey::IsDown(GameFunction::ShoWStationExterior)) { return GotoSection(SectionID::Starbase_Exterior); + } else if (MapKey::IsDown(GameFunction::NativeShortcutGalaxy)) { + InvokeNativeShortcut(on_galaxy_action, "Galaxy"); + return; } else if (MapKey::IsDown(GameFunction::ShowGalaxy)) { return ChangeNavigationSection(SectionID::Navigation_Galaxy); } else if (MapKey::IsDown(GameFunction::ShowStationInterior)) { @@ -423,6 +774,9 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) return GotoSection(SectionID::FleetCommander_Management); } else if (MapKey::IsDown(GameFunction::ShowAwayTeam)) { return GotoSection(SectionID::Missions_AwayTeamsList); + } else if (MapKey::IsDown(GameFunction::NativeShortcutEvents)) { + InvokeNativeShortcut(on_events_action, "Events"); + return; } else if (MapKey::IsDown(GameFunction::ShowEvents)) { return GotoSection(SectionID::Tournament_Group_Selection); } else if (MapKey::IsDown(GameFunction::ShowExoComp)) { @@ -590,12 +944,31 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) } if (config->disable_escape_exit && Key::Pressed(KeyCode::Escape)) { - return; + // Keep suppressing a held key. Only distinct key-down edges participate in + // the double-tap window. + if (config->escape_exit_timer <= 0 || !Key::Down(KeyCode::Escape)) { + return; + } + + static auto previous_escape_down = std::chrono::steady_clock::time_point{}; + const auto escape_now = std::chrono::steady_clock::now(); + const auto escape_diff = + std::chrono::duration_cast(escape_now - previous_escape_down); + + if (previous_escape_down == std::chrono::steady_clock::time_point{} + || escape_diff > std::chrono::milliseconds(config->escape_exit_timer)) { + previous_escape_down = escape_now; + return; + } + + // Consume the completed pair so a rapid third press starts a new one. + previous_escape_down = {}; } // config->Load(); - return original(_this); + original(_this); + fleet_watch_after_update(); } // NOTE: If you change this loop functionality, also change DoHideViewersOfType template @@ -1027,6 +1400,24 @@ void ShowWithFleet_Hook(auto original, PreScanTargetWidget* _this, void* a1) } } +void install_screen_manager_update_hook() +{ + static bool installed = false; + if (installed) { + return; + } + + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "ScreenManager"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("UI", "ScreenManager"); + } else if (auto update = helper.GetMethod("Update"); update) { + SPUD_STATIC_DETOUR(update, ScreenManager_Update_Hook); + installed = true; + } else { + ErrorMsg::MissingMethod("ScreenManager", "Update"); + } +} + void InstallHotkeyHooks() { auto shortcuts_manager_helper = @@ -1034,26 +1425,109 @@ void InstallHotkeyHooks() if (!shortcuts_manager_helper.isValidHelper()) { ErrorMsg::MissingHelper("GameInput", "ShortcutsManager"); } else { + on_events_action = shortcuts_manager_helper.GetMethodInfo("OnEventsAction", 1); + if (on_events_action == nullptr) { + ErrorMsg::MissingMethod("ShortcutsManager", "OnEventsAction"); + } + + on_galaxy_action = shortcuts_manager_helper.GetMethodInfo("OnGalaxyAction", 1); + if (on_galaxy_action == nullptr) { + ErrorMsg::MissingMethod("ShortcutsManager", "OnGalaxyAction"); + } + + get_show_keybindings = shortcuts_manager_helper.GetMethod("get_ShowKeybindings", 0); + set_show_keybindings = shortcuts_manager_helper.GetMethod("set_ShowKeybindings", 1); + can_use_shortcuts = shortcuts_manager_helper.GetMethod("get_CanUseShortcuts", 0); + if (!get_show_keybindings) + ErrorMsg::MissingMethod("ShortcutsManager", "get_ShowKeybindings"); + if (!set_show_keybindings) + ErrorMsg::MissingMethod("ShortcutsManager", "set_ShowKeybindings"); + if (!can_use_shortcuts) + ErrorMsg::MissingMethod("ShortcutsManager", "get_CanUseShortcuts"); + auto ptr_can_user_shortcuts = shortcuts_manager_helper.GetMethod("InitializeActions"); if (ptr_can_user_shortcuts == nullptr) { ErrorMsg::MissingMethod("ShortcutsManager", "InitializeActions"); } else { SPUD_STATIC_DETOUR(ptr_can_user_shortcuts, InitializeActions_Hook); + initialize_actions_hook_ready = true; } } - auto screen_manager_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "ScreenManager"); - if (!screen_manager_helper.isValidHelper()) { - ErrorMsg::MissingHelper("UI", "ScreenManager"); + auto text_localizer_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "TextLocalizer"); + if (!text_localizer_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.Client.UI", "TextLocalizer"); } else { - auto ptr_update = screen_manager_helper.GetMethod("Update"); - if (ptr_update == nullptr) { - ErrorMsg::MissingMethod("ScreenManager", "Update"); - } else { - SPUD_STATIC_DETOUR(ptr_update, ScreenManager_Update_Hook); + clear_text_override = text_localizer_helper.GetMethod("ClearTextOverride", 0); + override_localized_text = + text_localizer_helper.GetMethod("OverrideLocalizedText", 1); + if (!clear_text_override) + ErrorMsg::MissingMethod("TextLocalizer", "ClearTextOverride"); + if (!override_localized_text) + ErrorMsg::MissingMethod("TextLocalizer", "OverrideLocalizedText"); + } + + UpdateShortcutHintTextFn* shortcut_hint_update_text = nullptr; + UpdateShortcutHintVisibilityFn* shortcut_hint_update_visibility = nullptr; + auto shortcut_hint_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutKeybindHint"); + if (!shortcut_hint_helper.isValidHelper()) { + ErrorMsg::MissingHelper("GameInput", "ShortcutKeybindHint"); + } else { + auto input_action_field = shortcut_hint_helper.GetField("_inputAction"); + auto text_localizer_field = shortcut_hint_helper.GetField("_keyTextLocalizer"); + if (!input_action_field.isValidHelper()) { + spdlog::error("Unable to find field 'ShortcutKeybindHint->_inputAction'"); + } + if (!text_localizer_field.isValidHelper()) { + spdlog::error("Unable to find field 'ShortcutKeybindHint->_keyTextLocalizer'"); } + if (input_action_field.isValidHelper() && text_localizer_field.isValidHelper()) { + shortcut_hint_input_action_offset = input_action_field.offset(); + shortcut_hint_text_localizer_offset = text_localizer_field.offset(); + shortcut_hint_fields_ready = true; + } + + shortcut_hint_update_text = shortcut_hint_helper.GetMethod("UpdateText", 0); + shortcut_hint_update_visibility = + shortcut_hint_helper.GetMethod("UpdateVisibility", 1); + if (!shortcut_hint_update_text) { + ErrorMsg::MissingMethod("ShortcutKeybindHint", "UpdateText"); + } + if (!shortcut_hint_update_visibility) + ErrorMsg::MissingMethod("ShortcutKeybindHint", "UpdateVisibility"); } + auto input_action_reference_helper = + il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputActionReference"); + auto input_action_helper = + il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputAction"); + if (!input_action_reference_helper.isValidHelper()) { + ErrorMsg::MissingHelper("UnityEngine.InputSystem", "InputActionReference"); + } else { + get_input_action = input_action_reference_helper.GetMethod("get_action", 0); + if (!get_input_action) + ErrorMsg::MissingMethod("InputActionReference", "get_action"); + } + if (!input_action_helper.isValidHelper()) { + ErrorMsg::MissingHelper("UnityEngine.InputSystem", "InputAction"); + } else { + get_input_action_name = input_action_helper.GetMethod("get_name", 0); + if (!get_input_action_name) + ErrorMsg::MissingMethod("InputAction", "get_name"); + } + + if (get_show_keybindings && set_show_keybindings && can_use_shortcuts && initialize_actions_hook_ready && + clear_text_override && override_localized_text && shortcut_hint_fields_ready && shortcut_hint_update_text && + shortcut_hint_update_visibility && get_input_action && get_input_action_name) { + original_shortcut_hint_update_text = + SPUD_STATIC_DETOUR(shortcut_hint_update_text, ShortcutKeybindHint_UpdateText_Hook); + SPUD_STATIC_DETOUR(shortcut_hint_update_visibility, ShortcutKeybindHint_UpdateVisibility_Hook); + shortcut_hints_ready = true; + } + + install_screen_manager_update_hook(); + static auto rewards_button_widget = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Combat", "RewardsButtonWidget"); if (!rewards_button_widget.isValidHelper()) { diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc new file mode 100644 index 000000000..8653a6c7d --- /dev/null +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -0,0 +1,793 @@ +#include "errormsg.h" +#include "file.h" +#include "prime/KeyCode.h" +#include "str_utils.h" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !_WIN32 +#include +#include +#include +#endif + +namespace +{ +struct OfficerPresetItemContext { + Il2CppObject object; + void* synthetic_fleet_player_data; + int32_t presentation; + bool is_save_available; + bool is_occupied; + int32_t order_id; + int64_t slot_id; + Il2CppString* preset_name; + Il2CppArray* officers; + Il2CppArray* below_deck_officers; + bool below_deck_slots_unlocked; + void* officer_presets_view_context; +}; + +using ClearAndGenerateContentsFn = void(void*, Il2CppObject*, Il2CppObject*); +using GetScrollPositionFn = float(void*); +using RestoreScrollPositionFn = void(void*, float); + +std::vector session_order; +std::vector active_presentations; +Il2CppObject* active_controller = nullptr; +Il2CppClass* item_context_class = nullptr; +ptrdiff_t widget_context_offset = 0; +ptrdiff_t controller_context_offset = 0; +ptrdiff_t controller_scroller_offset = 0; +ptrdiff_t presets_items_offset = 0; +ClearAndGenerateContentsFn* clear_and_generate = nullptr; +GetScrollPositionFn* get_scroll_position = nullptr; +RestoreScrollPositionFn* restore_scroll_position = nullptr; + +std::filesystem::path state_path() +{ + if (File::hasCustomNames()) { + std::filesystem::path config_path{File::Config()}; + auto state_name = config_path.stem(); + state_name += ".state.json"; + return config_path.parent_path() / state_name; + } + return std::filesystem::path{File::MakePath("community_patch_state.json", true)}; +} + +class StateFileLock +{ +public: + explicit StateFileLock(const std::filesystem::path& path) + { + lock_path = path; + lock_path += ".lock"; + + for (int attempt = 0; attempt < 50; ++attempt) { +#if _WIN32 + handle = CreateFileW(lock_path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle != INVALID_HANDLE_VALUE) { + return; + } + const auto error = GetLastError(); + if (error != ERROR_SHARING_VIOLATION && error != ERROR_LOCK_VIOLATION) { + break; + } +#else + descriptor = open(lock_path.c_str(), O_CREAT | O_RDWR, 0600); + if (descriptor < 0) { + break; + } + if (flock(descriptor, LOCK_EX | LOCK_NB) == 0) { + return; + } + const auto error = errno; + close(descriptor); + descriptor = -1; + if (error != EWOULDBLOCK && error != EAGAIN) { + break; + } +#endif + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + + ~StateFileLock() + { +#if _WIN32 + if (handle != INVALID_HANDLE_VALUE) { + CloseHandle(handle); + } +#else + if (descriptor >= 0) { + flock(descriptor, LOCK_UN); + close(descriptor); + } +#endif + } + + StateFileLock(const StateFileLock&) = delete; + StateFileLock& operator=(const StateFileLock&) = delete; + + bool acquired() const + { +#if _WIN32 + return handle != INVALID_HANDLE_VALUE; +#else + return descriptor >= 0; +#endif + } + +private: + std::filesystem::path lock_path; +#if _WIN32 + HANDLE handle = INVALID_HANDLE_VALUE; +#else + int descriptor = -1; +#endif +}; + +std::filesystem::path temporary_state_path(const std::filesystem::path& path) +{ + static std::atomic_uint64_t sequence{0}; + auto temporary_path = path; +#if _WIN32 + const auto process_id = static_cast(GetCurrentProcessId()); +#else + const auto process_id = static_cast(getpid()); +#endif + temporary_path += "." + std::to_string(process_id) + "." + std::to_string(++sequence) + ".tmp"; + return temporary_path; +} + +void load_session_order() +{ + const auto path = state_path(); + std::ifstream file(path, std::ios::in | std::ios::binary); + if (!file) { + return; + } + + try { + const auto state = nlohmann::json::parse(file); + const auto order = state.find("officer_preset_order"); + if (order == state.end() || !order->is_array()) { + return; + } + + std::vector loaded_order; + loaded_order.reserve(order->size()); + for (const auto& item : *order) { + int64_t slot_id = -1; + if (item.is_string()) { + const auto& value = item.get_ref(); + const auto result = std::from_chars(value.data(), value.data() + value.size(), slot_id); + if (result.ec != std::errc{} || result.ptr != value.data() + value.size()) { + continue; + } + } else if (item.is_number_integer()) { + slot_id = item.get(); + } else { + continue; + } + + if (slot_id >= 0 && std::find(loaded_order.begin(), loaded_order.end(), slot_id) == loaded_order.end()) { + loaded_order.push_back(slot_id); + } + } + + session_order = std::move(loaded_order); + spdlog::info("[OfficerPresetReorder] loaded {} persisted slot positions", session_order.size()); + } catch (const std::exception& error) { + spdlog::warn("[OfficerPresetReorder] ignored invalid state file '{}': {}", path.string(), error.what()); + } +} + +bool replace_state_file(const std::filesystem::path& temporary_path, const std::filesystem::path& path) +{ +#if _WIN32 + if (MoveFileExW(temporary_path.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0) { + return true; + } + spdlog::warn("[OfficerPresetReorder] unable to replace state file '{}' (Windows error {})", path.string(), + GetLastError()); +#else + std::error_code error; + std::filesystem::rename(temporary_path, path, error); + if (!error) { + return true; + } + spdlog::warn("[OfficerPresetReorder] unable to replace state file '{}': {}", path.string(), error.message()); +#endif + std::error_code cleanup_error; + std::filesystem::remove(temporary_path, cleanup_error); + return false; +} + +bool save_session_order() +{ + const auto path = state_path(); + StateFileLock lock(path); + if (!lock.acquired()) { + spdlog::warn("[OfficerPresetReorder] unable to lock state file '{}'", path.string()); + return false; + } + + nlohmann::json state = nlohmann::json::object(); + try { + std::ifstream existing(path, std::ios::in | std::ios::binary); + if (existing) { + state = nlohmann::json::parse(existing); + if (!state.is_object()) { + state = nlohmann::json::object(); + } + } + } catch (const std::exception& error) { + spdlog::warn("[OfficerPresetReorder] replacing invalid state file '{}': {}", path.string(), error.what()); + state = nlohmann::json::object(); + } + + auto order = nlohmann::json::array(); + for (const auto slot_id : session_order) { + order.push_back(std::to_string(slot_id)); + } + state["version"] = 1; + state["officer_preset_order"] = std::move(order); + + const auto temporary_path = temporary_state_path(path); + std::ofstream file(temporary_path, std::ios::out | std::ios::binary | std::ios::trunc); + if (!file) { + spdlog::warn("[OfficerPresetReorder] unable to open temporary state file '{}'", temporary_path.string()); + return false; + } + file << state.dump(2) << '\n'; + file.flush(); + const bool write_succeeded = file.good(); + file.close(); + if (!write_succeeded || file.fail()) { + spdlog::warn("[OfficerPresetReorder] unable to finish temporary state file '{}'", temporary_path.string()); + std::error_code cleanup_error; + std::filesystem::remove(temporary_path, cleanup_error); + return false; + } + return replace_state_file(temporary_path, path); +} + +static_assert(offsetof(OfficerPresetItemContext, presentation) == 0x18); +static_assert(offsetof(OfficerPresetItemContext, is_occupied) == 0x1D); +static_assert(offsetof(OfficerPresetItemContext, order_id) == 0x20); +static_assert(offsetof(OfficerPresetItemContext, slot_id) == 0x28); +static_assert(offsetof(OfficerPresetItemContext, preset_name) == 0x30); + +bool validate_context_field(Il2CppClass* context_class, const char* name, ptrdiff_t expected_offset, + Il2CppTypeEnum expected_type) +{ + auto* field = il2cpp_class_get_field_from_name(context_class, name); + if (field == nullptr || field->type == nullptr) { + spdlog::error("[OfficerPresetReorder] required context field '{}' is unavailable", name); + return false; + } + if (field->offset != expected_offset || field->type->type != expected_type) { + spdlog::error("[OfficerPresetReorder] context field '{}' layout mismatch: offset=0x{:X} type={} expected " + "offset=0x{:X} type={}; feature disabled", + name, field->offset, static_cast(field->type->type), expected_offset, + static_cast(expected_type)); + return false; + } + return true; +} + +bool validate_context_layout(Il2CppClass* context_class) +{ + return context_class != nullptr + && validate_context_field(context_class, "Presentation", offsetof(OfficerPresetItemContext, presentation), + IL2CPP_TYPE_VALUETYPE) + && validate_context_field(context_class, "IsOccupied", offsetof(OfficerPresetItemContext, is_occupied), + IL2CPP_TYPE_BOOLEAN) + && validate_context_field(context_class, "OrderId", offsetof(OfficerPresetItemContext, order_id), + IL2CPP_TYPE_I4) + && validate_context_field(context_class, "SlotId", offsetof(OfficerPresetItemContext, slot_id), IL2CPP_TYPE_I8) + && validate_context_field(context_class, "PresetName", offsetof(OfficerPresetItemContext, preset_name), + IL2CPP_TYPE_STRING) + && validate_context_field(context_class, "Officers", offsetof(OfficerPresetItemContext, officers), + IL2CPP_TYPE_SZARRAY) + && validate_context_field(context_class, "_officerPresetsViewContext", + offsetof(OfficerPresetItemContext, officer_presets_view_context), IL2CPP_TYPE_CLASS); +} + +bool is_reorderable_preset(const OfficerPresetItemContext* context) +{ + return context != nullptr && context->slot_id >= 0 && context->order_id >= 0 && context->preset_name != nullptr + && context->officers != nullptr && reinterpret_cast(context->officers)->max_length > 0; +} + +template T* read_object_field(void* object, ptrdiff_t offset) +{ return object != nullptr ? *reinterpret_cast(reinterpret_cast(object) + offset) : nullptr; } + +bool key_pressed(KeyCode key) +{ + static auto get_key = il2cpp_resolve_icall_typed("UnityEngine.Input::GetKeyInt(UnityEngine.KeyCode)"); + return get_key != nullptr && get_key(key); +} + +bool shift_pressed() +{ return key_pressed(KeyCode::LeftShift) || key_pressed(KeyCode::RightShift); } + +bool control_pressed() +{ return key_pressed(KeyCode::LeftControl) || key_pressed(KeyCode::RightControl); } + +void remember_slots(OfficerPresetItemContext** items, il2cpp_array_size_t size) +{ + for (il2cpp_array_size_t index = 0; index < size; ++index) { + const auto* context = items[index]; + if (!is_reorderable_preset(context)) { + continue; + } + if (std::find(session_order.begin(), session_order.end(), context->slot_id) == session_order.end()) { + session_order.push_back(context->slot_id); + } + } +} + +bool has_native_identity(const OfficerPresetItemContext* context) +{ return context != nullptr && context->slot_id >= 0 && context->order_id >= 0; } + +bool validate_unique_preset_identity(OfficerPresetItemContext** items, int32_t size) +{ + std::vector slot_ids; + std::vector order_ids; + slot_ids.reserve(size); + order_ids.reserve(size); + for (int32_t index = 0; index < size; ++index) { + const auto* context = items[index]; + if (!has_native_identity(context)) { + continue; + } + if (std::find(slot_ids.begin(), slot_ids.end(), context->slot_id) != slot_ids.end() + || std::find(order_ids.begin(), order_ids.end(), context->order_id) != order_ids.end()) { + spdlog::error("[OfficerPresetReorder] duplicate preset identity detected at index {}: slot={} order={}", index, + context->slot_id, context->order_id); + return false; + } + slot_ids.push_back(context->slot_id); + order_ids.push_back(context->order_id); + } + return true; +} + +bool validate_canonical_order(OfficerPresetItemContext** items, int32_t size) +{ + if (!validate_unique_preset_identity(items, size)) { + return false; + } + for (int32_t index = 0; index < size; ++index) { + const auto* context = items[index]; + if (!has_native_identity(context)) { + continue; + } + if (context->order_id != index) { + spdlog::error("[OfficerPresetReorder] canonical preset invariant failed at index {}: slot={} order={}; " + "local ordering disabled for this view", + index, context->slot_id, context->order_id); + return false; + } + } + return true; +} + +std::vector make_view_order(OfficerPresetItemContext** canonical_items, int32_t size) +{ + std::vector view_items(canonical_items, canonical_items + size); + remember_slots(canonical_items, static_cast(size)); + + std::vector occupied_positions; + std::vector occupied_contexts; + occupied_positions.reserve(size); + occupied_contexts.reserve(size); + for (int32_t index = 0; index < size; ++index) { + if (is_reorderable_preset(canonical_items[index])) { + occupied_positions.push_back(index); + occupied_contexts.push_back(canonical_items[index]); + } + } + + std::stable_sort(occupied_contexts.begin(), occupied_contexts.end(), [](const auto* left, const auto* right) { + const auto left_order = std::find(session_order.begin(), session_order.end(), left->slot_id); + const auto right_order = std::find(session_order.begin(), session_order.end(), right->slot_id); + return left_order < right_order; + }); + for (size_t index = 0; index < occupied_positions.size(); ++index) { + view_items[occupied_positions[index]] = occupied_contexts[index]; + } + return view_items; +} + +bool try_get_preset_list(void* view_context, Il2CppObject** list, Il2CppArraySize** backing_items, int32_t* size) +{ + *list = read_object_field(view_context, presets_items_offset); + if (*list == nullptr) { + return false; + } + + auto list_helper = IL2CppClassHelper{(*list)->klass}; + auto items_field = list_helper.GetField("_items"); + auto size_field = list_helper.GetField("_size"); + if (!items_field.isValidHelper() || !size_field.isValidHelper()) { + return false; + } + + *backing_items = read_object_field(*list, items_field.offset()); + *size = *reinterpret_cast(reinterpret_cast(*list) + size_field.offset()); + return *backing_items != nullptr && *size >= 0 + && static_cast(*size) <= (*backing_items)->max_length; +} + +bool capture_native_presentations(OfficerPresetItemContext** canonical_items, int32_t size) +{ + if (!validate_canonical_order(canonical_items, size)) { + return false; + } + active_presentations.clear(); + active_presentations.reserve(size); + for (int32_t index = 0; index < size; ++index) { + active_presentations.push_back(canonical_items[index] != nullptr ? canonical_items[index]->presentation : 0); + } + return true; +} + +bool restore_native_presentations(OfficerPresetItemContext** canonical_items, int32_t size) +{ + if (!validate_canonical_order(canonical_items, size) || active_presentations.size() != static_cast(size)) { + spdlog::error("[OfficerPresetReorder] native presentation map is unavailable; local ordering disabled for this " + "view"); + return false; + } + for (int32_t index = 0; index < size; ++index) { + if (canonical_items[index] != nullptr) { + canonical_items[index]->presentation = active_presentations[index]; + } + } + return true; +} + +bool render_local_order(Il2CppObject* controller, bool preserve_scroll) +{ + if (controller == nullptr || item_context_class == nullptr || clear_and_generate == nullptr) { + return false; + } + + auto* view_context = read_object_field(controller, controller_context_offset); + auto* scroller = read_object_field(controller, controller_scroller_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (scroller == nullptr || !try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + return false; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size) || active_presentations.size() != static_cast(size)) { + return false; + } + + auto view_items = make_view_order(canonical_items, size); + auto* view_array = il2cpp_array_new(item_context_class, static_cast(size)); + if (view_array == nullptr) { + spdlog::warn("[OfficerPresetReorder] unable to allocate the scroller view array"); + return false; + } + auto** view_slots = reinterpret_cast(reinterpret_cast(view_array)->vector); + for (int32_t index = 0; index < size; ++index) { + auto* context = view_items[index]; + if (context != nullptr) { + context->presentation = active_presentations[index]; + } + il2cpp_gc_wbarrier_set_field(reinterpret_cast(view_array), &view_slots[index], context); + } + + const auto scroll_position = preserve_scroll && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; + clear_and_generate(scroller, controller, reinterpret_cast(view_array)); + if (preserve_scroll && restore_scroll_position != nullptr) { + restore_scroll_position(scroller, scroll_position); + } + return true; +} + +bool move_preset(OfficerPresetItemContext* context, int direction) +{ + if (!is_reorderable_preset(context) || context->officer_presets_view_context == nullptr + || active_controller == nullptr || clear_and_generate == nullptr) { + return false; + } + + Il2CppObject* list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (!try_get_preset_list(context->officer_presets_view_context, &list, &backing_items, &size)) { + spdlog::warn("[OfficerPresetReorder] unable to read live preset list"); + return false; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size)) { + return false; + } + auto view_items = make_view_order(canonical_items, size); + + int32_t current_index = -1; + for (int32_t index = 0; index < size; ++index) { + if (view_items[index] == context) { + current_index = index; + break; + } + } + if (current_index < 0) { + return false; + } + + int32_t target_index = current_index + direction; + while (target_index >= 0 && target_index < size && !is_reorderable_preset(view_items[target_index])) { + target_index += direction; + } + if (target_index < 0 || target_index >= size) { + spdlog::info("[OfficerPresetReorder] slot={} is already at the {}", context->slot_id, + direction < 0 ? "top" : "bottom"); + return true; + } + + auto* scroller = read_object_field(active_controller, controller_scroller_offset); + if (scroller == nullptr) { + spdlog::warn("[OfficerPresetReorder] unable to access the active preset scroller"); + return false; + } + + auto* target = view_items[target_index]; + const auto current_order = std::find(session_order.begin(), session_order.end(), context->slot_id); + const auto target_order = std::find(session_order.begin(), session_order.end(), target->slot_id); + bool persisted = false; + if (current_order != session_order.end() && target_order != session_order.end()) { + std::iter_swap(current_order, target_order); + persisted = save_session_order(); + } + + spdlog::info("[OfficerPresetReorder] moved slot={} {} across slot={} ({})", context->slot_id, + direction < 0 ? "up" : "down", target->slot_id, + persisted ? "persisted locally" : "local persistence failed"); + if (!render_local_order(active_controller, true)) { + spdlog::warn("[OfficerPresetReorder] local order changed but the scroller could not be refreshed"); + } + return true; +} + +void OfficerPresetsViewController_OnSaveSlotsSuccess_Hook(auto original, Il2CppObject* _this, + bool increase_occupied_slots_count) +{ + auto* view_context = read_object_field(_this, controller_context_offset); + auto* scroller = read_object_field(_this, controller_scroller_offset); + + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + const bool canonical_available = + scroller != nullptr && try_get_preset_list(view_context, &canonical_list, &backing_items, &size) + && validate_canonical_order(reinterpret_cast(backing_items->vector), size); + const bool can_rerender = + canonical_available && active_controller == _this + && restore_native_presentations(reinterpret_cast(backing_items->vector), size); + const auto scroll_position = can_rerender && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; + if (canonical_available) { + // Scopely's callback indexes SmartScroller._data by OrderId. Give it the canonical list for the duration of the + // callback, then rebuild our separate presentation-only view. + clear_and_generate(scroller, _this, canonical_list); + } + + original(_this, increase_occupied_slots_count); + + if (!canonical_available) { + spdlog::warn("[OfficerPresetReorder] save succeeded without an available canonical scroller source"); + return; + } + if (!can_rerender) { + spdlog::debug("[OfficerPresetReorder] save succeeded in canonical order without an active local view"); + return; + } + + if (!try_get_preset_list(read_object_field(_this, controller_context_offset), &canonical_list, &backing_items, + &size)) { + spdlog::warn("[OfficerPresetReorder] save succeeded but the canonical preset list became unavailable"); + return; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size)) { + spdlog::error("[OfficerPresetReorder] canonical preset invariant failed after save; local ordering was not " + "reapplied"); + return; + } + if (active_presentations.size() != static_cast(size)) { + if (!capture_native_presentations(canonical_items, size)) { + return; + } + } + if (!render_local_order(_this, false)) { + spdlog::warn("[OfficerPresetReorder] save reconciled safely but the local scroller order could not be restored"); + return; + } + if (restore_scroll_position != nullptr) { + restore_scroll_position(scroller, scroll_position); + } + spdlog::info("[OfficerPresetReorder] reconciled presentation-only ordering after preset save"); +} + +bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2CppArraySize** preset_contexts, + void* view_context) +{ + const bool result = original(_this, preset_contexts, view_context); + if (!result || preset_contexts == nullptr || *preset_contexts == nullptr) { + spdlog::info("[OfficerPresetReorder] preset context load returned result={} array={}", result, + preset_contexts != nullptr ? static_cast(*preset_contexts) : nullptr); + return result; + } + + auto* contexts = *preset_contexts; + auto** items = reinterpret_cast(contexts->vector); + if (validate_canonical_order(items, static_cast(contexts->max_length))) { + remember_slots(items, contexts->max_length); + spdlog::debug("[OfficerPresetReorder] observed {} canonical preset rows", contexts->max_length); + } else { + spdlog::error("[OfficerPresetReorder] Scopely returned non-canonical preset rows; leaving them untouched"); + } + + return result; +} + +void OfficerPresetItemWidget_OnEditNameButtonClicked_Hook(auto original, void* _this) +{ + const bool move_up = shift_pressed(); + const bool move_down = control_pressed(); + if (move_up != move_down) { + auto* context = read_object_field(_this, widget_context_offset); + if (move_preset(context, move_up ? -1 : 1)) { + return; + } + } + original(_this); +} + +void OfficerPresetsViewController_OnDidBindCanvasContext_Hook(auto original, Il2CppObject* _this) +{ + original(_this); + active_controller = _this; + + auto* view_context = read_object_field(_this, controller_context_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (!try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + active_presentations.clear(); + spdlog::warn("[OfficerPresetReorder] unable to capture the bound canonical preset list"); + return; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!capture_native_presentations(canonical_items, size) || !render_local_order(_this, false)) { + active_presentations.clear(); + spdlog::warn("[OfficerPresetReorder] local ordering was disabled for the bound preset view"); + } +} + +void OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook(auto original, Il2CppObject* _this) +{ + if (active_controller == _this) { + auto* view_context = read_object_field(_this, controller_context_offset); + auto* scroller = read_object_field(_this, controller_scroller_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (scroller != nullptr && try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (validate_canonical_order(canonical_items, size)) { + restore_native_presentations(canonical_items, size); + clear_and_generate(scroller, _this, canonical_list); + } + } + active_controller = nullptr; + active_presentations.clear(); + } + original(_this); +} +} // namespace + +void InstallOfficerPresetReorderHooks() +{ + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Officers", "OfficerManager"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.Prime.Officers", "OfficerManager"); + return; + } + + const auto method = helper.GetMethodInfo("TryGetPresetItemContext", 2); + if (method == nullptr || method->methodPointer == nullptr) { + ErrorMsg::MissingMethod("OfficerManager", "TryGetPresetItemContext"); + return; + } + + auto widget_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetItemWidget"); + auto controller_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewController"); + auto view_context_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewContext"); + auto item_context_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetItemContext"); + auto scroller_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "SmartScrollerBase"); + if (!widget_helper.isValidHelper() || !controller_helper.isValidHelper() || !view_context_helper.isValidHelper() + || !item_context_helper.isValidHelper() || !scroller_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.Prime.OfficerPresets", "reorder UI surface"); + return; + } + if (!validate_context_layout(item_context_helper.get_cls())) { + return; + } + item_context_class = item_context_helper.get_cls(); + + auto context_field = widget_helper.GetField("m_context"); + auto controller_context_field = controller_helper.GetField("m_context"); + auto scroller_field = controller_helper.GetField("_smartScroller"); + auto presets_field = view_context_helper.GetField("PresetsItemsContext"); + if (!context_field.isValidHelper() || !controller_context_field.isValidHelper() || !scroller_field.isValidHelper() + || !presets_field.isValidHelper()) { + ErrorMsg::MissingMethod("OfficerPresetReorder", "required field"); + return; + } + widget_context_offset = context_field.offset(); + controller_context_offset = controller_context_field.offset(); + controller_scroller_offset = scroller_field.offset(); + presets_items_offset = presets_field.offset(); + + const auto clear_method = scroller_helper.GetMethodInfo("ClearAndGenerateContents", 2); + const auto get_scroll_method = scroller_helper.GetMethodInfo("get_ScrollPosition", 0); + const auto restore_scroll_method = scroller_helper.GetMethodInfo("RestoreScrollPosition", 1); + const auto edit_method = widget_helper.GetMethodInfo("OnEditNameButtonClicked", 0); + const auto bind_method = controller_helper.GetMethodInfo("OnDidBindCanvasContext", 0); + const auto release_method = controller_helper.GetMethodInfo("OnAboutToReleaseCanvasContext", 0); + const auto save_success_method = controller_helper.GetMethodInfo("OnSaveSlotsSuccess", 1); + if (clear_method == nullptr || clear_method->methodPointer == nullptr || get_scroll_method == nullptr + || get_scroll_method->methodPointer == nullptr || restore_scroll_method == nullptr + || restore_scroll_method->methodPointer == nullptr || edit_method == nullptr + || edit_method->methodPointer == nullptr || bind_method == nullptr || bind_method->methodPointer == nullptr + || release_method == nullptr || release_method->methodPointer == nullptr || save_success_method == nullptr + || save_success_method->methodPointer == nullptr) { + ErrorMsg::MissingMethod("OfficerPresetReorder", "required UI method"); + return; + } + clear_and_generate = reinterpret_cast(clear_method->methodPointer); + get_scroll_position = reinterpret_cast(get_scroll_method->methodPointer); + restore_scroll_position = reinterpret_cast(restore_scroll_method->methodPointer); + + load_session_order(); + + SPUD_STATIC_DETOUR(method->methodPointer, OfficerManager_TryGetPresetItemContext_Hook); + SPUD_STATIC_DETOUR(edit_method->methodPointer, OfficerPresetItemWidget_OnEditNameButtonClicked_Hook); + SPUD_STATIC_DETOUR(bind_method->methodPointer, OfficerPresetsViewController_OnDidBindCanvasContext_Hook); + SPUD_STATIC_DETOUR(release_method->methodPointer, OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook); + SPUD_STATIC_DETOUR(save_success_method->methodPointer, OfficerPresetsViewController_OnSaveSlotsSuccess_Hook); +} diff --git a/mods/src/patches/parts/open_bulk_claim_gifts.cc b/mods/src/patches/parts/open_bulk_claim_gifts.cc index 28d5fd40b..ded9379ea 100644 --- a/mods/src/patches/parts/open_bulk_claim_gifts.cc +++ b/mods/src/patches/parts/open_bulk_claim_gifts.cc @@ -1,24 +1,13 @@ #include "config.h" #include "errormsg.h" -#include "prime/Hub.h" -#include "str_utils.h" #include #include #include -#include -#include namespace { -constexpr auto kGiftsCategoryKey = "chests"; -constexpr int kAutoOpenRetryFrames = 300; - -bool g_pending_auto_open = false; -bool g_auto_open_attempted = false; -int g_retry_frames_left = 0; - class DrawerContext { private: @@ -31,9 +20,7 @@ class DrawerContext public: static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } + { return get_class_helper(); } bool Enabled() { @@ -54,9 +41,7 @@ class DrawerWidget public: static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } + { return get_class_helper(); } DrawerContext* Context() { @@ -65,12 +50,6 @@ class DrawerWidget return field.GetRaw(this); } - bool ContextEnabled() - { - auto context = Context(); - return context != nullptr && context->Enabled(); - } - void OpenViaButtonCallback() { static auto method = get_class_helper().GetMethod("OnOpenButtonClicked"); @@ -80,68 +59,6 @@ class DrawerWidget } }; -class ShopCategory -{ -private: - static IL2CppClassHelper& get_class_helper() - { - static auto class_helper = - il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.Prime.Shop", "ShopCategory"); - return class_helper; - } - -public: - static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } - - static std::string KeyForValue(int value) - { - static auto method = get_class_helper().GetMethod("EnumToKey"); - auto key = method != nullptr ? method(value) : nullptr; - return key != nullptr ? to_string(key) : std::string{}; - } -}; - -class BundleGroupConfig -{ -private: - static IL2CppClassHelper& get_class_helper() - { - static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Shop", "BundleGroupConfig"); - return class_helper; - } - -public: - static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } - - int Category() - { - static auto field = get_class_helper().GetField("_category"); - return field.isValidHelper() ? *(int*)((ptrdiff_t)this + field.offset()) : -1; - } -}; - -class ShopSectionContext -{ -private: - static IL2CppClassHelper& get_class_helper() - { - static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Shop", "ShopSectionContext"); - return class_helper; - } - -public: - static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } -}; - class ShopListScrollerViewController { private: @@ -154,207 +71,127 @@ class ShopListScrollerViewController public: static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } + { return get_class_helper(); } DrawerWidget* SelectionDrawer() { - // Lower Gifts << drawer; scroller category can describe upper shop chrome such as latinum. static auto field = get_class_helper().GetField("_selectionDrawerWidget"); return field.isValidHelper() ? *(DrawerWidget**)((ptrdiff_t)this + field.offset()) : nullptr; } }; -bool IsClassNamed(void* object, const char* namespaze, const char* name) -{ - if (object == nullptr) { - return false; - } - - const auto klass = ((Il2CppObject*)object)->klass; - if (klass == nullptr) { - return false; - } - - const auto object_namespace = klass->namespaze != nullptr ? klass->namespaze : ""; - return strcmp(object_namespace, namespaze) == 0 && strcmp(klass->name, name) == 0; -} - -void ClearAutoOpen() -{ - g_pending_auto_open = false; - g_retry_frames_left = 0; -} - -void ArmAutoOpen() -{ - g_pending_auto_open = true; - g_auto_open_attempted = false; - g_retry_frames_left = kAutoOpenRetryFrames; -} - -bool IsGiftsShopPayload(int section, void* args) -{ - if (section != (int)SectionID::Shop_List || !IsClassNamed(args, "Digit.Prime.Shop", "BundleGroupConfig")) { - return false; - } - - const auto category = ((BundleGroupConfig*)args)->Category(); - return ShopCategory::KeyForValue(category) == kGiftsCategoryKey; -} - -bool IsBulkClaimTabSection(int section) -{ - return section == (int)SectionID::Shop_List || section == (int)SectionID::Shop_AllianceChests - || section == (int)SectionID::Consumables || section == (int)SectionID::Missions_AwayTeamsList; -} +ShopListScrollerViewController* g_active_scroller = nullptr; +bool g_opened_for_landing = false; void TryAutoOpenDrawer(ShopListScrollerViewController* controller) { - if (!Config::Get().auto_open_bulk_claim_flyout || !g_pending_auto_open || g_auto_open_attempted - || controller == nullptr) { + if (!Config::Get().auto_open_bulk_claim_flyout || controller == nullptr || g_opened_for_landing) { return; } - if (g_retry_frames_left-- <= 0) { - ClearAutoOpen(); + auto* drawer = controller->SelectionDrawer(); + auto* context = drawer != nullptr ? drawer->Context() : nullptr; + if (context == nullptr || !context->Enabled()) { return; } - auto drawer = controller->SelectionDrawer(); - if (drawer == nullptr || !drawer->ContextEnabled()) { - return; - } - - g_auto_open_attempted = true; - ClearAutoOpen(); + // Mark first so a native callback cannot cause a nested lifecycle update to open the drawer twice. + g_opened_for_landing = true; drawer->OpenViaButtonCallback(); } -void ShopListViewController_AboutToHide(auto original, void* _this) +void ShopListScrollerViewController_AboutToShow(auto original, ShopListScrollerViewController* self) { - ClearAutoOpen(); - original(_this); -} + g_active_scroller = self; + g_opened_for_landing = false; -void ShopListScrollerViewController_Update(auto original, ShopListScrollerViewController* _this) -{ - original(_this); - TryAutoOpenDrawer(_this); + original(self); + TryAutoOpenDrawer(self); } -void ShopSectionContext_InjectTabData(auto original, ShopSectionContext* _this, int section, - Il2CppArraySize* tab_locale_contexts, - Il2CppArraySize* additional_tab_locale_contexts, - Il2CppArraySize* tab_icon_identifiers, Il2CppArraySize* pip_types, - Il2CppArraySize* hide_if_no_content, bool set_current_section, - bool override_existing_tabs) +void ShopListScrollerViewController_AboutToHide(auto original, ShopListScrollerViewController* self) { - original(_this, section, tab_locale_contexts, additional_tab_locale_contexts, tab_icon_identifiers, pip_types, - hide_if_no_content, set_current_section, override_existing_tabs); - - if (!Config::Get().auto_open_bulk_claim_flyout || !set_current_section) { - return; + if (g_active_scroller == self) { + g_active_scroller = nullptr; + g_opened_for_landing = false; } - if (IsBulkClaimTabSection(section)) { - ArmAutoOpen(); - } else { - ClearAutoOpen(); - } + original(self); } -bool SectionManager_TriggerSectionChange(auto original, - void* _this, - int next_section, - void* args, - bool forced_section_change, - bool is_go_back_step, - bool allow_same_section) +void ShopListScrollerViewController_UpdateDrawer(auto original, ShopListScrollerViewController* self) { - const auto changed = original(_this, next_section, args, forced_section_change, is_go_back_step, allow_same_section); - if (changed && Config::Get().auto_open_bulk_claim_flyout && IsGiftsShopPayload(next_section, args)) { - ArmAutoOpen(); + original(self); + + if (g_active_scroller == self) { + TryAutoOpenDrawer(self); } - return changed; } } // namespace void InstallGiftsBulkClaimHooks() { + bool can_install = true; + auto drawer_context = DrawerContext::ClassHelper(); if (!drawer_context.isValidHelper()) { ErrorMsg::MissingHelper("Prime.SharedFeatures.Scripts.UI.Widgets", "DrawerContext"); + can_install = false; } else if (!drawer_context.GetField("Enabled").isValidHelper()) { ErrorMsg::MissingMethod("DrawerContext", "Enabled"); + can_install = false; } auto drawer_widget = DrawerWidget::ClassHelper(); if (!drawer_widget.isValidHelper()) { ErrorMsg::MissingHelper("Prime.SharedFeatures.Scripts.UI.Widgets", "DrawerWidget"); + can_install = false; } else { auto widget_base = drawer_widget.GetParent("Widget`1"); if (!widget_base.isValidHelper() || !widget_base.GetProperty("Context").isValidHelper()) { ErrorMsg::MissingMethod("DrawerWidget", "Widget.Context"); + can_install = false; } if (drawer_widget.GetMethod("OnOpenButtonClicked") == nullptr) { ErrorMsg::MissingMethod("DrawerWidget", "OnOpenButtonClicked"); + can_install = false; } } - auto shop_category = ShopCategory::ClassHelper(); - if (!shop_category.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.Shop", "ShopCategory"); - } else if (shop_category.GetMethod("EnumToKey") == nullptr) { - ErrorMsg::MissingMethod("ShopCategory", "EnumToKey"); + auto shop_list_scroller = ShopListScrollerViewController::ClassHelper(); + if (!shop_list_scroller.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.Prime.Shop", "ShopListScrollerViewController"); + return; } - auto bundle_group_config = BundleGroupConfig::ClassHelper(); - if (!bundle_group_config.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.Shop", "BundleGroupConfig"); - } else if (!bundle_group_config.GetField("_category").isValidHelper()) { - ErrorMsg::MissingMethod("BundleGroupConfig", "_category"); + if (!shop_list_scroller.GetField("_selectionDrawerWidget").isValidHelper()) { + ErrorMsg::MissingMethod("ShopListScrollerViewController", "_selectionDrawerWidget"); + can_install = false; } - auto shop_section_context = ShopSectionContext::ClassHelper(); - if (!shop_section_context.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.Shop", "ShopSectionContext"); - } else if (auto ptr = shop_section_context.GetMethod("InjectTabData"); ptr == nullptr) { - ErrorMsg::MissingMethod("ShopSectionContext", "InjectTabData"); - } else { - SPUD_STATIC_DETOUR(ptr, ShopSectionContext_InjectTabData); + auto about_to_show = shop_list_scroller.GetMethod("AboutToShow"); + if (about_to_show == nullptr) { + ErrorMsg::MissingMethod("ShopListScrollerViewController", "AboutToShow"); + can_install = false; } - auto shop_list_controller = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Shop", "ShopListViewController"); - if (!shop_list_controller.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.Shop", "ShopListViewController"); - } else if (auto ptr = shop_list_controller.GetMethod("AboutToHide"); ptr == nullptr) { - ErrorMsg::MissingMethod("ShopListViewController", "AboutToHide"); - } else { - SPUD_STATIC_DETOUR(ptr, ShopListViewController_AboutToHide); + auto about_to_hide = shop_list_scroller.GetMethod("AboutToHide"); + if (about_to_hide == nullptr) { + ErrorMsg::MissingMethod("ShopListScrollerViewController", "AboutToHide"); + can_install = false; } - auto shop_list_scroller = ShopListScrollerViewController::ClassHelper(); - if (!shop_list_scroller.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.Shop", "ShopListScrollerViewController"); - } else { - if (!shop_list_scroller.GetField("_selectionDrawerWidget").isValidHelper()) { - ErrorMsg::MissingMethod("ShopListScrollerViewController", "_selectionDrawerWidget"); - } - if (auto ptr = shop_list_scroller.GetMethod("Update"); ptr == nullptr) { - ErrorMsg::MissingMethod("ShopListScrollerViewController", "Update"); - } else { - SPUD_STATIC_DETOUR(ptr, ShopListScrollerViewController_Update); - } + auto update_drawer = shop_list_scroller.GetMethod("UpdateDrawer"); + if (update_drawer == nullptr) { + ErrorMsg::MissingMethod("ShopListScrollerViewController", "UpdateDrawer"); + can_install = false; } - auto section_manager = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.Sections", "SectionManager"); - if (!section_manager.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Client.Sections", "SectionManager"); - } else if (auto ptr = section_manager.GetMethod("TriggerSectionChange", 5); ptr == nullptr) { - ErrorMsg::MissingMethod("SectionManager", "TriggerSectionChange(section,args)"); - } else { - SPUD_STATIC_DETOUR(ptr, SectionManager_TriggerSectionChange); + if (!can_install) { + return; } + + SPUD_STATIC_DETOUR(about_to_show, ShopListScrollerViewController_AboutToShow); + SPUD_STATIC_DETOUR(about_to_hide, ShopListScrollerViewController_AboutToHide); + SPUD_STATIC_DETOUR(update_drawer, ShopListScrollerViewController_UpdateDrawer); } diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index c19de3af1..884eee23b 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -5,6 +5,8 @@ #include +#include +#include #include #include #include @@ -13,6 +15,187 @@ #include #include +#include +#include + +namespace +{ +std::unordered_map fleet_label_widgets; +bool fleet_label_hooks_installed = false; +uintptr_t active_system_zoom_id = 0; +bool system_zoom_state_valid = false; +ZoomLevels system_zoom_level = ZoomLevels::None; +float system_normalized_zoom = 0.0f; +bool threshold_state_valid = false; +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(); + return config.zoom_label_player.detail == FleetLabelDetail::Threshold + || config.zoom_label_non_player.detail == FleetLabelDetail::Threshold; +} + +const FleetLabelProfile *FleetLabelProfileFor(NavigationFleetWidget *widget) +{ + if (widget == nullptr) { + return nullptr; + } + + const auto &config = Config::Get(); + auto *context = widget->Context; + if (context == nullptr) { + return &config.zoom_label_non_player; + } + + DeployedFleetType fleet_type; + if (!context->TryGetFleetType(fleet_type)) { + return &config.zoom_label_non_player; + } + + switch (fleet_type) { + case DeployedFleetType::Player: + return &config.zoom_label_player; + case DeployedFleetType::Marauder: + case DeployedFleetType::NpcInstantiated: + case DeployedFleetType::Sentinel: + case DeployedFleetType::Alliance: + case DeployedFleetType::Challenge: + return &config.zoom_label_non_player; + case DeployedFleetType::Nonexistent: + default: + return &config.zoom_label_non_player; + } +} + +const FleetLabelProfile *FleetLabelProfileFor(NavigationLOD *lod) +{ + const auto widget = fleet_label_widgets.find(lod); + if (widget == fleet_label_widgets.end() || widget->second == nullptr || widget->second->Context == nullptr) { + return nullptr; + } + return FleetLabelProfileFor(widget->second); +} + +ZoomLevels ExpandedFleetLabelZoomLevel(ZoomLevels native_level) +{ return native_level == ZoomLevels::Near ? ZoomLevels::Near : ZoomLevels::Middle; } + +bool FleetLabelsExpandedAt(float normalized_zoom, float threshold) +{ + if (threshold <= 0.0f) { + return false; + } + if (threshold >= 1.0f) { + return true; + } + return normalized_zoom <= threshold; +} + +ZoomLevels FleetLabelZoomLevel(ZoomLevels native_level, const FleetLabelProfile &profile) +{ + switch (profile.detail) { + case FleetLabelDetail::Expanded: + return ExpandedFleetLabelZoomLevel(native_level); + case FleetLabelDetail::Compact: + return ZoomLevels::Far; + case FleetLabelDetail::Threshold: + if (!system_zoom_state_valid) { + return native_level; + } + return FleetLabelsExpandedAt(system_normalized_zoom, profile.zoom_threshold) + ? ExpandedFleetLabelZoomLevel(native_level) + : ZoomLevels::Far; + case FleetLabelDetail::Native: + default: + return native_level; + } +} + +void ApplyFleetLabelDetail(NavigationLOD *lod, ZoomLevels native_level) +{ + if (lod == nullptr || !system_zoom_state_valid || FleetLabelProfileFor(lod) == nullptr) { + return; + } + + lod->UpdateLOD(native_level); +} + +void ApplyAllFleetLabelDetails() +{ + for (const auto &[lod, widget] : fleet_label_widgets) { + (void)widget; + ApplyFleetLabelDetail(lod, system_zoom_level); + } +} + +void RestoreNativeFleetLabelDetail(NavigationLOD *lod) +{ + if (lod == nullptr || !system_zoom_state_valid) { + return; + } + + lod->SetTargetLevel(system_zoom_level); + lod->UpdateLOD(system_zoom_level); + lod->SetTargetLevel(system_zoom_level); +} + +void ResetFleetLabelSystemState() +{ + active_system_zoom_id = 0; + system_zoom_state_valid = false; + system_zoom_level = ZoomLevels::None; + system_normalized_zoom = 0.0f; + threshold_state_valid = false; +} + +void BeginFleetLabelSystemState(NavigationZoom *zoom) +{ + const auto zoom_id = reinterpret_cast(zoom); + if (active_system_zoom_id != zoom_id) { + ResetFleetLabelSystemState(); + active_system_zoom_id = zoom_id; + } +} + +void UpdateFleetLabelThreshold() +{ + if (!system_zoom_state_valid || !FleetLabelThresholdEnabled()) { + threshold_state_valid = false; + return; + } + + const auto &config = Config::Get(); + const auto player_uses_threshold = config.zoom_label_player.detail == FleetLabelDetail::Threshold; + const auto non_player_uses_threshold = config.zoom_label_non_player.detail == FleetLabelDetail::Threshold; + const auto player_expanded = + player_uses_threshold && FleetLabelsExpandedAt(system_normalized_zoom, config.zoom_label_player.zoom_threshold); + const auto non_player_expanded = + non_player_uses_threshold + && FleetLabelsExpandedAt(system_normalized_zoom, config.zoom_label_non_player.zoom_threshold); + if (threshold_state_valid && (!player_uses_threshold || player_expanded == player_threshold_state_expanded) + && (!non_player_uses_threshold || non_player_expanded == non_player_threshold_state_expanded)) { + return; + } + + threshold_state_valid = true; + player_threshold_state_expanded = player_expanded; + non_player_threshold_state_expanded = non_player_expanded; + ApplyAllFleetLabelDetails(); + +#ifdef _MODDBG + spdlog::info("Fleet label detail threshold state updated at normalized zoom {:.4f}", system_normalized_zoom); +#endif +} +} // namespace + vec3 GetMouseWorldPos(void *cam, vec3 *pos) { static auto class_helper = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.Client.Core", "MathUtils"); @@ -106,7 +289,7 @@ static void ScaleFR(void *fr) return; } - static auto comp_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Component"); + static auto comp_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Component"); if (!comp_helper.isValidHelper()) { return; } @@ -137,6 +320,15 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) il2cpp_resolve_icall_typed("UnityEngine.Input::get_mousePosition_Injected(UnityEngine.Vector3&)"); static auto GetDeltaTime = il2cpp_resolve_icall_typed("UnityEngine.Time::get_deltaTime()"); + if (fleet_label_hooks_installed) { + const auto zoom_id = reinterpret_cast(_this); + if (_this->_depth == NodeDepth::SolarSystem) { + BeginFleetLabelSystemState(_this); + } else if (active_system_zoom_id == zoom_id) { + ResetFleetLabelSystemState(); + } + } + const auto dt = GetDeltaTime(); auto zoomDelta = 0.0f; bool do_absolute_zoom = false; @@ -145,47 +337,51 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) EnsureSystemZoomRange(_this); + const auto camera_shortcuts_enabled = config->hotkeys_enabled && !config->use_scopely_hotkeys; + if (!Key::IsInputFocused()) { - if (MapKey::IsDown(GameFunction::SetZoomPreset1)) { - return StoreZoom("System Preset 1", config->system_zoom_preset_1, _this); - } else if (MapKey::IsDown(GameFunction::SetZoomPreset2)) { - return StoreZoom("System Preset 2", config->system_zoom_preset_2, _this); - } else if (MapKey::IsDown(GameFunction::SetZoomPreset3)) { - return StoreZoom("System Preset 3", config->system_zoom_preset_3, _this); - } else if (MapKey::IsDown(GameFunction::SetZoomPreset4)) { - return StoreZoom("System Preset 4", config->system_zoom_preset_4, _this); - } else if (MapKey::IsDown(GameFunction::SetZoomPreset5)) { - return StoreZoom("System Preset 5", config->system_zoom_preset_5, _this); - } else if (MapKey::IsDown(GameFunction::SetZoomDefault)) { - return StoreZoom("System Default", config->default_system_zoom, _this); - } + if (camera_shortcuts_enabled) { + if (MapKey::IsDown(GameFunction::SetZoomPreset1)) { + return StoreZoom("System Preset 1", config->system_zoom_preset_1, _this); + } else if (MapKey::IsDown(GameFunction::SetZoomPreset2)) { + return StoreZoom("System Preset 2", config->system_zoom_preset_2, _this); + } else if (MapKey::IsDown(GameFunction::SetZoomPreset3)) { + return StoreZoom("System Preset 3", config->system_zoom_preset_3, _this); + } else if (MapKey::IsDown(GameFunction::SetZoomPreset4)) { + return StoreZoom("System Preset 4", config->system_zoom_preset_4, _this); + } else if (MapKey::IsDown(GameFunction::SetZoomPreset5)) { + return StoreZoom("System Preset 5", config->system_zoom_preset_5, _this); + } else if (MapKey::IsDown(GameFunction::SetZoomDefault)) { + return StoreZoom("System Default", config->default_system_zoom, _this); + } - do_absolute_zoom = true; - if (MapKey::IsDown(GameFunction::ZoomPreset1)) { - zoomDelta = config->system_zoom_preset_1; - do_store_zoom = true; - } else if (MapKey::IsDown(GameFunction::ZoomPreset2)) { - zoomDelta = config->system_zoom_preset_2; - do_store_zoom = true; - } else if (MapKey::IsDown(GameFunction::ZoomPreset3)) { - zoomDelta = config->system_zoom_preset_3; - do_store_zoom = true; - } else if (MapKey::IsDown(GameFunction::ZoomPreset4)) { - zoomDelta = config->system_zoom_preset_4; - do_store_zoom = true; - } else if (MapKey::IsDown(GameFunction::ZoomPreset5)) { - zoomDelta = config->system_zoom_preset_5; - do_store_zoom = true; - } + do_absolute_zoom = true; + if (MapKey::IsDown(GameFunction::ZoomPreset1)) { + zoomDelta = config->system_zoom_preset_1; + do_store_zoom = true; + } else if (MapKey::IsDown(GameFunction::ZoomPreset2)) { + zoomDelta = config->system_zoom_preset_2; + do_store_zoom = true; + } else if (MapKey::IsDown(GameFunction::ZoomPreset3)) { + zoomDelta = config->system_zoom_preset_3; + do_store_zoom = true; + } else if (MapKey::IsDown(GameFunction::ZoomPreset4)) { + zoomDelta = config->system_zoom_preset_4; + do_store_zoom = true; + } else if (MapKey::IsDown(GameFunction::ZoomPreset5)) { + zoomDelta = config->system_zoom_preset_5; + do_store_zoom = true; + } - if (config->hotkeys_extended) { - if (MapKey::IsDown(GameFunction::ZoomReset)) { - do_absolute_zoom = false; - do_default_zoom = true; - } else if (MapKey::IsDown(GameFunction::ZoomMin)) { - zoomDelta = config->zoom; - } else if (MapKey::IsDown(GameFunction::ZoomMax)) { - zoomDelta = 100; + if (config->hotkeys_extended) { + if (MapKey::IsDown(GameFunction::ZoomReset)) { + do_absolute_zoom = false; + do_default_zoom = true; + } else if (MapKey::IsDown(GameFunction::ZoomMin)) { + zoomDelta = config->zoom; + } else if (MapKey::IsDown(GameFunction::ZoomMax)) { + zoomDelta = 100; + } } } @@ -199,7 +395,7 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) zoomDelta = config->keyboard_zoom_speed * dt; } - if (MapKey::IsPressed(GameFunction::ZoomIn) || do_absolute_zoom) { + if ((camera_shortcuts_enabled && MapKey::IsPressed(GameFunction::ZoomIn)) || do_absolute_zoom) { vec3 mousePos; GetMousePosition(&mousePos); _this->_zoomLocation = vec2{.x = mousePos.x, .y = mousePos.y}; @@ -213,7 +409,7 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) auto worldPos = GetMouseWorldPos(_this->_sceneCamera, &mousePos); _this->_worldPoint = worldPos; _this->ZoomCameraAtWorldPoint(); - } else if (MapKey::IsPressed(GameFunction::ZoomOut) && !Key::IsInputFocused()) { + } else if (camera_shortcuts_enabled && MapKey::IsPressed(GameFunction::ZoomOut)) { vec3 mousePos; GetMousePosition(&mousePos); _this->_zoomLocation = vec2{.x = mousePos.x, .y = mousePos.y}; @@ -234,6 +430,94 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) original(_this); EnsureSystemZoomRange(_this); + if (fleet_label_hooks_installed && _this->_depth == NodeDepth::SolarSystem) { + BeginFleetLabelSystemState(_this); + const auto state_was_valid = system_zoom_state_valid; + system_zoom_level = _this->_zoomLevel; + system_normalized_zoom = _this->NormalizedZoom; + system_zoom_state_valid = true; + if (FleetLabelThresholdEnabled()) { + UpdateFleetLabelThreshold(); + } else if (!state_was_valid) { + ApplyAllFleetLabelDetails(); + } + } +} + +void NavigationLOD_UpdateLOD_Hook(auto original, NavigationLOD *_this, ZoomLevels level) +{ + const auto *profile = FleetLabelProfileFor(_this); + const auto effective_level = profile != nullptr ? FleetLabelZoomLevel(level, *profile) : level; + if (profile != nullptr) { + _this->SetTargetLevel(effective_level); + } + original(_this, effective_level); + if (profile != nullptr) { + _this->SetTargetLevel(effective_level); + } +} + +void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget *_this) +{ + original(_this); + if (!_this) { + return; + } + + // Fresh pooled widgets are enabled before BindDataContext assigns m_context. Forcing an LOD in that window can fire + // OnLODChanged callbacks that dereference the not-yet-bound fleet data. + if (_this->Context == nullptr) { + return; + } + + auto *lod = _this->_lod; + if (lod) { + fleet_label_widgets.insert_or_assign(lod, _this); + if (FleetLabelThresholdEnabled() && !system_zoom_state_valid) { + threshold_state_valid = false; + } + ApplyFleetLabelDetail(lod, system_zoom_level); + } +} + +void NavigationFleetWidget_OnDisable_Hook(auto original, NavigationFleetWidget *_this) +{ + if (_this) { + auto *lod = _this->_lod; + fleet_label_widgets.erase(lod); + if (_this->Context != nullptr) { + RestoreNativeFleetLabelDetail(lod); + } + } + original(_this); +} + +void NavigationFleetWidget_OnDidBindContext_Hook(auto original, NavigationFleetWidget *_this) +{ + original(_this); + if (_this == nullptr) { + return; + } + + auto *lod = _this->_lod; + if (lod != nullptr) { + fleet_label_widgets.insert_or_assign(lod, _this); + ApplyFleetLabelDetail(lod, system_zoom_level); + } +} + +void NavigationFleetWidget_OnAboutToReleaseContext_Hook(auto original, NavigationFleetWidget *_this) +{ + if (_this != nullptr) { + auto *lod = _this->_lod; + if (lod != nullptr) { + fleet_label_widgets.erase(lod); + if (_this->Context != nullptr) { + RestoreNativeFleetLabelDetail(lod); + } + } + } + original(_this); } void PlanetViewUtils_CameraZoomedEventHandler_Hook(auto original, PlanetViewUtils *_this, float zoomDistance, @@ -249,6 +533,14 @@ void PlanetViewUtils_CameraZoomedEventHandler_Hook(auto original, PlanetViewUtil void NavigationZoom_SetViewParameters_Hook(auto original, NavigationZoom *_this, float radius, NodeDepth depth) { + if (fleet_label_hooks_installed && depth != NodeDepth::SolarSystem) { + if (active_system_zoom_id == reinterpret_cast(_this)) { + ResetFleetLabelSystemState(); + } + } else if (fleet_label_hooks_installed) { + BeginFleetLabelSystemState(_this); + } + if (depth == NodeDepth::SolarSystem) { ApplySystemZoomRange(_this, radius); SetSceneCameraFarClip(_this); @@ -264,6 +556,14 @@ void NavigationZoom_SetViewParameters_Hook(auto original, NavigationZoom *_this, void NavigationZoom_SetDepth_Hook(auto original, NavigationZoom *_this, NodeDepth depth) { + if (fleet_label_hooks_installed && depth != NodeDepth::SolarSystem) { + if (active_system_zoom_id == reinterpret_cast(_this)) { + ResetFleetLabelSystemState(); + } + } else if (fleet_label_hooks_installed) { + BeginFleetLabelSystemState(_this); + } + if (depth == NodeDepth::SolarSystem) { ApplySystemZoomRange(_this, _this->_viewRadius); SetSceneCameraFarClip(_this); @@ -290,6 +590,111 @@ void *PlanetViewUtils_get_FlatRenderable_Hook(auto original, PlanetViewUtils *_t void InstallZoomHooks() { + auto navigation_zoom_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationZoom"); + auto ptr_update = navigation_zoom_helper.isValidHelper() ? navigation_zoom_helper.GetMethod("Update") : nullptr; + auto *navigation_zoom_class = navigation_zoom_helper.get_cls(); + auto *zoom_level_field = navigation_zoom_class != nullptr + ? il2cpp_class_get_field_from_name(navigation_zoom_class, "_zoomLevel") + : nullptr; + auto *normalized_zoom_property = navigation_zoom_class != nullptr + ? il2cpp_class_get_property_from_name(navigation_zoom_class, "NormalizedZoom") + : nullptr; + if (FleetLabelProfilesEnabled()) { + auto lod_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationLOD"); + auto fleet_widget_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationFleetWidget"); + auto fleet_data_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData"); + auto ptr_update_lod = lod_helper.isValidHelper() ? lod_helper.GetMethod("UpdateLOD") : nullptr; + auto ptr_on_enable = fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnEnable") : nullptr; + auto ptr_on_disable = fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnDisable") : nullptr; + auto ptr_on_did_bind_context = + fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnDidBindContext") : nullptr; + auto ptr_on_about_to_release_context = + fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnAboutToReleaseContext") : nullptr; + 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; + auto *fleet_widget_class = fleet_widget_helper.get_cls(); + auto *lod_field = + fleet_widget_class != nullptr ? il2cpp_class_get_field_from_name(fleet_widget_class, "_lod") : nullptr; + auto *context_field = NavigationFleetWidget::ContextField(); + auto *fleet_data_class = fleet_data_helper.get_cls(); + auto *fleet_type_property = + fleet_data_class != nullptr ? il2cpp_class_get_property_from_name(fleet_data_class, "FleetType") : nullptr; + auto *fleet_type_getter = + fleet_type_property != nullptr ? il2cpp_property_get_get_method((PropertyInfo *)fleet_type_property) : nullptr; + + if (!lod_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Navigation", "NavigationLOD"); + } else { + if (ptr_update_lod == nullptr) { + ErrorMsg::MissingMethod("NavigationLOD", "UpdateLOD"); + } + if (target_level_field == nullptr) { + ErrorMsg::MissingMethod("NavigationLOD", "k__BackingField"); + } + } + if (!fleet_widget_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Navigation", "NavigationFleetWidget"); + } else { + if (ptr_on_enable == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "OnEnable"); + } + if (ptr_on_disable == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "OnDisable"); + } + if (ptr_on_did_bind_context == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "OnDidBindContext"); + } + if (ptr_on_about_to_release_context == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "OnAboutToReleaseContext"); + } + if (lod_field == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "_lod"); + } + if (context_field == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "m_context"); + } + } + if (!fleet_data_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.PrimeServer.Models", "FleetDeployedData"); + } else if (fleet_type_property == nullptr || fleet_type_getter == nullptr) { + ErrorMsg::MissingMethod("FleetDeployedData", "FleetType"); + } + if (!navigation_zoom_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Navigation", "NavigationZoom"); + } else { + if (ptr_update == nullptr) { + ErrorMsg::MissingMethod("NavigationZoom", "Update"); + } + if (zoom_level_field == nullptr) { + ErrorMsg::MissingMethod("NavigationZoom", "_zoomLevel"); + } + if (normalized_zoom_property == nullptr) { + ErrorMsg::MissingMethod("NavigationZoom", "NormalizedZoom"); + } + } + + const auto fleet_label_dependencies_valid = + lod_helper.isValidHelper() && ptr_update_lod != nullptr && target_level_field != nullptr + && fleet_widget_helper.isValidHelper() && ptr_on_enable != nullptr && ptr_on_disable != nullptr + && ptr_on_did_bind_context != nullptr && ptr_on_about_to_release_context != nullptr && lod_field != nullptr + && context_field != nullptr && fleet_data_helper.isValidHelper() && fleet_type_property != nullptr + && fleet_type_getter != nullptr && navigation_zoom_helper.isValidHelper() && ptr_update != nullptr + && zoom_level_field != nullptr && normalized_zoom_property != nullptr; + 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); + } 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()) { @@ -311,11 +716,9 @@ void InstallZoomHooks() } } - auto screen_manager_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationZoom"); - if (!screen_manager_helper.isValidHelper()) { + if (!navigation_zoom_helper.isValidHelper()) { ErrorMsg::MissingHelper("Navigation", "NavigationZoom"); } else { - auto ptr_update = screen_manager_helper.GetMethod("Update"); if (ptr_update == nullptr) { ErrorMsg::MissingMethod("NavigationZoom", "Update"); } else { @@ -323,7 +726,7 @@ void InstallZoomHooks() } #if _WIN32 - auto ptr_set_depth = screen_manager_helper.GetMethod("SetDepth"); + auto ptr_set_depth = navigation_zoom_helper.GetMethod("SetDepth"); if (ptr_set_depth == nullptr) { ErrorMsg::MissingMethod("NavigationZoom", "SetDepth"); } else { @@ -331,7 +734,7 @@ void InstallZoomHooks() } #endif - auto ptr_set_view_parameters = screen_manager_helper.GetMethod("SetViewParameters"); + auto ptr_set_view_parameters = navigation_zoom_helper.GetMethod("SetViewParameters"); if (ptr_set_view_parameters == nullptr) { ErrorMsg::MissingMethod("NavigationZoom", "SetViewParameters"); } else { diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 9579b9692..ad2d39388 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -27,6 +27,7 @@ void InstallFreeResizeHooks(); void InstallToastBannerHooks(); void InstallPanHooks(); void InstallHotkeyHooks(); +void InstallFleetWatchHooks(); void InstallGiftsBulkClaimHooks(); void InstallDailyFactionBulkClaimHooks(); @@ -48,6 +49,7 @@ void InstallDoubleClickAssignShipHooks(); void InstallInstantWarpConfirmationHooks(); void InstallForbiddenTechConfirmationHooks(); void InstallAudioEventHooks(); +void InstallOfficerPresetReorderHooks(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -127,6 +129,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"ToastBannerHooks", {InstallToastBannerHooks, &cfg.installToastBannerHooks}}, {"PanHooks", {InstallPanHooks, &cfg.installPanHooks}}, {"HotkeyHooks", {InstallHotkeyHooks, &cfg.installHotkeyHooks}}, + {"FleetWatchHooks", {InstallFleetWatchHooks, &cfg.installFleetWatchHooks}}, {"GiftsBulkClaimHooks", {InstallGiftsBulkClaimHooks, &cfg.installGiftsBulkClaimHooks}}, {"DailyFactionBulkClaimHooks", {InstallDailyFactionBulkClaimHooks, &cfg.installDailyFactionBulkClaimHooks}}, @@ -151,6 +154,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"InstantWarpConfirm", {InstallInstantWarpConfirmationHooks, &cfg.installInstantWarpConfirmationHooks}}, {"ForbiddenTechConfirm", {InstallForbiddenTechConfirmationHooks, &cfg.auto_confirm_ft_upgrade}}, {"AudioEvents", {InstallAudioEventHooks, &cfg.installAudioEventHooks}}, + {"OfficerPresetReorder", {InstallOfficerPresetReorderHooks, &cfg.allow_officer_preset_reordering}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); diff --git a/mods/src/patches/screen_update_hook.h b/mods/src/patches/screen_update_hook.h new file mode 100644 index 000000000..6fe3ebe46 --- /dev/null +++ b/mods/src/patches/screen_update_hook.h @@ -0,0 +1,5 @@ +#pragma once + +// ScreenManager.Update has one detour owner shared by HotkeyHooks and FleetWatchHooks. +// Installation is idempotent so either patch can request the dispatcher. +void install_screen_manager_update_hook(); diff --git a/mods/src/prime/CargoHoldData.h b/mods/src/prime/CargoHoldData.h new file mode 100644 index 000000000..a43486d1d --- /dev/null +++ b/mods/src/prime/CargoHoldData.h @@ -0,0 +1,25 @@ +#pragma once + +#include "ProgressData.h" + +#include + +struct CargoHoldData { +public: + __declspec(property(get = __get_UnprotectedCargoProgress)) ProgressData* UnprotectedCargoProgress; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "CargoHoldData"); + return class_helper; + } + +public: + ProgressData* __get_UnprotectedCargoProgress() + { + static auto property = get_class_helper().GetProperty("UnprotectedCargoProgress"); + return property.GetRaw(this); + } +}; diff --git a/mods/src/prime/FleetDeployedData.h b/mods/src/prime/FleetDeployedData.h index 0f7bf2e13..fc3bc607c 100644 --- a/mods/src/prime/FleetDeployedData.h +++ b/mods/src/prime/FleetDeployedData.h @@ -10,6 +10,7 @@ enum class DeployedFleetType { NpcInstantiated, Sentinel, Alliance, + Challenge, }; struct FleetDeployedData { @@ -44,4 +45,16 @@ struct FleetDeployedData { static auto field = get_class_helper().GetProperty("FleetType"); return *field.Get(this); } + + bool TryGetFleetType(DeployedFleetType& fleet_type) + { + static auto property = get_class_helper().GetProperty("FleetType"); + const auto* value = property.Get(this); + if (value == nullptr) { + return false; + } + + fleet_type = *value; + return true; + } }; diff --git a/mods/src/prime/FleetPlayerData.h b/mods/src/prime/FleetPlayerData.h index 05c5c0fa0..ba031eed4 100644 --- a/mods/src/prime/FleetPlayerData.h +++ b/mods/src/prime/FleetPlayerData.h @@ -1,7 +1,9 @@ #pragma once #include "BattleTargetData.h" +#include "CargoHoldData.h" #include "HullSpec.h" +#include "MiningSlot.h" #include "RecallRequirement.h" #include "CanRepairRequirement.h" @@ -15,19 +17,25 @@ enum class FleetState { Destroyed = 8, TieringUp = 16, Repairing = 32, - CannotLaunch = 56, + CanReplaceOfficers = 18, Battling = 64, WarpCharging = 128, Warping = 256, - CanRemove = 384, - CannotMove = 504, Impulsing = 512, - CanManage = 899, Capturing = 1024, - CanRecall = 1541, - CanEngage = 1543, - Deployed = 1989, - CanLocate = 1991 + AutoHunting = 2048, + Outposting = 4096, + CannotLaunch = 56, + CannotMove = 2552, + CanRecall = 5637, + CanRemove = 384, + CanManage = 2947, + CanLocate = 8135, + Deployed = 8133, + CanEngage = 3591, + CanActivateAbility = 513, + CanBeTargetedByAbility = 3589, + CanDisco = 515 }; struct FleetPlayerData { @@ -36,6 +44,10 @@ struct FleetPlayerData { __declspec(property(get = __get_PreviousState)) FleetState PreviousState; __declspec(property(get = __get_Id)) uint64_t Id; __declspec(property(get = __get_Hull)) HullSpec* Hull; + __declspec(property(get = __get_Index)) int Index; + __declspec(property(get = __get_MiningData)) MiningSlot* MiningData; + __declspec(property(get = __get_CargoHoldData)) ::CargoHoldData* CargoHoldData; + __declspec(property(get = __get_CargoResourceFillLevel)) float CargoResourceFillLevel; __declspec(property(get = __get_Address)) void* Address; __declspec(property(get = __get_Level)) int64_t Level; __declspec(property(get = __get_HasShip)) bool HasShip; @@ -54,6 +66,28 @@ struct FleetPlayerData { static auto field = get_class_helper().GetProperty("Hull"); return field.GetRaw(this); } + int __get_Index() + { + static auto property = get_class_helper().GetProperty("Index"); + auto* value = property.Get(this); + return value ? *value : -1; + } + MiningSlot* __get_MiningData() + { + static auto property = get_class_helper().GetProperty("MiningData"); + return property.GetRaw(this); + } + ::CargoHoldData* __get_CargoHoldData() + { + static auto property = get_class_helper().GetProperty("CargoHoldData"); + return property.GetRaw(this); + } + float __get_CargoResourceFillLevel() + { + static auto property = get_class_helper().GetProperty("CargoResourceFillLevel"); + auto* value = property.Get(this); + return value ? *value : -1.0f; + } void* __get_Address() { static auto field = get_class_helper().GetProperty("Address"); diff --git a/mods/src/prime/FleetsManager.h b/mods/src/prime/FleetsManager.h index df4649868..6555f510b 100644 --- a/mods/src/prime/FleetsManager.h +++ b/mods/src/prime/FleetsManager.h @@ -104,6 +104,42 @@ struct FleetsManager : MonoSingleton { return nullptr; } + bool HasFleetService() + { + static auto* fleet_service_cache = + il2cpp_class_get_field_from_name(get_class_helper().get_cls(), "_fleetServiceCache"); + static auto FleetServiceCacheWarn = true; + if (!fleet_service_cache) { + if (FleetServiceCacheWarn) { + FleetServiceCacheWarn = false; + spdlog::error("Unable to find field 'FleetsManager->_fleetServiceCache'"); + } + return false; + } + + auto* cache = reinterpret_cast(reinterpret_cast(this) + fleet_service_cache->offset); + if (*cache) { + return true; + } + + static auto* HasServiceMethod = [] { + auto* cache_class = il2cpp_class_from_type(il2cpp_field_get_type(fleet_service_cache)); + return cache_class ? il2cpp_class_get_method_from_name(cache_class, "get_HasService", 0) : nullptr; + }(); + static auto HasServiceWarn = true; + if (!HasServiceMethod) { + if (HasServiceWarn) { + HasServiceWarn = false; + ErrorMsg::MissingMethod("CachedService", "get_HasService"); + } + return false; + } + + Il2CppException* exception = nullptr; + auto* result = il2cpp_runtime_invoke(HasServiceMethod, cache, nullptr, &exception); + return !exception && result && *static_cast(il2cpp_object_unbox(result)); + } + FleetDeployedData* __get_TargetFleetData() { static auto field = get_class_helper().GetField("_targetFleetData").offset(); diff --git a/mods/src/prime/MiningSlot.h b/mods/src/prime/MiningSlot.h new file mode 100644 index 000000000..ea9df59b6 --- /dev/null +++ b/mods/src/prime/MiningSlot.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include + +struct MiningSlot { +public: + __declspec(property(get = __get_ResourceId)) int64_t ResourceId; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "MiningSlot"); + return class_helper; + } + +public: + int64_t __get_ResourceId() + { + static auto property = get_class_helper().GetProperty("ResourceId"); + auto* value = property.Get(this); + return value ? *value : 0; + } +}; diff --git a/mods/src/prime/NavigationFleetWidget.h b/mods/src/prime/NavigationFleetWidget.h new file mode 100644 index 000000000..03151b042 --- /dev/null +++ b/mods/src/prime/NavigationFleetWidget.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +#include + +#include "FleetDeployedData.h" +#include "NavigationLOD.h" + +struct NavigationFleetWidget { + __declspec(property(get = __get__lod)) NavigationLOD* _lod; + __declspec(property(get = __get_Context)) FleetDeployedData* Context; + + static FieldInfo* ContextField() + { + static auto* field = [] { + for (auto* cls = get_class_helper().get_cls(); cls != nullptr; cls = il2cpp_class_get_parent(cls)) { + void* iterator = nullptr; + while (auto* candidate = il2cpp_class_get_fields(cls, &iterator)) { + const auto* name = il2cpp_field_get_name(candidate); + if (name != nullptr && std::strcmp(name, "m_context") == 0) { + return candidate; + } + } + } + return static_cast(nullptr); + }(); + return field; + } + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationFleetWidget"); + return class_helper; + } + +public: + NavigationLOD* __get__lod() + { + static auto* field = [] { + auto* cls = get_class_helper().get_cls(); + return cls != nullptr ? il2cpp_class_get_field_from_name(cls, "_lod") : nullptr; + }(); + return field != nullptr ? *(NavigationLOD**)((ptrdiff_t)this + field->offset) : nullptr; + } + + FleetDeployedData* __get_Context() + { + auto* field = ContextField(); + return field != nullptr ? *(FleetDeployedData**)((ptrdiff_t)this + field->offset) : nullptr; + } +}; diff --git a/mods/src/prime/NavigationLOD.h b/mods/src/prime/NavigationLOD.h new file mode 100644 index 000000000..581922b47 --- /dev/null +++ b/mods/src/prime/NavigationLOD.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include "ZoomLevels.h" + +struct NavigationLOD { + void UpdateLOD(ZoomLevels level) + { + static auto update_lod = get_class_helper().GetMethod("UpdateLOD"); + if (update_lod != nullptr) { + update_lod(this, level); + } + } + + void SetTargetLevel(ZoomLevels level) + { + static auto field = get_class_helper().GetField("k__BackingField"); + *(ZoomLevels*)((ptrdiff_t)this + field.offset()) = level; + } + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationLOD"); + return class_helper; + } +}; diff --git a/mods/src/prime/NavigationZoom.h b/mods/src/prime/NavigationZoom.h index bf88d23e6..22cf2fcd7 100644 --- a/mods/src/prime/NavigationZoom.h +++ b/mods/src/prime/NavigationZoom.h @@ -7,6 +7,7 @@ #include "Camera.h" #include "NavigationPan.h" +#include "ZoomLevels.h" enum class NodeDepth { Galaxy = 1, @@ -42,6 +43,8 @@ struct NavigationZoom { __declspec(property(get = __get__zoomtotal, put = __set__zoomtotal)) float _zoomtotal; __declspec(property(get = __get__minimum, put = __set__minimum)) float _minimum; __declspec(property(get = __get__maximum, put = __set__maximum)) float _maximum; + __declspec(property(get = __get__zoomLevel)) ZoomLevels _zoomLevel; + __declspec(property(get = __get_NormalizedZoom)) float NormalizedZoom; __declspec(property(get = __get__zoomDelta, put = __set__zoomDelta)) float _zoomDelta; __declspec(property(get = __get__viewRadius, put = __set__viewRadius)) float _viewRadius; __declspec(property(get = __get__lastZoomDelta, put = __set__lastZoomDelta)) float _lastZoomDelta; @@ -134,6 +137,18 @@ struct NavigationZoom { *(float*)((ptrdiff_t)this + field.offset()) = delta; } + ZoomLevels __get__zoomLevel() + { + static auto field = get_class_helper().GetField("_zoomLevel"); + return *(ZoomLevels*)((ptrdiff_t)this + field.offset()); + } + + float __get_NormalizedZoom() + { + static auto field = get_class_helper().GetProperty("NormalizedZoom"); + return *field.Get(this); + } + float __get__actualDistance() { static auto field = get_class_helper().GetField("_actualDistance"); diff --git a/mods/src/prime/ProgressData.h b/mods/src/prime/ProgressData.h new file mode 100644 index 000000000..94049700d --- /dev/null +++ b/mods/src/prime/ProgressData.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include + +struct ProgressData { +public: + __declspec(property(get = __get_CurrentValue)) double CurrentValue; + __declspec(property(get = __get_MinValue)) double MinValue; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "ProgressData"); + return class_helper; + } + +public: + double __get_CurrentValue() + { + static auto property = get_class_helper().GetProperty("CurrentValue"); + auto* value = property.Get(this); + return value ? *value : std::numeric_limits::quiet_NaN(); + } + double __get_MinValue() + { + static auto property = get_class_helper().GetProperty("MinValue"); + auto* value = property.Get(this); + return value ? *value : std::numeric_limits::quiet_NaN(); + } +}; diff --git a/mods/src/prime/ResourceSpec.h b/mods/src/prime/ResourceSpec.h new file mode 100644 index 000000000..fd3935914 --- /dev/null +++ b/mods/src/prime/ResourceSpec.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +struct ResourceSpec { +public: + __declspec(property(get = __get_Name)) Il2CppString* Name; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "ResourceSpec"); + return class_helper; + } + +public: + Il2CppString* __get_Name() + { + static auto property = get_class_helper().GetProperty("Name"); + return property.GetRaw(this); + } +}; diff --git a/mods/src/prime/ShortcutsManager.h b/mods/src/prime/ShortcutsManager.h new file mode 100644 index 000000000..5529506f2 --- /dev/null +++ b/mods/src/prime/ShortcutsManager.h @@ -0,0 +1,14 @@ +#pragma once + +#include "MonoSingleton.h" + +struct ShortcutsManager : MonoSingleton { + friend struct MonoSingleton; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutsManager"); + return class_helper; + } +}; diff --git a/mods/src/prime/SpecManager.h b/mods/src/prime/SpecManager.h new file mode 100644 index 000000000..1187d385e --- /dev/null +++ b/mods/src/prime/SpecManager.h @@ -0,0 +1,35 @@ +#pragma once + +#include "MonoSingleton.h" +#include "ResourceSpec.h" +#include "errormsg.h" + +#include + +#include + +struct SpecManager : MonoSingleton { + friend struct MonoSingleton; + + ResourceSpec* GetResourceSpec(int64_t id) + { + static auto method = get_class_helper().GetMethod("GetResourceSpec"); + static bool warn = true; + if (method) { + return method(this, id); + } + if (warn) { + warn = false; + ErrorMsg::MissingMethod("SpecManager", "GetResourceSpec"); + } + return nullptr; + } + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation.Managers", "SpecManager"); + return class_helper; + } +}; diff --git a/mods/src/prime/ZoomLevels.h b/mods/src/prime/ZoomLevels.h new file mode 100644 index 000000000..d5b0f1295 --- /dev/null +++ b/mods/src/prime/ZoomLevels.h @@ -0,0 +1,8 @@ +#pragma once + +enum class ZoomLevels { + None = 0, + Near = 1, + Middle = 2, + Far = 3, +}; diff --git a/mods/src/str_utils.h b/mods/src/str_utils.h index a9fbfb7df..89ec079c2 100644 --- a/mods/src/str_utils.h +++ b/mods/src/str_utils.h @@ -57,14 +57,14 @@ constexpr std::string_view StripPrefix(std::string_view str, const std::string_v constexpr std::string AsciiStrToUpper(const std::string_view s) { - std::string str = s.data(); + std::string str{s}; std::ranges::transform(str, str.begin(), ::toupper); return str; } constexpr std::string AsciiStrToLower(const std::string_view s) { - std::string str = s.data(); + std::string str{s}; std::ranges::transform(str, str.begin(), ::tolower); return str; } diff --git a/mods/src/version.h b/mods/src/version.h index 45e94ef44..1f4be728a 100644 --- a/mods/src/version.h +++ b/mods/src/version.h @@ -21,6 +21,35 @@ #define VER_FILE_VERSION VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION, VERSION_PATCH #define VER_FILE_VERSION_STR STRINGIFY(VERSION_MAJOR) "." STRINGIFY(VERSION_MINOR) "." STRINGIFY(VERSION_REVISION) "." STRINGIFY(VERSION_PATCH) +#define STFC_DISTRIBUTION_ID "netniv.stfc-community-mod" + +#ifndef STFC_SOURCE_STATE_ID +#define STFC_SOURCE_STATE_ID "unknown" +#endif + +#ifndef STFC_BASE_COMMIT +#define STFC_BASE_COMMIT "unknown" +#endif + +#ifndef STFC_BUILD_INVOCATION_ID +#define STFC_BUILD_INVOCATION_ID "xmake-direct" +#endif + +#ifndef STFC_BUILD_MODE +#define STFC_BUILD_MODE "unknown" +#endif + +#ifndef STFC_BUILD_CHANNEL +#define STFC_BUILD_CHANNEL "local" +#endif + +#define STFC_IDENTITY_COMMENT_STR "stfc-identity-v1;distribution=" STFC_DISTRIBUTION_ID \ + ";source=" STFC_SOURCE_STATE_ID \ + ";base=" STFC_BASE_COMMIT \ + ";build=" STFC_BUILD_INVOCATION_ID \ + ";mode=" STFC_BUILD_MODE \ + ";channel=" STFC_BUILD_CHANNEL + #define VER_PRODUCTNAME_STR "STFC: Community Mod" #define VER_PRODUCT_VERSION VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION #define VER_PRODUCT_VERSION_STR VER_FILE_VERSION_STR diff --git a/mods/xmake.lua b/mods/xmake.lua index 1a80fc8ab..aee96c5a2 100644 --- a/mods/xmake.lua +++ b/mods/xmake.lua @@ -116,6 +116,7 @@ do if is_plat("windows") then add_cxflags("/bigobj") add_linkdirs("src/il2cpp") + add_syslinks("winmm") elseif is_plat("macosx") then add_cxflags("-fms-extensions") -- Add Objective-C++ source diff --git a/win-proxy-dll/src/version.rc b/win-proxy-dll/src/version.rc index 20154ba4b..86841ff61 100644 --- a/win-proxy-dll/src/version.rc +++ b/win-proxy-dll/src/version.rc @@ -27,6 +27,7 @@ BEGIN BEGIN VALUE "FileDescription", VER_FILE_DESCRIPTION_STR VALUE "FileVersion", VER_FILE_VERSION_STR + VALUE "Comments", STFC_IDENTITY_COMMENT_STR VALUE "InternalName", VER_INTERNAL_NAME_STR VALUE "LegalCopyright", VER_COPYRIGHT_STR VALUE "OriginalFilename", VER_ORIGINAL_FILENAME_STR @@ -39,4 +40,4 @@ BEGIN VALUE "Translation", 0x0409,1200 END END -#endif \ No newline at end of file +#endif diff --git a/win-proxy-dll/xmake.lua b/win-proxy-dll/xmake.lua index 466ab8018..f41d62e52 100644 --- a/win-proxy-dll/xmake.lua +++ b/win-proxy-dll/xmake.lua @@ -1,6 +1,7 @@ target("stfc-community-mod") do set_kind("shared") + add_rules("stfc.identity") add_files("src/*.cc") add_files("src/*.rc") add_files("src/version.def") diff --git a/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt b/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt index eba1509e8..234040179 100644 --- a/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt +++ b/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt @@ -217,6 +217,8 @@ target_sources( "src/detour/detour.cc" "src/detour/detour_impl.h" "src/detour/fwd.h" + "src/detour/target_registry.cc" + "src/detour/target_registry.h" "src/detour/remapper.cc" "src/detour/remapper.h" "src/memory/protection.cc" @@ -238,7 +240,7 @@ if(NOT SPUD_NO_INSTALL) ) endif() if(SPUD_BUILD_TESTS) - set(TEST_SRCS "tests/signature.cc") + set(TEST_SRCS "tests/detour_registry.cc") file( GLOB_RECURSE TEST_DETOUR_SHARED_SRCS tests/detour/shared/*.cc @@ -306,35 +308,35 @@ if(SPUD_BUILD_TESTS) add_executable( "spud.test" ${TEST_SRCS} - "tests/test_util.h" - "tests/signature.cc" ) - target_include_directories("spud.test" PRIVATE "tests") + target_include_directories("spud.test" PRIVATE "tests" "src") target_link_libraries( "spud.test" PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" ) add_dependencies("spud.test" "Catch2" "spud") - add_executable("spud.benchmark" ${BENCHMARK_SRCS}) - target_include_directories("spud.benchmark" PRIVATE "benchmark") - if(SPUD_COMPARE_LIBS) - target_link_libraries( - "spud.benchmark" - PRIVATE - "Catch2" - "Catch2::Catch2WithMain" - "spud" - "lime" - "minhook" - "PolyHook2" - ) - else() - target_link_libraries( - "spud.benchmark" - PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" - ) + if(BENCHMARK_SRCS) + add_executable("spud.benchmark" ${BENCHMARK_SRCS}) + target_include_directories("spud.benchmark" PRIVATE "benchmark") + if(SPUD_COMPARE_LIBS) + target_link_libraries( + "spud.benchmark" + PRIVATE + "Catch2" + "Catch2::Catch2WithMain" + "spud" + "lime" + "minhook" + "PolyHook2" + ) + else() + target_link_libraries( + "spud.benchmark" + PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" + ) + endif() + add_dependencies("spud.benchmark" "Catch2" "spud") + add_test(NAME spud.benchmark COMMAND spud.benchmark) endif() - add_dependencies("spud.benchmark" "Catch2" "spud") add_test(NAME spud.test COMMAND spud.test) - add_test(NAME spud.benchmark COMMAND spud.benchmark) endif() diff --git a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h index 946de5cc1..859370953 100644 --- a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h +++ b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #if __cpp_lib_source_location && SPUD_DETOUR_TRACING @@ -17,6 +18,14 @@ #include "utils.h" namespace spud { + +enum class detour_install_status : uint8_t { + not_installed, + installed, + already_installed, + duplicate_target, +}; + namespace detail { struct detour { @@ -29,27 +38,23 @@ struct detour { return this->trampoline_; } + detour_install_status last_install_status() const { + return last_install_status_; + } + using Self = detail::detour; public: detour(detour &&other) noexcept { - this->trampoline_ = other.trampoline_; - this->address_ = other.address_; - this->func_ = other.func_; - this->wrapper_ = other.wrapper_; - this->context_container_ = std::move(other.context_container_); - this->original_func_data_ = other.original_func_data_; - other.original_func_data_.clear(); + move_from(std::move(other)); } detour &operator=(detour &&other) noexcept { - this->trampoline_ = other.trampoline_; - this->address_ = other.address_; - this->func_ = other.func_; - this->wrapper_ = other.wrapper_; - this->context_container_ = std::move(other.context_container_); - this->original_func_data_ = other.original_func_data_; - other.original_func_data_.clear(); + if (this == &other) { + return *this; + } + remove(); + move_from(std::move(other)); return *this; } @@ -66,24 +71,19 @@ struct detour { #if __cpp_lib_source_location && SPUD_DETOUR_TRACING detour(uintptr_t address, uintptr_t func, uintptr_t wrapper, const std::source_location location = std::source_location::current()) - : address_(address), func_(func), wrapper_(wrapper), + : requested_address_(address), address_(address), func_(func), + wrapper_(wrapper), context_container_(std::make_unique()), location_(location) {} #else detour(uintptr_t address, uintptr_t func, uintptr_t wrapper) - : address_(address), func_(func), wrapper_(wrapper), + : requested_address_(address), address_(address), func_(func), + wrapper_(wrapper), context_container_(std::make_unique()) {} #endif Self &install(Arch arch = Arch::kHost); void remove(); - Self &detach() { - original_func_data_.clear(); - // We are intentionally ignoring the return value here - // TODO(alex): Move this to a global cleanup thing - (void)context_container_.release(); - - return *this; - } + Self &detach(); static uintptr_t get_context_value(); @@ -91,6 +91,22 @@ struct detour { detour(detour const &) = delete; detour &operator=(detour const &) = delete; + void move_from(detour &&other) noexcept { + requested_address_ = other.requested_address_; + address_ = other.address_; + func_ = other.func_; + wrapper_ = other.wrapper_; + context_container_ = std::move(other.context_container_); + trampoline_ = other.trampoline_; + original_func_data_ = std::move(other.original_func_data_); + installed_ = std::exchange(other.installed_, false); + last_install_status_ = other.last_install_status_; + + other.trampoline_ = 0; + other.last_install_status_ = detour_install_status::not_installed; + } + + uintptr_t requested_address_; uintptr_t address_; uintptr_t func_; uintptr_t wrapper_; @@ -98,6 +114,9 @@ struct detour { std::unique_ptr context_container_ = nullptr; uintptr_t trampoline_ = 0; std::vector original_func_data_ = {}; + bool installed_ = false; + detour_install_status last_install_status_ = + detour_install_status::not_installed; #if __cpp_lib_source_location && SPUD_DETOUR_TRACING const std::source_location location_ = std::source_location::current(); #endif diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc b/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc index e5cd87d42..98a5c41d6 100644 --- a/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc @@ -5,11 +5,14 @@ #include "detour_impl.h" #include "remapper.h" +#include "target_registry.h" #if SPUD_OS_APPLE #include #endif +#include +#include #include #if SPUD_OS_APPLE || SPUD_OS_LINUX @@ -22,6 +25,24 @@ extern "C" uintptr_t ASM_FUNC(spud_read_context_value, ()); namespace spud { namespace detail { +namespace { + +void report_conflict(const target_owner &candidate, + const target_owner &incumbent) noexcept { + std::fprintf( + stderr, + "spud: duplicate detour rejected (requested=0x%" PRIxPTR + ", canonical=0x%" PRIxPTR ", replacement=0x%" PRIxPTR + "); existing owner requested=0x%" PRIxPTR ", canonical=0x%" PRIxPTR + ", replacement=0x%" PRIxPTR ")\n", + candidate.requested_address, candidate.canonical_address, + candidate.replacement_address, incumbent.requested_address, + incumbent.canonical_address, incumbent.replacement_address); + std::fflush(stderr); +} + +} // namespace + struct DetourImpl { std::vector (*create_absolute_jump)(uintptr_t target, uintptr_t data); @@ -51,84 +72,127 @@ const static std::array kDetourImpls = { }; detail::detour &detour::install(Arch arch) { + if (installed_) { + last_install_status_ = detour_install_status::already_installed; + return *this; + } + if (context_container_ == nullptr) { + last_install_status_ = detour_install_status::not_installed; + return *this; + } + const auto &impl = kDetourImpls[arch]; // We don't want to hook things that point to a direct jump // This will resolve that jump and instead we hook the underlying function // func_ = impl.maybe_resolve_jump(func_); // TODO(alex): This will break hook stacking most likely right now... - address_ = impl.maybe_resolve_jump(address_); + const auto canonical_address = impl.maybe_resolve_jump(requested_address_); + const target_owner candidate = { + .requested_address = requested_address_, + .canonical_address = canonical_address, + .owner_token = reinterpret_cast(context_container_.get()), + .replacement_address = func_, + }; + const auto claim = detour_target_registry().claim(candidate); + if (claim.status == target_claim_status::conflict) { + last_install_status_ = detour_install_status::duplicate_target; + report_conflict(candidate, claim.incumbent); + return *this; + } + if (claim.status == target_claim_status::already_owned) { + last_install_status_ = detour_install_status::already_installed; + return *this; + } + + address_ = canonical_address; wrapper_ = impl.maybe_resolve_jump(wrapper_); - const auto jump = impl.create_absolute_jump( - wrapper_, reinterpret_cast(context_container_.get())); + bool jit_write_protection_disabled = false; + try { + const auto jump = impl.create_absolute_jump( + wrapper_, reinterpret_cast(context_container_.get())); - auto [relocation_infos, required_trampoline_size] = - impl.collect_relocations(address_, jump.size()); + auto [relocation_infos, required_trampoline_size] = + impl.collect_relocations(address_, jump.size()); - // Required trampoline size is how many bytes we have to take up of the - // original function This will then be used to calculate the expanded size, - // since we do have some instruction replacement and expansion going on + // Required trampoline size is how many bytes we have to take up of the + // original function This will then be used to calculate the expanded size, + // since we do have some instruction replacement and expansion going on - auto trampoline = impl.create_trampoline( - address_ + required_trampoline_size, - {reinterpret_cast(address_), required_trampoline_size}, - relocation_infos); + auto trampoline = impl.create_trampoline( + address_ + required_trampoline_size, + {reinterpret_cast(address_), required_trampoline_size}, + relocation_infos); - // TODO(tashcan): This isn't particularly amazing, we might have to ajdust - // permissions - disable_jit_write_protection(); + // TODO(tashcan): This isn't particularly amazing, we might have to adjust + // permissions + disable_jit_write_protection(); + jit_write_protection_disabled = true; - auto trampoline_address = alloc_executable_memory(trampoline.data.size()); - assert(trampoline_address != nullptr); + auto trampoline_address = alloc_executable_memory(trampoline.data.size()); + assert(trampoline_address != nullptr); - std::memcpy(trampoline_address, trampoline.data.data(), - trampoline.data.size()); - trampoline_ = - trampoline.start + reinterpret_cast(trampoline_address); + std::memcpy(trampoline_address, trampoline.data.data(), + trampoline.data.size()); + trampoline_ = + trampoline.start + reinterpret_cast(trampoline_address); - context_container_->func = func_; - context_container_->trampoline = trampoline_; + context_container_->func = func_; + context_container_->trampoline = trampoline_; #if __cpp_lib_source_location && SPUD_DETOUR_TRACING - context_container_->location = location_; + context_container_->location = location_; #endif - { - const auto copy_size = required_trampoline_size; - original_func_data_.resize(copy_size); - std::memcpy(original_func_data_.data(), reinterpret_cast(address_), - copy_size); - - remapper remap(address_, copy_size); { - SPUD_SCOPED_PROTECTION(remap, copy_size, - mem_protection::READ_WRITE_EXECUTE); - - // This will leave some trashed instructions - // Which is okay for now - // TODO(tashcan): Add some kind of NOP function to make the remaining - // stuff "valid" - std::memcpy(reinterpret_cast(uintptr_t(remap)), jump.data(), - jump.size()); + const auto copy_size = required_trampoline_size; + original_func_data_.resize(copy_size); + std::memcpy(original_func_data_.data(), + reinterpret_cast(address_), copy_size); + + remapper remap(address_, copy_size); + { + SPUD_SCOPED_PROTECTION(remap, copy_size, + mem_protection::READ_WRITE_EXECUTE); + + // This will leave some trashed instructions + // Which is okay for now + // TODO(tashcan): Add some kind of NOP function to make the remaining + // stuff "valid" + std::memcpy(reinterpret_cast(uintptr_t(remap)), jump.data(), + jump.size()); #if SPUD_OS_APPLE - sys_dcache_flush((void *)address_, copy_size); + sys_dcache_flush((void *)address_, copy_size); #endif - } + } #if SPUD_OS_APPLE - sys_icache_invalidate((void *)address_, copy_size); + sys_icache_invalidate((void *)address_, copy_size); #endif - } + } - enable_jit_write_protection(); + enable_jit_write_protection(); + jit_write_protection_disabled = false; + } catch (...) { + if (jit_write_protection_disabled) { + enable_jit_write_protection(); + } + detour_target_registry().release(candidate); + original_func_data_.clear(); + trampoline_ = 0; + address_ = requested_address_; + throw; + } + installed_ = true; + last_install_status_ = detour_install_status::installed; return *this; } void detour::remove() { - assert(context_container_.get() != nullptr); - if (original_func_data_.size() == 0) { + if (!installed_ || original_func_data_.empty()) { return; } + assert(context_container_.get() != nullptr); disable_jit_write_protection(); { @@ -143,6 +207,28 @@ void detour::remove() { enable_jit_write_protection(); original_func_data_.clear(); + detour_target_registry().release({ + .requested_address = requested_address_, + .canonical_address = address_, + .owner_token = reinterpret_cast(context_container_.get()), + .replacement_address = func_, + }); + installed_ = false; + trampoline_ = 0; + address_ = requested_address_; + last_install_status_ = detour_install_status::not_installed; +} + +detour::Self &detour::detach() { + if (!installed_ || original_func_data_.empty()) { + return *this; + } + + original_func_data_.clear(); + // The target remains patched, so both the callback context and target claim + // intentionally live until process exit. + (void)context_container_.release(); + return *this; } uintptr_t detour::get_context_value() { diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc new file mode 100644 index 000000000..ac3397e3f --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc @@ -0,0 +1,78 @@ +#include "target_registry.h" + +#include + +namespace spud::detail +{ + +namespace +{ + std::array target_keys(const target_owner &owner) + { return {owner.requested_address, owner.canonical_address}; } +} // namespace + +target_claim_result target_registry::claim(const target_owner &candidate) +{ + std::lock_guard lock(mutex_); + bool owner_seen = false; + + for (const auto key : target_keys(candidate)) { + const auto entry = targets_.find(key); + if (entry == targets_.end()) { + continue; + } + if (entry->second.owner_token != candidate.owner_token) { + return {target_claim_status::conflict, entry->second}; + } + owner_seen = true; + } + if (owner_seen) { + return {target_claim_status::already_owned, {}}; + } + + std::array inserted{}; + size_t inserted_count = 0; + try { + for (const auto key : target_keys(candidate)) { + if (targets_.contains(key)) { + continue; + } + targets_.emplace(key, candidate); + inserted[inserted_count++] = key; + } + } catch (...) { + for (size_t index = 0; index < inserted_count; ++index) { + targets_.erase(inserted[index]); + } + throw; + } + + return {target_claim_status::claimed, {}}; +} + +void target_registry::release(const target_owner &owner) +{ + std::lock_guard lock(mutex_); + for (const auto key : target_keys(owner)) { + const auto entry = targets_.find(key); + if (entry != targets_.end() && entry->second.owner_token == owner.owner_token) { + targets_.erase(entry); + } + } +} + +size_t target_registry::size() const +{ + std::lock_guard lock(mutex_); + return targets_.size(); +} + +target_registry &detour_target_registry() +{ + // Detours can be destroyed during static teardown, so the registry must + // intentionally outlive every static detour object. + static auto *registry = new target_registry(); + return *registry; +} + +} // namespace spud::detail diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h new file mode 100644 index 000000000..cf8c67f9c --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include + +namespace spud::detail +{ + +struct target_owner { + uintptr_t requested_address; + uintptr_t canonical_address; + uintptr_t owner_token; + uintptr_t replacement_address; +}; + +enum class target_claim_status { + claimed, + already_owned, + conflict, +}; + +struct target_claim_result { + target_claim_status status; + target_owner incumbent{}; +}; + +class target_registry +{ +public: + target_claim_result claim(const target_owner &candidate); + void release(const target_owner &owner); + size_t size() const; + +private: + mutable std::mutex mutex_; + std::unordered_map targets_; +}; + +target_registry &detour_target_registry(); + +} // namespace spud::detail diff --git a/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc b/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc new file mode 100644 index 000000000..189cea49b --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc @@ -0,0 +1,180 @@ +#include + +#include "detour/target_registry.h" + +#include +#include +#include +#include + +#include + +namespace +{ + +#if defined(_MSC_VER) +#define SPUD_TEST_NOINLINE __declspec(noinline) +#else +#define SPUD_TEST_NOINLINE __attribute__((noinline)) +#endif + +SPUD_TEST_NOINLINE int duplicate_target(int value) +{ return value + 1; } + +SPUD_TEST_NOINLINE int move_target(int value) +{ return value + 2; } + +SPUD_TEST_NOINLINE int reinstall_target(int value) +{ return value + 3; } + +SPUD_TEST_NOINLINE int detached_target(int value) +{ return value + 4; } + +int add_ten(int (*original)(int), int value) +{ return original(value) + 10; } + +int add_twenty(int (*original)(int), int value) +{ return original(value) + 20; } + +} // namespace + +TEST_CASE("target registry rejects exact and canonical aliases") +{ + spud::detail::target_registry registry; + const spud::detail::target_owner first = { + .requested_address = 0x1000, + .canonical_address = 0x2000, + .owner_token = 0x3000, + .replacement_address = 0x4000, + }; + + REQUIRE(registry.claim(first).status == spud::detail::target_claim_status::claimed); + REQUIRE(registry.size() == 2); + REQUIRE(registry.claim(first).status == spud::detail::target_claim_status::already_owned); + + const auto exact = registry.claim({ + .requested_address = first.requested_address, + .canonical_address = 0x5000, + .owner_token = 0x6000, + .replacement_address = 0x7000, + }); + REQUIRE(exact.status == spud::detail::target_claim_status::conflict); + REQUIRE(exact.incumbent.owner_token == first.owner_token); + + const auto alias = registry.claim({ + .requested_address = 0x8000, + .canonical_address = first.canonical_address, + .owner_token = 0x9000, + .replacement_address = 0xA000, + }); + REQUIRE(alias.status == spud::detail::target_claim_status::conflict); + REQUIRE(alias.incumbent.owner_token == first.owner_token); + + registry.release(first); + REQUIRE(registry.size() == 0); +} + +TEST_CASE("target registry admits exactly one concurrent owner") +{ + spud::detail::target_registry registry; + std::barrier start(3); + std::atomic_size_t claimed = 0; + std::atomic_size_t conflicts = 0; + + const auto contender = [&](uintptr_t owner) { + start.arrive_and_wait(); + const auto result = registry.claim({ + .requested_address = 0x1000, + .canonical_address = 0x2000, + .owner_token = owner, + .replacement_address = owner + 1, + }); + if (result.status == spud::detail::target_claim_status::claimed) { + claimed.fetch_add(1, std::memory_order_relaxed); + } else if (result.status == spud::detail::target_claim_status::conflict) { + conflicts.fetch_add(1, std::memory_order_relaxed); + } + }; + + std::thread first(contender, 0x3000); + std::thread second(contender, 0x4000); + start.arrive_and_wait(); + first.join(); + second.join(); + + REQUIRE(claimed.load(std::memory_order_relaxed) == 1); + REQUIRE(conflicts.load(std::memory_order_relaxed) == 1); + REQUIRE(registry.size() == 2); +} + +TEST_CASE("duplicate detour preserves the first installed hook") +{ + int (*volatile call_target)(int) = duplicate_target; + REQUIRE(call_target(1) == 2); + + { + auto first = spud::create_detour(&duplicate_target, &add_ten); + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 12); + + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::already_installed); + REQUIRE(call_target(1) == 12); + + auto second = spud::create_detour(&duplicate_target, &add_twenty); + second.install(); + REQUIRE(second.last_install_status() == spud::detour_install_status::duplicate_target); + REQUIRE(second.trampoline() == nullptr); + REQUIRE(call_target(1) == 12); + } + REQUIRE(call_target(1) == 2); +} + +TEST_CASE("moving an installed detour preserves ownership and cleanup") +{ + int (*volatile call_target)(int) = move_target; + REQUIRE(call_target(1) == 3); + { + auto source = spud::create_detour(&move_target, &add_ten); + source.install(); + auto moved = std::move(source); + REQUIRE(moved.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 13); + } + REQUIRE(call_target(1) == 3); +} + +TEST_CASE("normal destruction releases a target for later installation") +{ + int (*volatile call_target)(int) = reinstall_target; + { + auto first = spud::create_detour(&reinstall_target, &add_ten); + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 14); + } + REQUIRE(call_target(1) == 4); + { + auto second = spud::create_detour(&reinstall_target, &add_twenty); + second.install(); + REQUIRE(second.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 24); + } + REQUIRE(call_target(1) == 4); +} + +TEST_CASE("detached hooks retain their target claim") +{ + int (*volatile call_target)(int) = detached_target; + { + auto first = spud::create_detour(&detached_target, &add_ten); + first.install().detach(); + REQUIRE(call_target(1) == 15); + } + + auto second = spud::create_detour(&detached_target, &add_twenty); + second.install(); + REQUIRE(second.last_install_status() == spud::detour_install_status::duplicate_target); + REQUIRE(call_target(1) == 15); +} diff --git a/xmake-requires.lock b/xmake-requires.lock index cad21781a..c10fec04b 100644 --- a/xmake-requires.lock +++ b/xmake-requires.lock @@ -126,11 +126,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-4#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-4" }, ["toml++#f56260b5"] = { repo = { @@ -273,11 +273,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-4#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-4" }, ["toml++#f56260b5"] = { repo = { @@ -384,11 +384,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-4#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-4" }, ["toml++#f56260b5"] = { repo = { @@ -407,4 +407,4 @@ version = "v1.3.2" } } -} \ No newline at end of file +} diff --git a/xmake.lua b/xmake.lua index 10eb4a97c..9b25acbfb 100644 --- a/xmake.lua +++ b/xmake.lua @@ -6,6 +6,8 @@ includes("xmake/dependencies/common.lua") set_languages("c++23") set_runtimes("MT") +includes("xmake/rules/stfc_identity.lua") + if is_plat("windows") then includes("xmake/dependencies/windows.lua") includes("win-proxy-dll") diff --git a/xmake/dependencies/common.lua b/xmake/dependencies/common.lua index 879f03a4d..17ffdc99d 100644 --- a/xmake/dependencies/common.lua +++ b/xmake/dependencies/common.lua @@ -20,7 +20,7 @@ add_requires("toml++") add_requires("nlohmann_json") add_requires("protobuf 35.1") add_requires("cpr", {system = false}) -add_requires("spud v0.2.0-3") +add_requires("spud v0.2.0-4") add_requires("libil2cpp") add_requires("simdutf", {system = false}) diff --git a/xmake/options.lua b/xmake/options.lua index fb6a4e58f..14b204bb3 100644 --- a/xmake/options.lua +++ b/xmake/options.lua @@ -9,3 +9,27 @@ option("use_original_bg") set_description("Keep the original in-game loading screen background (no custom BG replacement, logos still shown)") set_default(false) option_end() + +option("stfc_source_state_id") + set_default(os.getenv("STFC_SOURCE_STATE_ID") or "unknown") + set_showmenu(true) + set_description("Clean commit or dirty source fingerprint embedded in Windows DLL provenance") +option_end() + +option("stfc_base_commit") + set_default(os.getenv("STFC_BASE_COMMIT") or "unknown") + set_showmenu(true) + set_description("Base source commit embedded in Windows DLL provenance") +option_end() + +option("stfc_build_invocation_id") + set_default(os.getenv("STFC_BUILD_INVOCATION_ID") or "xmake-direct") + set_showmenu(true) + set_description("Build correlation identifier embedded in Windows DLL provenance") +option_end() + +option("stfc_build_channel") + set_default(os.getenv("STFC_BUILD_CHANNEL") or "local") + set_showmenu(true) + set_description("Build channel embedded in Windows DLL provenance") +option_end() diff --git a/xmake/rules/stfc_identity.lua b/xmake/rules/stfc_identity.lua new file mode 100644 index 000000000..0506ce18e --- /dev/null +++ b/xmake/rules/stfc_identity.lua @@ -0,0 +1,28 @@ +rule("stfc.identity") + on_load(function(target) + local function identity_define(option_name, fallback) + local value = get_config(option_name) + if value == nil then + value = fallback + end + if type(value) ~= "string" or value == "" then + raise(option_name .. " must not be empty") + end + if #value > 160 then + raise(option_name .. " must not exceed 160 characters") + end + if not value:match("^[A-Za-z0-9%._:+/%-]+$") then + raise(option_name .. " contains unsupported identity characters") + end + return value + end + + target:add("defines", "STFC_SOURCE_STATE_ID=\"" .. + identity_define("stfc_source_state_id", "unknown") .. "\"") + target:add("defines", "STFC_BASE_COMMIT=\"" .. identity_define("stfc_base_commit", "unknown") .. "\"") + target:add("defines", "STFC_BUILD_INVOCATION_ID=\"" .. + identity_define("stfc_build_invocation_id", "xmake-direct") .. "\"") + target:add("defines", "STFC_BUILD_MODE=\"" .. identity_define("mode", "unknown") .. "\"") + target:add("defines", "STFC_BUILD_CHANNEL=\"" .. identity_define("stfc_build_channel", "local") .. "\"") + end) +rule_end()