diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 9183656..0000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @Quantumyilmaz diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 9a350e5..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -updates: - - package-ecosystem: github-actions - directory: / - schedule: - interval: monthly - open-pull-requests-limit: 5 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 1b3cf44..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,32 +0,0 @@ -## Problem and acceptance behavior - - - -## Scope - -- Map/menu: -- Runtime(s): -- Payload changes: -- Dependency changes: -- Save impact: - -## Implementation - - - -## Native evidence - - - -## Verification - -- [ ] Clean Release x64 build -- [ ] Static ABI/signature/target checks -- [ ] Vanilla fallback dispatches exactly once on rejection -- [ ] Gameplay acceptance case -- [ ] Regression cases from `docs/RELEASE_CHECKLIST.md` -- [ ] Package contents and hashes verified - -## Compatibility and rollback - - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 45acd43..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: read - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Release x64 - runs-on: windows-2022 - timeout-minutes: 30 - - steps: - - name: Check out the recursive source tree - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - fetch-depth: 1 - persist-credentials: false - submodules: recursive - - - name: Install Xmake 3.0.9 - uses: xmake-io/github-action-setup-xmake@3a1a5dddfc7fa625d9a698738334bf55655a861a - with: - xmake-version: '3.0.9' - - - name: Verify dependency pins - shell: pwsh - run: ./scripts/Test-SubmodulePins.ps1 - - - name: Verify deterministic recursive source export - shell: pwsh - run: | - $first = Join-Path $env:RUNNER_TEMP 'source-first.zip' - $second = Join-Path $env:RUNNER_TEMP 'source-second.zip' - ./scripts/New-RecursiveSourceArchive.ps1 -OutputPath $first - ./scripts/New-RecursiveSourceArchive.ps1 -OutputPath $second - $firstHash = (Get-FileHash -LiteralPath $first -Algorithm SHA256).Hash - $secondHash = (Get-FileHash -LiteralPath $second -Algorithm SHA256).Hash - if ($firstHash -cne $secondHash) { - throw "Recursive source export is not deterministic: $firstHash != $secondHash" - } - - - name: Configure Release x64 - shell: pwsh - run: xmake f -c -m release -a x64 -p windows -y - - - name: Build - shell: pwsh - run: xmake -r -y TrackQuestSurfaceNativeOnly - - - name: Verify binary payload contract - shell: pwsh - run: | - $payload = Join-Path $env:RUNNER_TEMP 'payload' - $pluginDirectory = Join-Path $payload 'SFSE/Plugins' - New-Item -ItemType Directory -Path $pluginDirectory -Force | Out-Null - Copy-Item -LiteralPath 'build/windows/x64/release/TrackQuestSurfaceNativeOnly.dll' -Destination $pluginDirectory - ./scripts/Test-BinaryPayload.ps1 -PayloadRoot $payload diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 68e7022..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,37 +0,0 @@ -# Project guidance - -This file contains only Track Quest from Map-specific constraints. General QTR -working rules remain outside this repository. - -## Objective - -Releasing Select over an inactive Surface Map quest marker must track exactly -the quest represented by the marker's visible label. Both ordinary -quest-bearing location overlays and standalone large quest markers are in the -0.2.2 baseline. Ambiguous, invalid, unsupported, or stale state must preserve -vanilla input. - -## Boundaries - -- Keep the default release a native SFSE DLL. Do not add SWFs, a Bethesda - plugin, Papyrus, INIs, or bundled Address Library files without an explicitly - reviewed scope change. -- Text is an exact discriminator, never quest identity. Native quest identity - is FormID plus instance ID. -- Never assume marker handles are unique. -- Tracking is core behavior. Repaint is optional and must never undo or gate a - successful track. -- Support only runtimes whose ABIs, layouts, signatures, original targets, - vtables, locking, and caller paths were re-audited against the executable. -- Missing UI members and rejected activation must fail open to vanilla input. -- Detect same-callsite native conflicts before mutating code. -- Builds must have no game, mod-manager, install, or launch side effect. -- Pin release dependencies. A mutable local CommonLib checkout is not release - provenance. -- Add each new map/menu through a focused pull request. Do not opportunistically - broaden menu support while fixing Surface Map. -- Public author identity is exactly `Quantumyilmaz`. Do not add tool or editor - attribution to project metadata. - -See `CONTRIBUTING.md`, `docs/DEVELOPMENT.md`, and -`docs/RELEASE_CHECKLIST.md` before changing runtime code. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index cfd984b..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,40 +0,0 @@ -# Changelog - -## Unreleased - -- Moved reusable input, quest, vector-bound, and Surface Map engine contracts - into focused commits on the QTR CommonLibSF fork. -- Split the native plugin into entrypoint, hook transaction, Star Map input, - and Surface Map ownership/activation units with a real `PCH.h` and QTR - `logger::` usage. -- Tightened native marker capture into an owner-thread copy phase followed by - form resolution, validation, logging, and cache publication from owned data. -- Added pinned Windows CI plus deterministic recursive-source and one-DLL - payload verification. CI does not publish artifacts or claim gameplay proof. -- No development DLL from this refactor is a release candidate until its exact - hash passes the gameplay regression matrix. - -## 0.2.2 — 2026-08-21 - -- Added support for large standalone Surface Map quest markers. -- Preserved duplicate and sentinel marker handles as distinct native rows. -- Matched the hovered marker through exact representation-specific fields and - required one exact quest FormID/instance owner. -- Rejected incomplete ownership generations instead of publishing a partial - cache. -- Gameplay-verified small location-overlay markers, large standalone markers, - quest tracking, and open-map refresh on Starfield 1.16.244.0. - -## 0.2.1 — 2026-08-21 - -- Fixed a confirmed input-release crash caused by a CommonLibSF wrapper whose - declared `QUserEvent` return ABI did not match Starfield 1.16.244. -- Read the incoming `ButtonEvent` user-event field by reference without - creating or releasing a false temporary. - -## 0.2.0 — 2026-08-21 - -- Replaced the early SWF bridge prototype with a DLL-only runtime GFx resolver. -- Added native Select interception, quest-owner capture, tracking, and Surface - Map refresh. -- Withdrawn: contains the fixed `QUserEvent` ABI crash. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index e8250f3..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,79 +0,0 @@ -# Contributing - -Track Quest from Map is runtime-specific native code. A patch that compiles can -still corrupt input state, select the wrong quest instance, or crash inside the -game. Keep changes narrow and evidence-backed. - -## Scope - -The 0.2.2 baseline covers two Surface Map representations: - -- an ordinary location row with a small quest overlay; and -- a standalone type-`0x48` large quest marker. - -New menus, UI assets, persistent data, dependencies, or runtime support are -explicit design changes. Add each map through its own pull request. - -## Prerequisites - -- Windows x64 -- MSVC with C++23 support and a Windows SDK -- Xmake 3.0.9 or newer -- the repository's recursive submodules -- SFSE, the matching Address Library, and a legally installed Starfield for - runtime testing - -Never redistribute a Starfield executable or Bethesda assets. - -## Build - -```powershell -git submodule update --init --recursive -xmake f -c -m release -a x64 -p windows -y -xmake -r -y TrackQuestSurfaceNativeOnly -``` - -Builds always use the pinned `lib/commonlibsf` submodule. The build target must -remain side-effect free. - -## Change workflow - -1. Write the exact user-visible acceptance case. -2. Identify the affected layer: input, GFx inspection, ownership capture, - activation, or repaint. -3. For a native contract change, record exact-runtime disassembly and prove - register/stack ABI, layout, lock, lifetime, and caller behavior. -4. Implement the smallest production change. Keep diagnostics bounded and - isolate disposable instrumentation. -5. Build without deployment and complete the static checks. -6. Test the changed case in-game plus the vanilla-fallback regressions. -7. Record payload, dependency, compatibility, runtime, and save-impact changes. - -Do not weaken the 1.16.244 gate to claim another runtime. Add a separately -audited mapping/build. - -## Pull requests - -Every pull request must stand alone and include: - -- the problem and exact acceptance behavior; -- why the chosen layer is correct; -- supported runtime and dependencies; -- build result; -- ABI/layout/signature evidence where applicable; -- gameplay and regression evidence; -- compatibility, payload, and save-impact changes; -- rollback behavior; and -- a statement that vanilla dispatch still occurs exactly once on every - rejected path. - -Keep style-only refactors separate from behavior changes. In particular, the -tagged 0.2.2 source preserves the exact gameplay-tested prototype layout for -binary provenance; its PCH and logging normalization belongs in a distinct, -no-behavior-change pull request. - -## License and provenance - -Contributions are accepted under the repository license. Do not add code or -assets whose provenance and redistribution terms are unclear. Update -`SOURCE.md` and `THIRD_PARTY_NOTICES.md` when a linked dependency changes. diff --git a/MANIFEST.md b/MANIFEST.md deleted file mode 100644 index a7fdacd..0000000 --- a/MANIFEST.md +++ /dev/null @@ -1,49 +0,0 @@ -# Release verification manifest - -This manifest records the immutable gameplay-tested `v0.2.2` release at source -commit `77671bb579fe882996c44947c30d2756c186ed07`. Development-branch builds -have different source and DLL hashes and are not covered by this release proof. - -- Public name: Track Quest from Map -- Internal plugin/DLL name: TrackQuestSurfaceNativeOnly -- Version: 0.2.2.0 -- Author: Quantumyilmaz -- Target runtime: Steam Starfield 1.16.244.0 -- Required SFSE: 0.2.21 -- Required Address Library: matching 1.16.244.0 database -- Starfield executable SHA-256 used for ABI review: - `7E9ADB1414A8E1B325E5E1F097B9B17B78DEB7EEBEDA37A333351A43A60F9D28` -- Gameplay-tested DLL SHA-256: - `98B558A42E96CCDA5FD63AB57ED4284AFBC05A4C8145F29BFF996766018220E9` -- Release ZIP SHA-256: - `20CB0204C69F81F08ABDD28A13EEAE8A523AEC315A74EC2ED246CD3C8BC58698` -- DLL size: 615,424 bytes -- ZIP size: 243,877 bytes -- Exports: `SFSEPlugin_Load`, `SFSEPlugin_Version` -- Archive payload: exactly - `SFSE/Plugins/TrackQuestSurfaceNativeOnly.dll` -- CommonLibSF-QTR: - `765219c66f58f729f854771a95cdd59bbd76b84b` -- commonlib-shared: - `5470284e964d5510aa001dca3e0bb5548b6356a4` -- `src/SurfaceActivation.cpp` SHA-256: - `F90788B6C7BE9A60D2539EC59AFA551F157199EF03EF5B821A9586910DF61BC5` -- `src/SurfaceActivation.h` SHA-256: - `D374C7E037F27A8CD0BDAF8A989F3275D2DFBB6D0AD7497280C1827175ED2844` -- `src/main.cpp` SHA-256: - `73746B94E7B4BCA9B000C592288FBF6156159AA09AD40D6CC77666505D25EB8C` -- SWFs, plugins, INIs, Papyrus files, Address Library files, PDBs, and save - data: none - -## Gameplay proof - -The exact DLL hash above was loaded through SFSE and verified on 2026-08-21. -The log confirmed successful owner resolution, queueing, tracking, and live -Surface Map rebuild for both: - -- ordinary quest-bearing location overlays; and -- standalone large type-`0x48` quest markers, including nonzero quest instance - IDs. - -This is gameplay proof for the stated cases, not blanket compatibility proof -for other runtimes, map menus, or UI replacements. diff --git a/README.md b/README.md index e26b425..13fbaa2 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,36 @@ # Track Quest from Map -Track Quest from Map is a native SFSE plugin that lets you track the quest -represented by a marker directly from Starfield's map. Version 0.2.2 supports -the **Surface Map**: hover an inactive quest marker and release the normal -**Activate/Select** control. +Track quests directly from Starfield's maps with the normal **Activate/Select** +control. -Both Surface Map representations are supported: +Supported views: -- the small quest overlay attached to a location; and -- the large standalone quest marker. - -The exact 0.2.2 DLL in this repository's release was verified in-game on Steam -Starfield 1.16.244.0 with both representations. Quest tracking and the open-map -marker refresh completed successfully. +- Surface Map +- Galaxy Map +- System Map ## Requirements -- Steam Starfield 1.16.244.0 +- Steam Starfield 1.16.244 - SFSE 0.2.21 -- the Address Library matching Starfield 1.16.244.0 - -Other runtimes are deliberately rejected. The plugin uses reviewed native -layouts and call sites; a Bethesda update requires a separately audited build. +- Address Library for Starfield 1.16.244 ## Installation -Install `TrackQuestFromMap-v0.2.2.zip` with a mod manager. Its complete payload -is: - -```text -SFSE/Plugins/TrackQuestSurfaceNativeOnly.dll -``` - -Launch Starfield through SFSE. - -If upgrading from a pre-0.2.0 prototype, replace the old mod instead of merging -it. Remove its `surfacemap.swf` and `surfacemap_lrg.swf`; version 0.2.2 is -DLL-only and must not be combined with those obsolete prototype files. - -## Compatibility - -Version 0.2.2 ships no SWF, Bethesda plugin, Papyrus script, INI, or Address -Library file. It therefore does not overwrite UI mods. At runtime it reads the -public Surface Map display hierarchy and marker properties. UI replacements -remain compatible when they preserve that contract; missing or incompatible -members fail open to vanilla input. - -Another native plugin patching any of the same three direct call sites is a -hard conflict. All signatures and original targets are checked before any -write, and hook installation is transactional. On a mismatch this plugin -refuses to install its hooks rather than stacking an unknown patch. - -See [Compatibility](docs/COMPATIBILITY.md) for the exact boundary. - -## Safety model - -- Quest identity is captured natively as FormID plus instance ID. -- Displayed text is used only as an exact, within-generation discriminator. It - is never parsed into quest identity. -- Marker handles are not assumed unique; large quest markers can share a - sentinel handle. -- Ambiguous, stale, active, malformed, or unsupported state preserves vanilla - input. -- Quest mutation is queued to SFSE's main thread and revalidated immediately - before the vanilla tracking helper is called. -- Rapid repeated Activate presses cannot toggle the newly tracked quest off. -- The plugin stores no save data. +Install the DLL at: -See [Architecture](docs/ARCHITECTURE.md) for the implementation contracts. + Data/SFSE/Plugins/TrackQuestSurfaceNativeOnly.dll -## Current scope and known limitation - -Only the Surface Map is implemented. Galaxy, system, orbital/planet overview, -and other map views have different native state and marker data. Each new map -will be added through its own reviewed pull request; support will not be guessed -from Surface Map layouts. - -On a small location marker shared by both an active and inactive quest, -Starfield exposes an aggregate active flag. Version 0.2.2 fails open instead of -risking activation of the wrong owner. This mixed-state case remains tracked as -a Surface Map limitation. - -## Uninstallation - -Remove `TrackQuestSurfaceNativeOnly.dll`. The plugin adds no records, scripts, -configuration, or save-baked state, so no clean-save procedure is required. +The legacy DLL filename is retained for upgrades from the Surface Map release. +The mod contains no SWF files and stores no save data. ## Building -Clone recursively so the pinned QTR CommonLibSF revision and its nested -dependency are present: - -```powershell -git clone --recursive https://github.com/QTR-Modding/TrackQuestFromMap.git -cd TrackQuestFromMap -xmake f -c -m release -a x64 -p windows -y -xmake -r -y TrackQuestSurfaceNativeOnly -``` - -The build always consumes the repository-pinned `lib/commonlibsf` submodule. -Building has no install, deploy, mod-manager, or game-launch step. - -The tested 0.2.2 dependency revisions and the separate development dependency -pin are listed in [SOURCE.md](SOURCE.md). A CI build is compile evidence, not a -gameplay-tested release candidate. - -## Development - -Surface Map is the reviewed baseline. New menu support and behavioral changes -belong in focused pull requests with exact runtime evidence and gameplay -verification. See [CONTRIBUTING.md](CONTRIBUTING.md) and the -[release checklist](docs/RELEASE_CHECKLIST.md). + git clone --recursive https://github.com/QTR-Modding/TrackQuestFromMap.git + cd TrackQuestFromMap + xmake f -c -m release -a x64 -p windows -y + xmake -r -y TrackQuestSurfaceNativeOnly ## License -Track Quest from Map is licensed under GPL-3.0-or-later with the CommonLibSF -Modding Exception and GPL-3.0 Linking Exception (with Corresponding Source). -See [COPYING](COPYING), [EXCEPTIONS](EXCEPTIONS), and -[third-party notices](THIRD_PARTY_NOTICES.md). - -## Credits - -- Quantumyilmaz -- CommonLibSF, commonlib-shared, and SFSE maintainers and contributors -- meh321 for Address Library for SFSE Plugins +GPL-3.0-or-later with the exceptions in [EXCEPTIONS](EXCEPTIONS). diff --git a/SOURCE.md b/SOURCE.md deleted file mode 100644 index 448d75a..0000000 --- a/SOURCE.md +++ /dev/null @@ -1,35 +0,0 @@ -# Corresponding source and build provenance - -## Current development branch - -The `refactor/qtr-native-conventions` branch pins QTR CommonLibSF commit -`04a3d88e2925806355000190c9c3a9df586ebf3f`. That five-commit series adds -the verified input-event ABI correction, maps the generic menu button-event -handler, exposes quest-instance tracking state, adds raw engine-vector bounds, -and provides typed Surface Map runtime contracts consumed by the refactor. It -is development provenance, not a claim about the released 0.2.2 DLL. - -## Released 0.2.2 artifact - -The distributed `TrackQuestSurfaceNativeOnly.dll` statically incorporates code -from these exact revisions: - -- [QTR CommonLibSF](https://github.com/QTR-Modding/commonlibsf) commit - `765219c66f58f729f854771a95cdd59bbd76b84b` -- [commonlib-shared](https://github.com/libxse/commonlib-shared) commit - `5470284e964d5510aa001dca3e0bb5548b6356a4` -- [spdlog](https://github.com/gabime/spdlog) v1.16.0, commit - `486b55554f11c9cccc913e11a87085b2a91f706f` - -The Git repository pins CommonLibSF and spdlog directly. CommonLibSF pins its -nested commonlib-shared revision. Xmake builds spdlog from its locked v1.16.0 -package recipe; the additional spdlog gitlink records the corresponding source -revision. - -GitHub's automatic source archives do not expand submodules. Every binary -release must therefore include a separate version-matched source archive with -the project and all three dependency trees expanded. - -The exact gameplay-tested 0.2.2 binary and source hashes are in `MANIFEST.md`. -Build instructions are in `README.md`. Complete license and exception texts -are in `COPYING`, `EXCEPTIONS`, and `LICENSES/`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md deleted file mode 100644 index 8d1a5e2..0000000 --- a/THIRD_PARTY_NOTICES.md +++ /dev/null @@ -1,36 +0,0 @@ -# Third-party notices - -The release DLL statically incorporates the following software. - -## CommonLibSF - -- Project: -- Revision: `765219c66f58f729f854771a95cdd59bbd76b84b` -- License: GPL-3.0-or-later with the CommonLibSF Modding Exception and - GPL-3.0 Linking Exception (with Corresponding Source) -- License files: `LICENSES/CommonLibSF-COPYING.txt` and - `LICENSES/CommonLibSF-EXCEPTIONS.txt` - -## commonlib-shared - -- Project: -- Revision: `5470284e964d5510aa001dca3e0bb5548b6356a4` -- License: GPL-3.0-or-later with its exceptions -- License files: `LICENSES/commonlib-shared-LICENSE.txt` and - `LICENSES/commonlib-shared-EXCEPTIONS.txt` - -## CommonLibSSE-derived portions - -CommonLibSF carries historical CommonLibSSE-derived portions under the MIT -license. The notice is reproduced in `LICENSES/CommonLibSSE-MIT.txt`. - -## spdlog - -- Project: -- Version: 1.16.0 -- Revision: `486b55554f11c9cccc913e11a87085b2a91f706f` -- License: MIT -- License file: `LICENSES/spdlog-MIT.txt` - -SFSE and Address Library are runtime requirements and are not redistributed by -this repository or its binary archive. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index dd2c40d..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,114 +0,0 @@ -# Architecture - -## User contract - -On the visible Surface Map, releasing the normal Select control while hovering -an inactive quest marker queues tracking for exactly the quest represented by -that marker. If identity is not exact, the plugin performs no mod action and -passes the event to vanilla once. - -## Why this is native - -Surface Map Flash data contains visual text, flags, and a marker handle but not -the owning quest FormID/instance pair required by Starfield's tracking helper. -The native marker rows retain owner FormIDs that are dropped before the data is -sent to Flash. The plugin bridges those two views without modifying a SWF. - -## Ownership capture - -The unreleased development refactor preserves version 0.2.2's transactional -Surface Map gather and quest-composition hooks while tightening the capture -boundary. The inner composition callback executes while the -engine holds a PlayerCharacter-owned `BSSpinLock`; it copies only bounded -primitive FormID/instance pairs into fixed-capacity thread-local storage. It -does not allocate, log, look up forms, touch UI, or retain engine pointers in -that callback. - -The engine releases that lock before gather returns. The outer hook then runs -synchronously on the Surface Map state's owner/UI thread, before the vanilla -caller resumes and walks the same marker vector. Unlike the immutable 0.2.2 -release, the refactor first copies every relevant -native row into plugin-owned storage without retaining a pointer or view. Only -after that copy completes does it resolve forms, log, validate, and publish the -generation: - -- handle, type, location/target/active flags; -- the representation-specific raw label fields; and -- exact quest owner keys. - -Rows are kept individually because marker handles are not identities and can -repeat. - -This lifetime is same-thread and call-path bounded; it is not a claim that the -marker vector is mutex-protected or generically thread-safe. Rebuild, refresh, -state transition, and destruction invalidate its native storage. - -## Marker representations - -### Ordinary quest overlay - -An ordinary location marker carries `bHasQuestTarget=true`. Its small quest -overlay displays `sQuestTargetText`, and the inactive row's final contributor -is the owner represented by that exact label. - -### Large standalone quest marker - -A standalone quest marker uses type `0x48`, `bIsLocation=false`, and -`bHasQuestTarget=false`. It displays `sNameText` plus `sExtraText` through the -nameplate path. Such rows can share a sentinel handle, so 0.2.2 matches the -full representation/type/location/text tuple and requires exactly one owner. - -Text remains a byte-for-byte, within-generation discriminator. It is never -parsed and never converted into a quest ID. - -## Input and GFx inspection - -The third transactional hook wraps the reviewed Star Map call to the generic -user-event dispatcher. It acts only on a finite Select release and only while -`SurfaceMap_mc` is visibly active. - -The resolver follows the public Surface Map hierarchy to the marker container -and scans its bounded direct children in reverse display-list order. Vanilla -raises the currently hovered marker to the top. All GFx values and string -pointers remain local to that synchronous call and are copied immediately. - -The event is consumed only after exact native resolution and successful -main-thread task queueing. Every other path calls the original dispatcher once. - -## Activation - -The queued task re-resolves the same FormID/instance pair, requires the quest to -be running, not stopped, and not already tracked, then calls Starfield's -reviewed tracking helper. Because the helper toggles rather than sets, the -second inactive check prevents double releases from turning the quest back off. - -## Repaint - -After tracking is verified, the task reacquires the live Star Map menu and -Surface Map state, validates both vtables, and calls the reviewed Surface Map -refresh handler. Repaint is optional: failure to reacquire or validate it does -not undo successful tracking. - -## Reviewed 1.16.244 contracts - -- Surface rebuild: - `RE::ID::StarMap::SurfaceMapState::RebuildSurfaceMarkers` (95000) `+ 0x77` - to `GatherSurfaceQuestTargets` (95012) -- Quest composition: gather (95012) `+ 0x237` to - `RE::ID::StarMap::ComposeSurfaceQuestTarget` (95013) -- Star Map input: `RE::ID::StarMap::StarMapMenu::OnButtonEvent` (94684) - `+ 0x10C` to `RE::ID::IMenu::OnButtonEvent` (130632) -- Tracking helper: `RE::TESQuest::ToggleTracking` (91440) -- Current Surface Map state: `RE::StarMap::StarMapMenu::GetSurfaceMapState` - (94755) -- Surface Map repaint: `RE::StarMap::SurfaceMapState::Refresh` (95003) -- Primary vtables: `RE::StarMap::StarMapMenu::PRIMARY_VTABLE` (446845) and - `RE::StarMap::SurfaceMapState::PRIMARY_VTABLE` (447074) - -Those reusable APIs, layouts, flags, marker types, and relocation IDs live in -the QTR CommonLibSF fork. The plugin keeps only its chosen callsite offsets and -signatures, the incomplete composition-context offset, GFx member names, and -its matching, caching, and transactional-install policy. - -Detailed offsets and the exact executable hash are retained in source and -`MANIFEST.md`. They are not portable contracts. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md deleted file mode 100644 index 471c7df..0000000 --- a/docs/COMPATIBILITY.md +++ /dev/null @@ -1,60 +0,0 @@ -# Compatibility - -## Payload boundary - -The release contains one SFSE DLL. It does not ship or overwrite: - -- `surfacemap.swf` or any other interface file; -- ESM, ESP, or ESL records; -- Papyrus scripts; -- INI files; or -- Address Library files. - -It stores no save data. - -## UI compatibility - -The plugin reads these public Surface Map concepts at runtime: - -- the visible Surface Map container; -- its map and marker-container children; -- direct marker display objects; -- vanilla hover visibility clips; and -- raw marker-data fields for type, handle, flags, and text. - -A UI replacer is compatible when it preserves that public hierarchy and -contract. Missing, differently typed, oversized, or ambiguous data fails open -to normal input. No UI replacer is patched by this project. - -## Native hook conflicts - -Another DLL that replaces any of the same three reviewed direct calls is a hard -conflict. Version 0.2.2 checks the complete signatures and decoded original -targets before writing. It allocates all branch islands first, verifies each -write, and restores all original calls if a transaction cannot complete. - -## Runtime compatibility - -The 0.2.2 DLL supports only Steam Starfield 1.16.244.0 with SFSE 0.2.21 and the -matching Address Library. It is layout-dependent and refuses other runtimes. - -Adding a runtime requires fresh proof for every relocation, signature, decoded -target, ABI, structure offset, vtable, lock assumption, and caller path. Do not -copy IDs or offsets forward because Address Library contains a number for a new -runtime. - -## Known limitation - -An ordinary location can aggregate multiple quest owners. Its exposed active -flag is an aggregate OR state. If one owner is active while the visible owner is -inactive, 0.2.2 declines the mod action rather than risk choosing incorrectly. -Vanilla input continues. This limitation does not apply to a standalone large -marker, whose active flag and single owner are per quest. - -## Future maps - -Galaxy, system, orbital/planet overview, and other map views do not share the -Surface Map's native row layout or ownership path. Each requires an independent -data-flow trace and its own pull request. New support should reuse only proven -generic primitives such as main-thread activation and transactional hook -installation. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md deleted file mode 100644 index 263a060..0000000 --- a/docs/DEVELOPMENT.md +++ /dev/null @@ -1,75 +0,0 @@ -# Development rules - -## Style - -- C++23, Windows x64, all-extra warnings. -- Match the surrounding file and keep format-only churn out of behavior or ABI - changes. Canonical workspace guidance does not yet choose one indentation or - brace-placement standard, so this repository does not invent one. -- Types and functions use `PascalCase`; variables use `lowerCamelCase`; - constants and enumerators use `kPascalCase`; parameters use `a_name`. -- Use fixed-width integer types at ABI and serialized boundaries. -- Keep translation units responsibility-based and few. Centralize runtime IDs, - offsets, signatures, and bounds. -- Reusable engine mappings belong in QTR CommonLibSF; plugin-specific hooks and - safety policy remain here. -- Missing reusable engine contracts are added as focused, upstream-ready - commits to the QTR CommonLibSF fork and consumed by an exact gitlink. Never - open an upstream CommonLibSF pull request without explicit permission. - -The tagged 0.2.2 source remains byte-identical to the gameplay-tested prototype -source. The development refactor uses the required uppercase `PCH.h`, focused -translation units, and QTR `logger::` style. Its changed DLL hash requires a -fresh gameplay regression before any versioned release. - -## ABI rules - -- State the audited Starfield runtime for every inferred signature or layout. -- Verify callsite bytes and decode the original target. -- Use `static_assert` for exact ABI-sized data. -- Do not call a CommonLib wrapper whose declared ABI disagrees with the - executable. -- Never reintroduce the 0.2.0 copied `QUserEvent` temporary. Starfield - 1.16.244 returns a reference while the superseded wrapper declared a value. - -## Locks, ownership, and threads - -- The quest-composition callback runs under a PlayerCharacter-owned - `BSSpinLock`; perform only bounded primitive/thread-local capture there. -- The post-gather marker vector is owner-thread-only, non-reentrant, and not - protected by that lock. Copy its relevant rows synchronously before the - vanilla caller resumes; never retain a pointer or view. -- Do not allocate, log, resolve forms, or inspect UI inside the composition - callback. Resolve and publish only after native rows are fully owned. -- Copy native and GFx text immediately into bounded owned storage. -- Queue quest mutation through SFSE's main-thread interface. -- Re-resolve FormID plus instance ID and recheck state before calling a toggle - helper. - -## GFx rules - -- Inspect only the documented public hierarchy. -- Validate object types, scalar ranges, visibility, text lengths, and child - counts. -- Never retain a GFx value or borrowed string after its call. -- Never parse localized label text into quest identity. -- Ambiguity preserves vanilla behavior. - -## Hooks and failure behavior - -- Validate all required signatures and targets before mutation. -- Allocate all branch islands before mutation. -- Verify every write. -- Restore and verify all original calls on partial failure. -- A required-hook failure rejects plugin load. -- Optional repaint failure skips only repaint. -- Original input dispatch occurs exactly once unless activation was accepted - and queued. - -## Logging - -- Do not allow a C++ exception to unwind into Starfield. -- Catch allocation and standard exceptions at native callback/task boundaries. -- Do not claim C++ exception handling catches access violations. -- Log identifiers, counts, classification, and rejection reason; do not log the - displayed quest text. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md deleted file mode 100644 index c2d032d..0000000 --- a/docs/RELEASE_CHECKLIST.md +++ /dev/null @@ -1,76 +0,0 @@ -# Release checklist - -## Scope and metadata - -- [ ] Acceptance behavior and menu scope are explicit. -- [ ] Payload, dependency, compatibility, runtime, and save impact are reviewed. -- [ ] No test-only code remains in production. -- [ ] `xmake.lua`, `PluginVersionData`, the load log, changelog, tag, archive, - and release title use the same version. -- [ ] Author is exactly `Quantumyilmaz`; no tool/editor attribution appears. - -## Dependency and runtime pins - -- [ ] CommonLibSF and nested dependency commits are public and recorded. -- [ ] Xmake's package lock is current and reviewed. -- [ ] Starfield, SFSE, Address Library, executable hash, and toolchain are - recorded. -- [ ] Every relocation, callsite, target, ABI, offset, vtable, lock, and caller - assumption is rechecked for the supported runtime. - -## Clean build - -- [ ] Configure and rebuild Release x64 from a clean recursive clone. -- [ ] No build command deploys, installs, enables, or launches anything. -- [ ] Warnings are reviewed. -- [ ] A second clean build in another path matches before claiming reproducible - DLL bytes. -- [ ] Exact source and dependency provenance for the shipped DLL is retained. - -## Binary and archive - -- [ ] PE architecture is x64. -- [ ] Exports are exactly `SFSEPlugin_Load` and `SFSEPlugin_Version`. -- [ ] Imports are only expected MSVC/UCRT and Windows system dependencies. -- [ ] Embedded runtime gate and plugin metadata are inspected. -- [ ] Package DLL hash equals the reviewed build DLL hash. -- [ ] Archive contains exactly - `SFSE/Plugins/TrackQuestSurfaceNativeOnly.dll`. -- [ ] No SWF, plugin record, INI, script, Address Library file, PDB, LIB, EXP, - log, or build tree is present. -- [ ] DLL and archive SHA-256 values are recorded in `MANIFEST.md`. -- [ ] Version-matched corresponding-source archive expands all submodules when - binary distribution requires it. - -## Static compatibility - -- [ ] All required hook signatures and decoded targets pass. -- [ ] Transactional rollback is independently reviewed. -- [ ] Effective UI preserves the public Surface Map path/properties. -- [ ] Same-callsite hard conflicts and unsupported runtimes fail safely. - -## Gameplay matrix - -- [ ] Inactive small quest-bearing location overlay. -- [ ] Inactive standalone large type-`0x48` marker. -- [ ] Already tracked marker never toggles off. -- [ ] Rapid repeated Select. -- [ ] Multiple quests at one location, including mixed active state. -- [ ] Duplicate handles and duplicate labels. -- [ ] Non-quest markers and ordinary location activation. -- [ ] Press, hold, release, and non-Select inputs. -- [ ] Surface Map hidden and other Star Map views. -- [ ] Open/close and marker-provider rebuild cycles. -- [ ] RB/Surface Map transition crash regression. -- [ ] Marker repaint without leaving the map. -- [ ] Safe tracking when repaint cannot run. -- [ ] Vanilla UI and a representative compatible UI replacement. - -## Publication - -- [ ] Tag the exact reviewed commit. -- [ ] Attach the reviewed binary archive, not only GitHub's source archive. -- [ ] Release notes state requirements, payload, UI/native conflicts, save - impact, rollback, and replace-not-merge upgrade instructions. -- [ ] Read back repository privacy, commit, tag, release asset name, size, and - hash from GitHub before calling it published. diff --git a/docs/REPOSITORY_AUTOMATION.md b/docs/REPOSITORY_AUTOMATION.md deleted file mode 100644 index 4265813..0000000 --- a/docs/REPOSITORY_AUTOMATION.md +++ /dev/null @@ -1,40 +0,0 @@ -# Repository automation - -The CI workflow performs three repository checks on Windows Server 2022: - -1. every recursive submodule must be initialized, clean, and checked out at - the commit recorded by its parent repository; -2. two recursive source exports from Git objects must have identical SHA-256 - hashes; and -3. a clean Xmake 3.0.9 Release x64 build must fit the one-DLL payload contract. - -CI is compile and packaging-structure evidence only. It is not gameplay proof, -does not create a release, and does not upload artifacts. - -The private repository is on QTR's GitHub Free plan, which does not enforce the -desired private-repository branch-protection rules. Changes therefore follow a -PR-only project process; the repository does not claim that GitHub currently -enforces that process. - -## Local commands - -```powershell -./scripts/Test-SubmodulePins.ps1 -./scripts/New-RecursiveSourceArchive.ps1 -OutputPath C:\tmp\TrackQuestFromMap-source.zip - -$payload = 'C:\tmp\TrackQuestFromMap-payload' -New-Item -ItemType Directory -Path "$payload\SFSE\Plugins" -Force | Out-Null -Copy-Item .\build\windows\x64\release\TrackQuestSurfaceNativeOnly.dll "$payload\SFSE\Plugins" -./scripts/Test-BinaryPayload.ps1 -PayloadRoot $payload -``` - -The source exporter reads tracked blobs from the root repository and every -pinned recursive submodule. It never packages working-tree files, build output, -or untracked files. Entry order, timestamps, attributes, and compression mode -are fixed so two exports of the same recursive commit graph are byte-identical. -The command refuses to overwrite an existing archive. - -The binary verifier accepts exactly -`SFSE/Plugins/TrackQuestSurfaceNativeOnly.dll`, confirms that it is an AMD64 PE -DLL, and can optionally enforce an expected SHA-256 with `-ExpectedSha256`. -Passing these checks does not make a build a gameplay-tested release candidate. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index 58cbdf8..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,22 +0,0 @@ -# Roadmap - -## Baseline - -- [x] Surface Map small quest-bearing location overlays -- [x] Surface Map large standalone quest markers -- [x] Native quest tracking without SWF replacement -- [x] Live Surface Map rebuild after tracking -- [x] QTR CommonLib-first API layer, real `PCH.h`, focused translation units, - and `logger::` convention on the development branch - -## Candidate pull requests - -- [ ] Resolve the exact inactive visible owner on mixed-active shared location - overlays without using the aggregate active flag. -- [ ] Add galaxy/system-map support after independently tracing its marker - ownership path. -- [ ] Add orbital/planet-overview support after independently tracing its - marker ownership path. - -Each map addition must remain independently reviewable and must preserve -vanilla input on every unsupported or ambiguous path. diff --git a/docs/releases/v0.2.2.md b/docs/releases/v0.2.2.md deleted file mode 100644 index 8708425..0000000 --- a/docs/releases/v0.2.2.md +++ /dev/null @@ -1,51 +0,0 @@ -# Track Quest from Map v0.2.2 - -This is the first gameplay-verified native-only Surface Map release. - -## What works - -- Hover an inactive Surface Map quest marker. -- Release the normal Activate/Select control. -- The represented quest becomes tracked without opening the Missions menu. -- The open Surface Map rebuilds so marker state can update immediately. -- Both small quest-bearing location overlays and large standalone quest markers - are supported. - -## Requirements - -- Steam Starfield 1.16.244.0 -- SFSE 0.2.21 -- Address Library matching Starfield 1.16.244.0 - -## Upgrade warning - -Replace every older prototype; do not merge versions. Pre-native prototypes -included `surfacemap.swf` and `surfacemap_lrg.swf`. Remove those obsolete files. -The v0.2.2 binary archive contains only: - -```text -SFSE/Plugins/TrackQuestSurfaceNativeOnly.dll -``` - -Version 0.2.0 is withdrawn because it contains a confirmed input-release crash. - -## Compatibility - -No interface files, Bethesda plugins, scripts, INIs, or save data are included. -UI replacements must preserve the public Surface Map hierarchy and marker-data -contract. A native plugin patching the same reviewed direct calls is a hard -conflict; v0.2.2 refuses to install its hooks when the original signatures or -targets differ. - -Only Surface Map is supported in this release. Other maps will be added in -separate pull requests after their own native data paths are traced and tested. - -## Verified binary - -- DLL SHA-256: - `98B558A42E96CCDA5FD63AB57ED4284AFBC05A4C8145F29BFF996766018220E9` -- Binary ZIP SHA-256: - `20CB0204C69F81F08ABDD28A13EEAE8A523AEC315A74EC2ED246CD3C8BC58698` - -See the attached `CHECKSUMS.txt` and the repository's `MANIFEST.md` for complete -provenance. diff --git a/lib/commonlibsf b/lib/commonlibsf index 04a3d88..c741c4a 160000 --- a/lib/commonlibsf +++ b/lib/commonlibsf @@ -1 +1 @@ -Subproject commit 04a3d88e2925806355000190c9c3a9df586ebf3f +Subproject commit c741c4a6a29cadf2db1f2eb53a9b62ead060dbd6 diff --git a/scripts/New-RecursiveSourceArchive.ps1 b/scripts/New-RecursiveSourceArchive.ps1 deleted file mode 100644 index 97d318e..0000000 --- a/scripts/New-RecursiveSourceArchive.ps1 +++ /dev/null @@ -1,342 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] - [string] $OutputPath, - - [string] $RepositoryRoot = (Join-Path $PSScriptRoot '..'), - - [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]*$')] - [string] $ArchiveRoot = 'TrackQuestFromMap-source' -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -function New-GitProcess { - param( - [Parameter(Mandatory)] - [string] $WorkingDirectory, - - [Parameter(Mandatory)] - [string[]] $Arguments - ) - - $startInfo = [Diagnostics.ProcessStartInfo]::new() - $startInfo.FileName = 'git' - $startInfo.WorkingDirectory = $WorkingDirectory - $startInfo.UseShellExecute = $false - $startInfo.CreateNoWindow = $true - $startInfo.RedirectStandardInput = $true - $startInfo.RedirectStandardOutput = $true - $startInfo.RedirectStandardError = $true - foreach ($argument in $Arguments) { - $startInfo.ArgumentList.Add($argument) - } - - $process = [Diagnostics.Process]::new() - $process.StartInfo = $startInfo - if (-not $process.Start()) { - throw "Failed to start git in '$WorkingDirectory'." - } - - return $process -} - -function Invoke-GitBytes { - param( - [Parameter(Mandatory)] - [string] $WorkingDirectory, - - [Parameter(Mandatory)] - [string[]] $Arguments - ) - - $process = New-GitProcess -WorkingDirectory $WorkingDirectory -Arguments $Arguments - $memory = [IO.MemoryStream]::new() - try { - $copyTask = $process.StandardOutput.BaseStream.CopyToAsync($memory) - $errorTask = $process.StandardError.ReadToEndAsync() - $process.WaitForExit() - [void] $copyTask.GetAwaiter().GetResult() - $errorText = $errorTask.GetAwaiter().GetResult() - if ($process.ExitCode -ne 0) { - throw "git $($Arguments -join ' ') failed in '$WorkingDirectory':`n$errorText" - } - - return ,$memory.ToArray() - } - finally { - $process.Dispose() - $memory.Dispose() - } -} - -function Split-ZeroTerminatedUtf8 { - param( - [Parameter(Mandatory)] - [byte[]] $Bytes - ) - - $decoder = [Text.UTF8Encoding]::new($false, $true) - $values = [System.Collections.Generic.List[string]]::new() - $start = 0 - for ($index = 0; $index -lt $Bytes.Length; ++$index) { - if ($Bytes[$index] -ne 0) { - continue - } - - if ($index -gt $start) { - $values.Add($decoder.GetString($Bytes, $start, $index - $start)) - } - $start = $index + 1 - } - - if ($start -ne $Bytes.Length) { - throw 'Git emitted a non-terminated -z record.' - } - - return $values -} - -function Resolve-ContainedPath { - param( - [Parameter(Mandatory)] - [string] $Parent, - - [Parameter(Mandatory)] - [string] $Child - ) - - if ([IO.Path]::IsPathRooted($Child) -or $Child.IndexOf([char]0) -ge 0) { - throw "Unsafe repository path '$Child'." - } - - $parentFull = [IO.Path]::GetFullPath($Parent).TrimEnd('\', '/') - $childFull = [IO.Path]::GetFullPath((Join-Path $parentFull $Child)) - $prefix = $parentFull + [IO.Path]::DirectorySeparatorChar - if (-not $childFull.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { - throw "Repository path '$Child' escapes '$Parent'." - } - - return $childFull -} - -function Add-RepositoryTree { - param( - [Parameter(Mandatory)] - [string] $WorkingDirectory, - - [Parameter(Mandatory)] - [string] $ArchivePrefix, - - [Parameter(Mandatory)] - [System.Collections.Generic.SortedDictionary[string, object]] $Entries, - - [Parameter(Mandatory)] - [int] $Depth - ) - - if ($Depth -gt 16) { - throw "Submodule nesting exceeds the supported depth of 16 at '$WorkingDirectory'." - } - - $treeBytes = Invoke-GitBytes -WorkingDirectory $WorkingDirectory -Arguments @( - '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--full-tree', 'HEAD' - ) - foreach ($record in (Split-ZeroTerminatedUtf8 -Bytes $treeBytes)) { - if ($record -notmatch '^(?[0-9]{6}) (?[^ ]+) (?[0-9a-fA-F]{40,64})\t(?.+)$') { - throw "Unexpected git ls-tree record in '$WorkingDirectory': $record" - } - - $mode = $Matches.mode - $type = $Matches.type - $objectId = $Matches.oid.ToLowerInvariant() - $gitPath = $Matches.path - if ($gitPath.Contains('\') -or $gitPath.StartsWith('/') -or ($gitPath.Split('/') -contains '..')) { - throw "Unsafe Git path '$gitPath'." - } - - $archivePath = "$ArchivePrefix/$gitPath" - if ($mode -eq '160000') { - if ($type -ne 'commit') { - throw "Gitlink '$gitPath' does not point to a commit." - } - - $submodulePath = Resolve-ContainedPath -Parent $WorkingDirectory -Child $gitPath - Add-RepositoryTree ` - -WorkingDirectory $submodulePath ` - -ArchivePrefix $archivePath ` - -Entries $Entries ` - -Depth ($Depth + 1) - continue - } - - if ($type -ne 'blob' -or $mode -notin @('100644', '100755', '120000')) { - throw "Unsupported tree entry '$mode $type $gitPath'." - } - if ($Entries.ContainsKey($archivePath)) { - throw "Duplicate archive path '$archivePath'." - } - - $Entries.Add($archivePath, [pscustomobject]@{ - ArchivePath = $archivePath - Mode = $mode - ObjectId = $objectId - WorkingDirectory = $WorkingDirectory - }) - } -} - -function Read-GitBlobBatch { - param( - [Parameter(Mandatory)] - [string] $WorkingDirectory, - - [Parameter(Mandatory)] - [string[]] $ObjectIds - ) - - $uniqueIds = @($ObjectIds | Sort-Object -Unique) - $process = New-GitProcess -WorkingDirectory $WorkingDirectory -Arguments @('cat-file', '--batch') - $memory = [IO.MemoryStream]::new() - try { - $copyTask = $process.StandardOutput.BaseStream.CopyToAsync($memory) - $errorTask = $process.StandardError.ReadToEndAsync() - foreach ($objectId in $uniqueIds) { - $process.StandardInput.WriteLine($objectId) - } - $process.StandardInput.Close() - $process.WaitForExit() - [void] $copyTask.GetAwaiter().GetResult() - $errorText = $errorTask.GetAwaiter().GetResult() - if ($process.ExitCode -ne 0) { - throw "git cat-file --batch failed in '$WorkingDirectory':`n$errorText" - } - - $bytes = $memory.ToArray() - } - finally { - $process.Dispose() - $memory.Dispose() - } - - $result = [System.Collections.Generic.Dictionary[string, byte[]]]::new([StringComparer]::Ordinal) - $offset = 0 - foreach ($expectedId in $uniqueIds) { - $lineEnd = [Array]::IndexOf($bytes, [byte]10, $offset) - if ($lineEnd -lt 0) { - throw "Truncated git cat-file header for $expectedId." - } - - $header = [Text.Encoding]::ASCII.GetString($bytes, $offset, $lineEnd - $offset) - if ($header -notmatch '^(?[0-9a-fA-F]{40,64}) blob (?[0-9]+)$') { - throw "Unexpected git cat-file header '$header'." - } - if ($Matches.oid.ToLowerInvariant() -cne $expectedId) { - throw "git cat-file returned $($Matches.oid) while $expectedId was requested." - } - - $size = [int64]::Parse($Matches.size, [Globalization.CultureInfo]::InvariantCulture) - if ($size -gt [int]::MaxValue) { - throw "Blob $expectedId exceeds the supported per-file size." - } - $offset = $lineEnd + 1 - if ($offset + $size -ge $bytes.Length) { - throw "Truncated git blob $expectedId." - } - - $content = [byte[]]::new([int]$size) - [Array]::Copy($bytes, $offset, $content, 0, [int]$size) - $offset += [int]$size - if ($bytes[$offset] -ne 10) { - throw "Missing git cat-file separator after $expectedId." - } - ++$offset - $result.Add($expectedId, $content) - } - - if ($offset -ne $bytes.Length) { - throw 'git cat-file emitted trailing bytes.' - } - - return ,$result -} - -$root = (Resolve-Path -LiteralPath $RepositoryRoot).Path -& (Join-Path $PSScriptRoot 'Test-SubmodulePins.ps1') -RepositoryRoot $root - -$entries = [System.Collections.Generic.SortedDictionary[string, object]]::new([StringComparer]::Ordinal) -Add-RepositoryTree -WorkingDirectory $root -ArchivePrefix $ArchiveRoot -Entries $entries -Depth 0 -if ($entries.Count -eq 0) { - throw 'The recursive source tree is empty.' -} - -$blobSets = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::OrdinalIgnoreCase) -foreach ($group in ($entries.Values | Group-Object WorkingDirectory)) { - $objectIds = @($group.Group | ForEach-Object { $_.ObjectId }) - $blobSets.Add($group.Name, (Read-GitBlobBatch -WorkingDirectory $group.Name -ObjectIds $objectIds)) -} - -$fullOutputPath = [IO.Path]::GetFullPath($OutputPath) -if (Test-Path -LiteralPath $fullOutputPath) { - throw "Output already exists: '$fullOutputPath'." -} -$outputDirectory = [IO.Path]::GetDirectoryName($fullOutputPath) -if (-not $outputDirectory) { - throw "Output path has no parent directory: '$fullOutputPath'." -} -[IO.Directory]::CreateDirectory($outputDirectory) | Out-Null - -$temporaryPath = Join-Path $outputDirectory ('.' + [IO.Path]::GetFileName($fullOutputPath) + '.' + [Guid]::NewGuid().ToString('N') + '.tmp') -try { - $fileStream = [IO.File]::Open($temporaryPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) - try { - $archive = [IO.Compression.ZipArchive]::new($fileStream, [IO.Compression.ZipArchiveMode]::Create, $true, [Text.Encoding]::UTF8) - try { - $fixedTimestamp = [DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero) - foreach ($source in $entries.Values) { - $entry = $archive.CreateEntry($source.ArchivePath, [IO.Compression.CompressionLevel]::NoCompression) - $entry.LastWriteTime = $fixedTimestamp - - $unixMode = switch ($source.Mode) { - '100755' { 33261 } - '120000' { 41471 } - default { 33188 } - } - $attributes = [uint32]$unixMode -shl 16 - $entry.ExternalAttributes = [BitConverter]::ToInt32([BitConverter]::GetBytes($attributes), 0) - - $entryStream = $entry.Open() - try { - $content = $blobSets[$source.WorkingDirectory][$source.ObjectId] - $entryStream.Write($content, 0, $content.Length) - } - finally { - $entryStream.Dispose() - } - } - } - finally { - $archive.Dispose() - } - } - finally { - $fileStream.Dispose() - } - - [IO.File]::Move($temporaryPath, $fullOutputPath) -} -finally { - if (Test-Path -LiteralPath $temporaryPath) { - Remove-Item -LiteralPath $temporaryPath -Force - } -} - -$rootCommit = (& git -C $root rev-parse HEAD).Trim() -if ($LASTEXITCODE -ne 0) { - throw 'Failed to resolve the root commit after writing the archive.' -} -$hash = (Get-FileHash -LiteralPath $fullOutputPath -Algorithm SHA256).Hash -Write-Host "PASS: exported $($entries.Count) Git blobs from root commit $rootCommit" -Write-Host "SHA-256: $hash" -Write-Host "Archive: $fullOutputPath" diff --git a/scripts/Test-BinaryPayload.ps1 b/scripts/Test-BinaryPayload.ps1 deleted file mode 100644 index 3cc6a83..0000000 --- a/scripts/Test-BinaryPayload.ps1 +++ /dev/null @@ -1,86 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] - [string] $PayloadRoot, - - [string] $ExpectedSha256 -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$root = (Resolve-Path -LiteralPath $PayloadRoot).Path.TrimEnd('\', '/') -$expectedRelativePath = 'SFSE/Plugins/TrackQuestSurfaceNativeOnly.dll' -$expectedFullPath = [IO.Path]::GetFullPath((Join-Path $root $expectedRelativePath)) - -$files = @(Get-ChildItem -LiteralPath $root -Recurse -Force -File) -if ($files.Count -ne 1) { - $found = @($files | ForEach-Object { - [IO.Path]::GetRelativePath($root, $_.FullName).Replace('\', '/') - }) - throw "Payload must contain exactly one file, '$expectedRelativePath'. Found: $($found -join ', ')" -} - -$actualRelativePath = [IO.Path]::GetRelativePath($root, $files[0].FullName).Replace('\', '/') -if ($actualRelativePath -cne $expectedRelativePath) { - throw "Unexpected payload path '$actualRelativePath'; expected '$expectedRelativePath'." -} -if ($files[0].FullName -cne $expectedFullPath) { - throw "Payload path casing or normalization differs from '$expectedRelativePath'." -} -if (($files[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw 'The payload DLL must be a regular file, not a reparse point.' -} -if ($files[0].Length -lt 512) { - throw 'The payload DLL is unexpectedly small.' -} - -$stream = [IO.File]::OpenRead($expectedFullPath) -$reader = $null -try { - $reader = [IO.BinaryReader]::new($stream) - if ($reader.ReadUInt16() -ne 0x5A4D) { - throw 'The payload does not begin with an MZ header.' - } - - $stream.Position = 0x3C - $peOffset = $reader.ReadUInt32() - if ($peOffset -gt ($stream.Length - 24)) { - throw 'The PE header offset is outside the payload.' - } - - $stream.Position = $peOffset - if ($reader.ReadUInt32() -ne 0x00004550) { - throw 'The payload does not contain a valid PE signature.' - } - if ($reader.ReadUInt16() -ne 0x8664) { - throw 'The payload is not an AMD64 PE image.' - } - - $stream.Position = $peOffset + 22 - $characteristics = $reader.ReadUInt16() - if (($characteristics -band 0x2000) -eq 0) { - throw 'The AMD64 PE image is not marked as a DLL.' - } -} -finally { - if ($null -ne $reader) { - $reader.Dispose() - } - else { - $stream.Dispose() - } -} - -$actualHash = (Get-FileHash -LiteralPath $expectedFullPath -Algorithm SHA256).Hash -if ($ExpectedSha256) { - $normalizedExpectedHash = $ExpectedSha256.Trim().ToUpperInvariant() - if ($normalizedExpectedHash -notmatch '^[0-9A-F]{64}$') { - throw 'ExpectedSha256 must contain exactly 64 hexadecimal characters.' - } - if ($actualHash -cne $normalizedExpectedHash) { - throw "Payload SHA-256 is $actualHash; expected $normalizedExpectedHash." - } -} - -Write-Host "PASS: exact native payload contract; SHA-256 $actualHash" diff --git a/scripts/Test-SubmodulePins.ps1 b/scripts/Test-SubmodulePins.ps1 deleted file mode 100644 index ead5d9f..0000000 --- a/scripts/Test-SubmodulePins.ps1 +++ /dev/null @@ -1,140 +0,0 @@ -[CmdletBinding()] -param( - [string] $RepositoryRoot = (Join-Path $PSScriptRoot '..') -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -function Invoke-GitText { - param( - [Parameter(Mandatory)] - [string] $WorkingDirectory, - - [Parameter(Mandatory)] - [string[]] $Arguments - ) - - $output = & git -C $WorkingDirectory @Arguments 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "git $($Arguments -join ' ') failed in '$WorkingDirectory':`n$($output -join [Environment]::NewLine)" - } - - return @($output) -} - -function Get-Gitlinks { - param( - [Parameter(Mandatory)] - [string] $WorkingDirectory - ) - - $records = [System.Collections.Generic.List[object]]::new() - foreach ($line in (Invoke-GitText -WorkingDirectory $WorkingDirectory -Arguments @( - '-c', 'core.quotePath=false', 'ls-tree', '-r', '--full-tree', 'HEAD' - ))) { - if ($line -notmatch '^(?[0-9]{6}) (?[^ ]+) (?[0-9a-fA-F]{40,64})\t(?.+)$') { - throw "Unexpected git ls-tree output in '$WorkingDirectory': $line" - } - - if ($Matches.mode -eq '160000') { - if ($Matches.type -ne 'commit') { - throw "Gitlink '$($Matches.path)' does not point to a commit." - } - - $records.Add([pscustomobject]@{ - ObjectId = $Matches.oid.ToLowerInvariant() - Path = $Matches.path - }) - } - } - - return $records -} - -function Resolve-ContainedPath { - param( - [Parameter(Mandatory)] - [string] $Parent, - - [Parameter(Mandatory)] - [string] $Child - ) - - if ([IO.Path]::IsPathRooted($Child) -or $Child.IndexOf([char]0) -ge 0) { - throw "Unsafe submodule path '$Child'." - } - - $parentFull = [IO.Path]::GetFullPath($Parent).TrimEnd('\', '/') - $childFull = [IO.Path]::GetFullPath((Join-Path $parentFull $Child)) - $prefix = $parentFull + [IO.Path]::DirectorySeparatorChar - if (-not $childFull.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { - throw "Submodule path '$Child' escapes '$Parent'." - } - - return $childFull -} - -function Test-RepositoryLevel { - param( - [Parameter(Mandatory)] - [string] $WorkingDirectory, - - [Parameter(Mandatory)] - [int] $Depth - ) - - if ($Depth -gt 16) { - throw "Submodule nesting exceeds the supported depth of 16 at '$WorkingDirectory'." - } - - $staged = @(Invoke-GitText -WorkingDirectory $WorkingDirectory -Arguments @( - 'diff', '--cached', '--name-only', 'HEAD', '--' - )) - if ($staged.Count -ne 0) { - throw "The index differs from HEAD in '$WorkingDirectory':`n$($staged -join [Environment]::NewLine)" - } - - $unstagedGitmodules = @(Invoke-GitText -WorkingDirectory $WorkingDirectory -Arguments @( - 'diff', '--name-only', '--', '.gitmodules' - )) - if ($unstagedGitmodules.Count -ne 0) { - throw "The tracked .gitmodules file is modified in '$WorkingDirectory'." - } - - foreach ($gitlink in (Get-Gitlinks -WorkingDirectory $WorkingDirectory)) { - $submodulePath = Resolve-ContainedPath -Parent $WorkingDirectory -Child $gitlink.Path - if (-not (Test-Path -LiteralPath $submodulePath -PathType Container)) { - throw "Submodule '$($gitlink.Path)' is not initialized." - } - - $headLines = @(Invoke-GitText -WorkingDirectory $submodulePath -Arguments @( - 'rev-parse', '--verify', 'HEAD' - )) - $actualHead = $headLines[0].Trim().ToLowerInvariant() - if ($actualHead -cne $gitlink.ObjectId) { - throw "Submodule '$($gitlink.Path)' is at $actualHead; HEAD pins $($gitlink.ObjectId)." - } - - $status = @(Invoke-GitText -WorkingDirectory $submodulePath -Arguments @( - 'status', '--porcelain=v1', '--untracked-files=all', '--ignore-submodules=none' - )) - if ($status.Count -ne 0) { - throw "Submodule '$($gitlink.Path)' has tracked or untracked changes:`n$($status -join [Environment]::NewLine)" - } - - Test-RepositoryLevel -WorkingDirectory $submodulePath -Depth ($Depth + 1) - } -} - -$root = (Resolve-Path -LiteralPath $RepositoryRoot).Path -$insideWorkTreeLines = @(Invoke-GitText -WorkingDirectory $root -Arguments @( - 'rev-parse', '--is-inside-work-tree' -)) -$insideWorkTree = $insideWorkTreeLines[0].Trim() -if ($insideWorkTree -cne 'true') { - throw "'$root' is not a Git working tree." -} - -Test-RepositoryLevel -WorkingDirectory $root -Depth 0 -Write-Host 'PASS: every recursive submodule is initialized, clean, and checked out at its HEAD gitlink.' diff --git a/src/GalaxyMap.cpp b/src/GalaxyMap.cpp new file mode 100644 index 0000000..2dfab58 --- /dev/null +++ b/src/GalaxyMap.cpp @@ -0,0 +1,385 @@ +#include "PCH.h" + +#include "GalaxyMap.h" + +#include "QuestTracking.h" + +namespace TrackQuestSurface::GalaxyMap +{ + namespace + { + constexpr std::ptrdiff_t kComposeQuestOffset = 0x8; + constexpr std::size_t kMaximumCapturedTargets = 4096; + constexpr std::size_t kMaximumQuestTargetTextBytes = 4096; + + struct CapturedRecord + { + std::uint32_t systemLocationID{}; + std::uint32_t bodyLocationID{}; + RE::QuestInstanceKey quest{}; + RE::BSFixedString questTargetText; + bool questActive{}; + }; + + struct GenerationCapture + { + std::array records{}; + std::size_t count{}; + std::uint64_t generation{}; + bool invalid{}; + + void Reset(const std::uint64_t a_generation) noexcept + { + for (std::size_t index = 0; index < count; ++index) { + records[index] = {}; + } + count = 0; + generation = a_generation; + invalid = false; + } + + void Add(const CapturedRecord& a_record) noexcept + { + if (count >= records.size()) { + invalid = true; + return; + } + records[count++] = a_record; + } + }; + + struct MarkerRecord + { + std::uint32_t systemLocationID{}; + std::uint32_t bodyLocationID{}; + RE::QuestInstanceKey quest{}; + std::string questTargetText; + bool questActive{}; + }; + + BuildQuestTargetTree originalBuildQuestTargetTree{}; + ComposeQuestTargetMarker originalComposeQuestTargetMarker{}; + InsertQuestTargetMarker originalInsertQuestTargetMarker{}; + bool refreshValidated{}; + + thread_local GenerationCapture threadCapture; + thread_local GenerationCapture* activeCapture{}; + thread_local RE::QuestInstanceKey activeContributor{}; + thread_local bool contributorValid{}; + thread_local std::size_t buildDepth{}; + thread_local std::size_t composeDepth{}; + + std::atomic nextGeneration{ 1 }; + std::atomic latestStartedGeneration{}; + std::mutex cacheMutex; + std::uint64_t publishedGeneration{}; + std::vector markerCache; + + template + [[nodiscard]] T ReadAt(const void* a_base, const std::ptrdiff_t a_offset) noexcept + { + T result{}; + std::memcpy( + std::addressof(result), + static_cast(a_base) + a_offset, + sizeof(result)); + return result; + } + + void ClearLatestGeneration(const std::uint64_t a_generation) noexcept + { + try { + std::scoped_lock lock{ cacheMutex }; + if (latestStartedGeneration.load(std::memory_order_acquire) == a_generation) { + markerCache.clear(); + publishedGeneration = 0; + } + } catch (...) { + } + } + + void Publish(GenerationCapture& a_capture) noexcept + { + if (a_capture.invalid || + latestStartedGeneration.load(std::memory_order_acquire) != a_capture.generation) { + ClearLatestGeneration(a_capture.generation); + return; + } + + try { + std::vector next; + next.reserve(a_capture.count); + for (std::size_t index = 0; index < a_capture.count; ++index) { + const auto& captured = a_capture.records[index]; + next.push_back(MarkerRecord{ + .systemLocationID = captured.systemLocationID, + .bodyLocationID = captured.bodyLocationID, + .quest = captured.quest, + .questTargetText = std::string{ + captured.questTargetText.c_str(), + captured.questTargetText.length() }, + .questActive = captured.questActive }); + } + + std::scoped_lock lock{ cacheMutex }; + if (latestStartedGeneration.load(std::memory_order_acquire) != a_capture.generation) { + return; + } + markerCache = std::move(next); + publishedGeneration = a_capture.generation; + } catch (...) { + ClearLatestGeneration(a_capture.generation); + a_capture.invalid = true; + } + } + + [[nodiscard]] std::optional ResolveRequest( + const Request& a_request) noexcept + { + std::optional resolved; + try { + std::scoped_lock lock{ cacheMutex }; + const auto latest = latestStartedGeneration.load(std::memory_order_acquire); + if (publishedGeneration == 0 || publishedGeneration != latest) { + return std::nullopt; + } + + if (a_request.view == View::kGalaxy) { + const MarkerRecord* displayed{}; + for (const auto& marker : markerCache) { + if (marker.systemLocationID != a_request.markerLocationID) { + continue; + } + if (marker.questActive) { + return std::nullopt; + } + if (!displayed || marker.bodyLocationID > displayed->bodyLocationID) { + displayed = std::addressof(marker); + } + } + if (!displayed || displayed->questTargetText != a_request.questTargetText) { + return std::nullopt; + } + resolved = displayed->quest; + } else { + for (const auto& marker : markerCache) { + if (marker.bodyLocationID != a_request.markerLocationID || + marker.questTargetText != a_request.questTargetText || + marker.questActive) { + continue; + } + if (resolved && *resolved != marker.quest) { + return std::nullopt; + } + resolved = marker.quest; + } + } + } catch (...) { + return std::nullopt; + } + return resolved; + } + + void RefreshCurrentStarMap() noexcept + { + if (!refreshValidated) { + return; + } + + try { + auto* ui = RE::UI::GetSingleton(); + if (!ui) { + return; + } + + const RE::BSFixedString menuName{ RE::StarMap::StarMapMenu::MENU_NAME.data() }; + auto menu = ui->GetMenu(menuName); + if (!menu) { + return; + } + + std::uintptr_t menuVtable{}; + std::memcpy(std::addressof(menuVtable), menu.get(), sizeof(menuVtable)); + if (menuVtable != RE::StarMap::StarMapMenu::PRIMARY_VTABLE.address()) { + logger::warn("Skipped Galaxy/System repaint: unexpected StarMapMenu vtable"); + return; + } + + auto* starMapMenu = static_cast(menu.get()); + starMapMenu->RefreshQuestTargets(); + logger::info("Refreshed live Star Map states after quest activation"); + } catch (const std::exception& error) { + try { + logger::warn("Galaxy/System repaint failed safely: {}", error.what()); + } catch (...) { + } + } catch (...) { + try { + logger::warn("Galaxy/System repaint failed safely"); + } catch (...) { + } + } + } + } + + void SetOriginalFunctions( + const BuildQuestTargetTree a_buildQuestTargetTree, + const ComposeQuestTargetMarker a_composeQuestTargetMarker, + const InsertQuestTargetMarker a_insertQuestTargetMarker, + const bool a_refreshValidated) noexcept + { + originalBuildQuestTargetTree = a_buildQuestTargetTree; + originalComposeQuestTargetMarker = a_composeQuestTargetMarker; + originalInsertQuestTargetMarker = a_insertQuestTargetMarker; + refreshValidated = a_refreshValidated; + } + + void BuildAndPublishQuestTargetTree(void* a_playerCharacter, void* a_outputTree) noexcept + { + if (!originalBuildQuestTargetTree) { + std::terminate(); + } + + ++buildDepth; + if (buildDepth != 1) { + if (activeCapture) { + activeCapture->invalid = true; + } + originalBuildQuestTargetTree(a_playerCharacter, a_outputTree); + --buildDepth; + return; + } + + const auto generation = nextGeneration.fetch_add(1, std::memory_order_acq_rel); + latestStartedGeneration.store(generation, std::memory_order_release); + threadCapture.Reset(generation); + activeCapture = std::addressof(threadCapture); + originalBuildQuestTargetTree(a_playerCharacter, a_outputTree); + activeCapture = nullptr; + --buildDepth; + + Publish(threadCapture); + try { + logger::info( + "Captured Star Map quest-target generation {}: records={}, valid={}", + generation, + threadCapture.count, + !threadCapture.invalid); + } catch (...) { + } + } + + bool CaptureAndComposeQuestTargetMarker(void* a_context, void* a_target) noexcept + { + if (!originalComposeQuestTargetMarker) { + std::terminate(); + } + + ++composeDepth; + if (!activeCapture || buildDepth != 1 || composeDepth != 1) { + if (activeCapture && composeDepth != 1) { + activeCapture->invalid = true; + } + const bool result = originalComposeQuestTargetMarker(a_context, a_target); + --composeDepth; + return result; + } + + contributorValid = false; + const auto* quest = a_context ? + ReadAt(a_context, kComposeQuestOffset) : + nullptr; + if (quest) { + activeContributor = quest->GetInstanceKey(); + contributorValid = activeContributor.formID != 0; + } + if (!contributorValid) { + activeCapture->invalid = true; + } + + const bool result = originalComposeQuestTargetMarker(a_context, a_target); + contributorValid = false; + --composeDepth; + return result; + } + + void* CaptureInsertedQuestTargetMarker( + void* const a_nestedTree, + InsertResult* const a_result, + RE::StarMap::QuestTargetMarkerData* const a_markerData) noexcept + { + if (!originalInsertQuestTargetMarker) { + std::terminate(); + } + + CapturedRecord captured{}; + bool captureValid = false; + const bool captureExpected = + activeCapture && buildDepth == 1 && composeDepth == 1; + if (captureExpected && + (!contributorValid || !a_nestedTree || !a_result || !a_markerData)) { + activeCapture->invalid = true; + } + if (captureExpected && contributorValid && a_nestedTree && a_result && a_markerData) { + captured.systemLocationID = ReadAt(a_nestedTree, -0x8); + captured.bodyLocationID = a_markerData->bodyLocationID; + captured.quest = activeContributor; + if (a_markerData->questActive <= 1 && + captured.systemLocationID != 0 && captured.bodyLocationID != 0 && + a_markerData->questTargetText.length() <= kMaximumQuestTargetTextBytes) { + captured.questTargetText = a_markerData->questTargetText; + captured.questActive = a_markerData->questActive == 1; + captureValid = true; + } else { + activeCapture->invalid = true; + } + } + + void* const result = originalInsertQuestTargetMarker(a_nestedTree, a_result, a_markerData); + if (captureValid && a_result->inserted) { + activeCapture->Add(captured); + } + return result; + } + + bool TryActivate(const Request& a_request) noexcept + { + try { + const auto key = ResolveRequest(a_request); + if (!key) { + logger::info( + "Rejected {} marker {}: no exact inactive quest-target ownership match", + a_request.view == View::kGalaxy ? "Galaxy" : "System", + a_request.markerLocationID); + return false; + } + + const auto source = a_request.view == View::kGalaxy ? + QuestTracking::Source::kGalaxy : + QuestTracking::Source::kSystem; + if (!QuestTracking::QueueTrack(*key, source, RefreshCurrentStarMap)) { + return false; + } + + logger::info( + "Accepted {} marker {}: quest=0x{:08X}, instance={}, labelBytes={}", + a_request.view == View::kGalaxy ? "Galaxy" : "System", + a_request.markerLocationID, + key->formID, + key->instanceID, + a_request.questTargetText.size()); + return true; + } catch (const std::exception& error) { + try { + logger::error("Galaxy/System quest activation failed: {}", error.what()); + } catch (...) { + } + } catch (...) { + try { + logger::error("Galaxy/System quest activation failed unexpectedly"); + } catch (...) { + } + } + return false; + } +} diff --git a/src/GalaxyMap.h b/src/GalaxyMap.h new file mode 100644 index 0000000..36f8340 --- /dev/null +++ b/src/GalaxyMap.h @@ -0,0 +1,53 @@ +#pragma once + +#include "RE/S/StarMap.h" + +#include + +namespace TrackQuestSurface::GalaxyMap +{ + enum class View : std::uint8_t + { + kGalaxy, + kSystem + }; + + struct Request + { + View view{}; + std::uint32_t markerLocationID{}; + std::string questTargetText; + }; + + struct InsertResult + { + void* node{}; + bool inserted{}; + std::byte pad009[0x7]{}; + }; + static_assert(sizeof(InsertResult) == 0x10); + + using BuildQuestTargetTree = void (*)(void*, void*); + using ComposeQuestTargetMarker = bool (*)(void*, void*); + using InsertQuestTargetMarker = void* (*)(void*, + InsertResult*, + RE::StarMap::QuestTargetMarkerData*); + + void SetOriginalFunctions( + BuildQuestTargetTree a_buildQuestTargetTree, + ComposeQuestTargetMarker a_composeQuestTargetMarker, + InsertQuestTargetMarker a_insertQuestTargetMarker, + bool a_refreshValidated) noexcept; + + // ABI thunks installed only at reviewed Starfield 1.16.244 direct CALL sites. + void BuildAndPublishQuestTargetTree(void* a_playerCharacter, void* a_outputTree) noexcept; + [[nodiscard]] bool CaptureAndComposeQuestTargetMarker(void* a_context, void* a_target) noexcept; + [[nodiscard]] void* CaptureInsertedQuestTargetMarker( + void* a_nestedTree, + InsertResult* a_result, + RE::StarMap::QuestTargetMarkerData* a_markerData) noexcept; + + // Returns true only when the exact inactive quest represented by the visible + // Galaxy/System mission label was accepted for the main-thread task queue. + [[nodiscard]] bool TryActivate(const Request& a_request) noexcept; +} diff --git a/src/Hooks.cpp b/src/Hooks.cpp index 56a3ef0..31aff99 100644 --- a/src/Hooks.cpp +++ b/src/Hooks.cpp @@ -2,6 +2,7 @@ #include "Hooks.h" +#include "GalaxyMap.h" #include "StarMapInput.h" #include "SurfaceMap.h" @@ -9,10 +10,31 @@ namespace TrackQuestSurface::Hooks { namespace { - // Reviewed against Starfield.exe 1.16.244.0 and its v5 Address Library. - constexpr std::ptrdiff_t kQuestGatherCallOffset = 0x77; - constexpr std::ptrdiff_t kQuestComposeCallOffset = 0x237; + // Plugin-specific direct CALL sites reviewed against Starfield.exe + // 1.16.244. Reusable function identities and layouts live in QTR CommonLib. + constexpr std::ptrdiff_t kSurfaceGatherCallOffset = 0x77; + constexpr std::ptrdiff_t kSurfaceComposeCallOffset = 0x237; constexpr std::ptrdiff_t kStarMapInputCallOffset = 0x10C; + constexpr std::ptrdiff_t kQuestTreeComposeCallOffset = 0x118; + constexpr std::ptrdiff_t kQuestTreeInsertCallOffsetA = 0x38D; + constexpr std::ptrdiff_t kQuestTreeInsertCallOffsetB = 0x580; + + constexpr std::array kQuestTreeCallRvas{ + 0x16A0FE8, + 0x16AE219, + 0x16B78AF, + 0x16B9420, + 0x16BA07C, + 0x16BB1DA, + 0x16BE91E + }; + + struct CallPatch + { + std::uintptr_t address{}; + std::uintptr_t branch{}; + std::array original{}; + }; [[nodiscard]] bool IsRel32Reachable( const std::uintptr_t a_callsite, @@ -36,66 +58,152 @@ namespace TrackQuestSurface::Hooks template [[nodiscard]] bool Matches( - const std::uintptr_t a_address, + const std::uintptr_t a_address, const std::array& a_bytes) noexcept { return std::memcmp( - reinterpret_cast(a_address), - a_bytes.data(), - a_bytes.size()) == 0; + reinterpret_cast(a_address), + a_bytes.data(), + a_bytes.size()) == 0; } template [[nodiscard]] bool Restore( - const std::uintptr_t a_address, + const std::uintptr_t a_address, const std::array& a_original) noexcept { return REL::WriteSafe(a_address, a_original.data(), a_original.size()) && Matches(a_address, a_original); } + + [[nodiscard]] bool ValidateCallTarget( + const std::uintptr_t a_callsite, + const std::uintptr_t a_expected, + const std::string_view a_name) + { + const auto actual = REL::ASM::CALL5::TARGET(a_callsite); + if (actual == a_expected) { + return true; + } + logger::error( + "{} target mismatch: expected 0x{:X}, found 0x{:X}", + a_name, + a_expected, + actual); + return false; + } } bool Install() noexcept { try { - const auto gatherCallsite = + const auto surfaceGatherCallsite = RE::ID::StarMap::SurfaceMapState::RebuildSurfaceMarkers.address() + - kQuestGatherCallOffset; - const auto composeCallsite = + kSurfaceGatherCallOffset; + const auto surfaceComposeCallsite = RE::ID::StarMap::SurfaceMapState::GatherSurfaceQuestTargets.address() + - kQuestComposeCallOffset; + kSurfaceComposeCallOffset; const auto inputCallsite = RE::ID::StarMap::StarMapMenu::OnButtonEvent.address() + kStarMapInputCallOffset; - const auto expectedGatherTarget = + const auto questTreeBuildTarget = RE::ID::StarMap::BuildQuestTargetTree.address(); + const auto questTreeComposeTarget = RE::ID::StarMap::ComposeQuestTargetMarker.address(); + const auto questTreeInsertTarget = RE::ID::StarMap::InsertQuestTargetMarker.address(); + const auto questTreeComposeCallsite = + questTreeBuildTarget + kQuestTreeComposeCallOffset; + const auto questTreeInsertCallsiteA = + questTreeComposeTarget + kQuestTreeInsertCallOffsetA; + const auto questTreeInsertCallsiteB = + questTreeComposeTarget + kQuestTreeInsertCallOffsetB; + + std::array questTreeCallsites{}; + for (std::size_t index = 0; index < kQuestTreeCallRvas.size(); ++index) { + questTreeCallsites[index] = REL::Offset{ kQuestTreeCallRvas[index] }.address(); + } + + const auto surfaceGatherTarget = RE::ID::StarMap::SurfaceMapState::GatherSurfaceQuestTargets.address(); - const auto expectedComposeTarget = + const auto surfaceComposeTarget = RE::ID::StarMap::ComposeSurfaceQuestTarget.address(); - const auto expectedInputTarget = RE::ID::IMenu::OnButtonEvent.address(); + const auto inputTarget = RE::ID::IMenu::OnButtonEvent.address(); if (!REL::Pattern< "48 8B CF E8 E4 27 00 00 48 83 BF E8 08 00 00 00">() - .match(gatherCallsite - 3)) { - logger::error("SurfaceMap gather-hook signature mismatch at 0x{:X}", gatherCallsite); - return false; - } - if (!REL::Pattern<"48 8B 13 48 8D 4D C7 E8 D4 00 00 00">() - .match(composeCallsite - 7)) { - logger::error("SurfaceMap compose-hook signature mismatch at 0x{:X}", composeCallsite); + .match(surfaceGatherCallsite - 3) || + !REL::Pattern<"48 8B 13 48 8D 4D C7 E8 D4 00 00 00">() + .match(surfaceComposeCallsite - 7)) { + logger::error("Surface Map ownership-hook signature mismatch"); return false; } if (!REL::Pattern< "77 0F 84 C0 75 0B 48 8B D3 49 8B CF E8 6F 8F E9 00 40 84 ED">() - .match(inputCallsite - 12)) { - logger::error("Star Map input-hook signature mismatch at 0x{:X}", inputCallsite); + .match(inputCallsite - 12) || + !REL::Pattern< + "48 89 5C 24 18 55 56 57 41 54 41 55 41 56 41 57">() + .match(inputTarget)) { + logger::error("Star Map input-hook signature mismatch"); return false; } + + for (std::size_t index = 0; index + 1 < questTreeCallsites.size(); ++index) { + if (!REL::Pattern< + "48 8D 54 24 ?? 48 8B 0D ?? ?? ?? ?? E8 ?? ?? ?? ??">() + .match(questTreeCallsites[index] - 12)) { + logger::error( + "Star Map quest-tree caller {} signature mismatch at 0x{:X}", + index, + questTreeCallsites[index]); + return false; + } + } if (!REL::Pattern< - "48 89 5C 24 18 55 56 57 41 54 41 55 41 56 41 57">() - .match(expectedInputTarget)) { - logger::error( - "Star Map vanilla dispatcher signature mismatch at 0x{:X}", - expectedInputTarget); + "48 8D 55 B0 48 8B 0D ?? ?? ?? ?? E8 ?? ?? ?? ??">() + .match(questTreeCallsites.back() - 11) || + !REL::Pattern< + "48 8B 13 48 8D 4C 24 20 E8 83 A1 FF FF 85 C0">() + .match(questTreeComposeCallsite - 8) || + !REL::Pattern< + "4C 8D 45 A0 48 8D 55 00 48 8D 4B 28 E8 3E 58 00 00 90">() + .match(questTreeInsertCallsiteA - 12) || + !REL::Pattern< + "4C 8D 45 B8 48 8D 55 20 48 8D 4B 28 E8 4B 56 00 00 90">() + .match(questTreeInsertCallsiteB - 12) || + !REL::Pattern< + "48 89 54 24 10 48 89 4C 24 08 53 55 56 57 41 54">() + .match(questTreeBuildTarget)) { + logger::error("Star Map quest-tree ownership-hook signature mismatch"); + return false; + } + + for (std::size_t index = 0; index < questTreeCallsites.size(); ++index) { + if (!ValidateCallTarget( + questTreeCallsites[index], + questTreeBuildTarget, + "Star Map quest-tree caller")) { + return false; + } + } + if (!ValidateCallTarget( + questTreeComposeCallsite, + questTreeComposeTarget, + "Star Map quest-tree compose") || + !ValidateCallTarget( + questTreeInsertCallsiteA, + questTreeInsertTarget, + "Star Map quest-tree insert A") || + !ValidateCallTarget( + questTreeInsertCallsiteB, + questTreeInsertTarget, + "Star Map quest-tree insert B") || + !ValidateCallTarget( + surfaceGatherCallsite, + surfaceGatherTarget, + "Surface Map gather") || + !ValidateCallTarget( + surfaceComposeCallsite, + surfaceComposeTarget, + "Surface Map compose") || + !ValidateCallTarget(inputCallsite, inputTarget, "Star Map input")) { return false; } @@ -108,151 +216,128 @@ namespace TrackQuestSurface::Hooks .match(RE::ID::StarMap::SurfaceMapState::Refresh.address()); if (!surfaceRefreshValidated) { logger::warn( - "Surface Map repaint signatures do not match; quest tracking will remain enabled but visual refresh is disabled"); - } - - const auto actualGatherTarget = REL::ASM::CALL5::TARGET(gatherCallsite); - if (actualGatherTarget != expectedGatherTarget) { - logger::error( - "SurfaceMap gather-hook target mismatch: expected 0x{:X}, found 0x{:X}", - expectedGatherTarget, - actualGatherTarget); - return false; + "Surface Map repaint signatures do not match; tracking stays enabled but forced repaint is disabled"); } - const auto actualComposeTarget = REL::ASM::CALL5::TARGET(composeCallsite); - if (actualComposeTarget != expectedComposeTarget) { - logger::error( - "SurfaceMap compose-hook target mismatch: expected 0x{:X}, found 0x{:X}", - expectedComposeTarget, - actualComposeTarget); - return false; - } - const auto actualInputTarget = REL::ASM::CALL5::TARGET(inputCallsite); - if (actualInputTarget != expectedInputTarget) { - logger::error( - "Star Map input-hook target mismatch: expected 0x{:X}, found 0x{:X}", - expectedInputTarget, - actualInputTarget); - return false; + const bool starMapRefreshValidated = + REL::Pattern< + "48 89 5C 24 10 48 89 74 24 18 57 48 83 EC 30">() + .match(RE::ID::StarMap::StarMapMenu::RefreshQuestTargets.address()); + if (!starMapRefreshValidated) { + logger::warn( + "Galaxy/System repaint signature does not match; tracking stays enabled but forced repaint is disabled"); } + GalaxyMap::SetOriginalFunctions( + reinterpret_cast(questTreeBuildTarget), + reinterpret_cast(questTreeComposeTarget), + reinterpret_cast(questTreeInsertTarget), + starMapRefreshValidated); SurfaceMap::SetOriginalFunctions( - reinterpret_cast(expectedGatherTarget), - reinterpret_cast(expectedComposeTarget), + reinterpret_cast(surfaceGatherTarget), + reinterpret_cast(surfaceComposeTarget), surfaceRefreshValidated); StarMapInput::SetOriginalDispatcher( - reinterpret_cast(expectedInputTarget)); + reinterpret_cast(inputTarget)); - auto& trampoline = REL::GetTrampoline(); - constexpr std::size_t requiredTrampolineBytes = 42; + auto& trampoline = REL::GetTrampoline(); + constexpr std::size_t requiredTrampolineBytes = 84; if (trampoline.free_size() < requiredTrampolineBytes) { logger::error( - "SurfaceMap hooks require {} trampoline bytes; {} remain", + "Track Quest from Map hooks require {} trampoline bytes; {} remain", requiredTrampolineBytes, trampoline.free_size()); return false; } // Allocate every branch island before touching executable callsites. - const auto composeBranch = trampoline.allocate_branch5( + const auto questTreeInsertBranch = trampoline.allocate_branch5( + reinterpret_cast(GalaxyMap::CaptureInsertedQuestTargetMarker)); + const auto questTreeComposeBranch = trampoline.allocate_branch5( + reinterpret_cast(GalaxyMap::CaptureAndComposeQuestTargetMarker)); + const auto questTreeBuildBranch = trampoline.allocate_branch5( + reinterpret_cast(GalaxyMap::BuildAndPublishQuestTargetTree)); + const auto surfaceComposeBranch = trampoline.allocate_branch5( reinterpret_cast(SurfaceMap::CaptureAndComposeQuestTarget)); - const auto gatherBranch = trampoline.allocate_branch5( + const auto surfaceGatherBranch = trampoline.allocate_branch5( reinterpret_cast(SurfaceMap::BuildAndSnapshot)); const auto inputBranch = trampoline.allocate_branch5( reinterpret_cast(StarMapInput::OnStarMapButton)); - if (!IsRel32Reachable(composeCallsite, composeBranch) || - !IsRel32Reachable(gatherCallsite, gatherBranch) || - !IsRel32Reachable(inputCallsite, inputBranch)) { - logger::error("One or more allocated hook branches are outside signed rel32 reach"); - return false; + std::array patches{}; + std::size_t patchIndex{}; + patches[patchIndex++] = { questTreeInsertCallsiteA, questTreeInsertBranch }; + patches[patchIndex++] = { questTreeInsertCallsiteB, questTreeInsertBranch }; + patches[patchIndex++] = { questTreeComposeCallsite, questTreeComposeBranch }; + for (const auto callsite : questTreeCallsites) { + patches[patchIndex++] = { callsite, questTreeBuildBranch }; + } + patches[patchIndex++] = { surfaceComposeCallsite, surfaceComposeBranch }; + patches[patchIndex++] = { surfaceGatherCallsite, surfaceGatherBranch }; + patches[patchIndex++] = { inputCallsite, inputBranch }; + if (patchIndex != patches.size()) { + std::terminate(); } - const REL::ASM::CALL5 composePatch{ composeCallsite, composeBranch }; - const REL::ASM::CALL5 gatherPatch{ gatherCallsite, gatherBranch }; - const REL::ASM::CALL5 inputPatch{ inputCallsite, inputBranch }; - - std::array originalComposeCall{}; - std::array originalGatherCall{}; - std::array originalInputCall{}; - std::memcpy( - originalComposeCall.data(), - reinterpret_cast(composeCallsite), - originalComposeCall.size()); - std::memcpy( - originalGatherCall.data(), - reinterpret_cast(gatherCallsite), - originalGatherCall.size()); - std::memcpy( - originalInputCall.data(), - reinterpret_cast(inputCallsite), - originalInputCall.size()); + for (auto& patch : patches) { + if (!IsRel32Reachable(patch.address, patch.branch)) { + logger::error("Allocated hook branch is outside signed rel32 reach at 0x{:X}", patch.address); + return false; + } + std::memcpy( + patch.original.data(), + reinterpret_cast(patch.address), + patch.original.size()); + } try { - const bool composeWritten = - REL::WriteSafeData(composeCallsite, composePatch) && - std::memcmp( - reinterpret_cast(composeCallsite), - std::addressof(composePatch), - sizeof(composePatch)) == 0 && - REL::ASM::CALL5::TARGET(composeCallsite) == composeBranch; - const bool gatherWritten = - composeWritten && - REL::WriteSafeData(gatherCallsite, gatherPatch) && - std::memcmp( - reinterpret_cast(gatherCallsite), - std::addressof(gatherPatch), - sizeof(gatherPatch)) == 0 && - REL::ASM::CALL5::TARGET(gatherCallsite) == gatherBranch; - const bool inputWritten = - gatherWritten && - REL::WriteSafeData(inputCallsite, inputPatch) && - std::memcmp( - reinterpret_cast(inputCallsite), - std::addressof(inputPatch), - sizeof(inputPatch)) == 0 && - REL::ASM::CALL5::TARGET(inputCallsite) == inputBranch; - if (!composeWritten || !gatherWritten || !inputWritten) { - throw std::runtime_error("one or more hook writes failed verification"); + // Fail-safe exposure order: inert insertion wrappers first; their owning + // compose/build wrappers next; Surface ownership next; input last. + for (const auto& patch : patches) { + const REL::ASM::CALL5 call{ patch.address, patch.branch }; + const bool written = + REL::WriteSafeData(patch.address, call) && + std::memcmp( + reinterpret_cast(patch.address), + std::addressof(call), + sizeof(call)) == 0 && + REL::ASM::CALL5::TARGET(patch.address) == patch.branch; + if (!written) { + throw std::runtime_error("one or more hook writes failed verification"); + } } } catch (...) { - // Reverse the installation order and do not short-circuit: every original - // CALL is restored and read back even if an earlier restoration fails. - const bool inputRestored = Restore(inputCallsite, originalInputCall); - const bool gatherRestored = Restore(gatherCallsite, originalGatherCall); - const bool composeRestored = Restore(composeCallsite, originalComposeCall); - if (!inputRestored || !gatherRestored || !composeRestored) { + bool restored = true; + for (auto iterator = patches.rbegin(); iterator != patches.rend(); ++iterator) { + restored = Restore(iterator->address, iterator->original) && restored; + } + if (!restored) { try { logger::critical( - "Could not restore original SurfaceMap/input callsites after hook installation failure"); + "Could not restore original Track Quest from Map callsites after installation failure"); } catch (...) { } std::terminate(); } - logger::error( - "SurfaceMap/input hook transaction failed; all original calls restored"); + logger::error("Hook transaction failed; all original calls restored"); return false; } try { logger::info( - "Installed transactional SurfaceMap hooks: gather=0x{:X}, compose=0x{:X}, input=0x{:X}", - gatherCallsite, - composeCallsite, + "Installed transactional Surface/Galaxy/System hooks: callsites={}, input=0x{:X}", + patches.size(), inputCallsite); } catch (...) { - // Logging cannot turn a successfully committed hook set into a reported - // plugin-load failure. } return true; } catch (const std::exception& error) { try { - logger::error("Could not install SurfaceMap/input hooks: {}", error.what()); + logger::error("Could not install Track Quest from Map hooks: {}", error.what()); } catch (...) { } } catch (...) { try { - logger::error("Could not install SurfaceMap/input hooks"); + logger::error("Could not install Track Quest from Map hooks"); } catch (...) { } } diff --git a/src/Hooks.h b/src/Hooks.h index 0e3ad7b..f9b44b1 100644 --- a/src/Hooks.h +++ b/src/Hooks.h @@ -2,8 +2,8 @@ namespace TrackQuestSurface::Hooks { - // Transactionally installs the guarded surface-marker ownership hooks and the - // GalaxyStarMapMenu Select dispatcher hook. No engine or Scaleform pointer is - // retained after its originating call. + // Transactionally installs the guarded Surface and shared Galaxy/System + // quest-target ownership hooks plus the GalaxyStarMapMenu Select dispatcher. + // No engine or Scaleform pointer is retained after its originating call. [[nodiscard]] bool Install() noexcept; } diff --git a/src/PCH.h b/src/PCH.h index 6f1576b..b2382d4 100644 --- a/src/PCH.h +++ b/src/PCH.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include diff --git a/src/QuestTracking.cpp b/src/QuestTracking.cpp new file mode 100644 index 0000000..1b1d6d3 --- /dev/null +++ b/src/QuestTracking.cpp @@ -0,0 +1,125 @@ +#include "PCH.h" + +#include "QuestTracking.h" + +namespace TrackQuestSurface::QuestTracking +{ + namespace + { + [[nodiscard]] std::string_view SourceName(const Source a_source) noexcept + { + switch (a_source) { + case Source::kSurface: + return "Surface Map"; + case Source::kGalaxy: + return "Galaxy Map"; + case Source::kSystem: + return "System Map"; + default: + return "Star Map"; + } + } + + void ActivateOnMainThread( + const RE::QuestInstanceKey a_key, + const Source a_source, + const PostTrack a_postTrack) noexcept + { + try { + auto* quest = ResolveQuest(a_key); + if (!quest || !quest->IsRunning() || quest->IsStopped() || quest->IsTracked()) { + logger::warn( + "Skipped stale/already-active {} quest 0x{:08X}, instance={}", + SourceName(a_source), + a_key.formID, + a_key.instanceID); + return; + } + + quest->ToggleTracking(); + quest = ResolveQuest(a_key); + if (!quest || !quest->IsTracked()) { + logger::warn( + "Vanilla helper rejected {} quest 0x{:08X}, instance={}", + SourceName(a_source), + a_key.formID, + a_key.instanceID); + return; + } + + try { + logger::info( + "Tracked {} quest 0x{:08X}, instance={}", + SourceName(a_source), + a_key.formID, + a_key.instanceID); + } catch (...) { + } + if (a_postTrack) { + a_postTrack(); + } + } catch (const std::exception& error) { + try { + logger::error("Queued Track Quest task failed: {}", error.what()); + } catch (...) { + } + } catch (...) { + try { + logger::error("Queued Track Quest task failed unexpectedly"); + } catch (...) { + } + } + } + } + + RE::TESQuest* ResolveQuest(const RE::QuestInstanceKey& a_key) noexcept + { + auto* quest = RE::TESForm::LookupByID(a_key.formID); + return quest && quest->GetInstanceKey() == a_key ? quest : nullptr; + } + + bool IsInactiveTrackable(const RE::QuestInstanceKey& a_key) noexcept + { + const auto* quest = ResolveQuest(a_key); + return quest && quest->IsRunning() && !quest->IsStopped() && !quest->IsTracked(); + } + + bool QueueTrack( + const RE::QuestInstanceKey& a_key, + const Source a_source, + const PostTrack a_postTrack) noexcept + { + try { + if (!IsInactiveTrackable(a_key)) { + logger::warn( + "Rejected stale/already-active {} quest 0x{:08X}, instance={} before queue", + SourceName(a_source), + a_key.formID, + a_key.instanceID); + return false; + } + + const auto* tasks = SFSE::GetTaskInterface(); + if (!tasks) { + logger::error("SFSE TaskInterface is unavailable"); + return false; + } + + tasks->AddTask([key = a_key, source = a_source, postTrack = a_postTrack] { + ActivateOnMainThread(key, source, postTrack); + }); + return true; + } catch (const std::exception& error) { + try { + logger::error("Track Quest queue request failed: {}", error.what()); + } catch (...) { + } + } catch (...) { + try { + logger::error("Track Quest queue request failed unexpectedly"); + } catch (...) { + } + } + return false; + } +} diff --git a/src/QuestTracking.h b/src/QuestTracking.h new file mode 100644 index 0000000..9ab22c3 --- /dev/null +++ b/src/QuestTracking.h @@ -0,0 +1,26 @@ +#pragma once + +#include "RE/T/TESQuest.h" + +namespace TrackQuestSurface::QuestTracking +{ + enum class Source : std::uint8_t + { + kSurface, + kGalaxy, + kSystem + }; + + using PostTrack = void (*)() noexcept; + + [[nodiscard]] RE::TESQuest* ResolveQuest(const RE::QuestInstanceKey& a_key) noexcept; + [[nodiscard]] bool IsInactiveTrackable(const RE::QuestInstanceKey& a_key) noexcept; + + // Returns true only after the exact inactive quest was accepted for the + // SFSE main-thread task queue. The task repeats all live-state checks before + // calling the engine's toggle-semantic helper. + [[nodiscard]] bool QueueTrack( + const RE::QuestInstanceKey& a_key, + Source a_source, + PostTrack a_postTrack = nullptr) noexcept; +} diff --git a/src/StarMapInput.cpp b/src/StarMapInput.cpp index 9a801a8..9067259 100644 --- a/src/StarMapInput.cpp +++ b/src/StarMapInput.cpp @@ -2,15 +2,18 @@ #include "StarMapInput.h" +#include "GalaxyMap.h" #include "SurfaceMap.h" namespace TrackQuestSurface::StarMapInput { namespace { - constexpr auto kLargeQuestMarkerType = RE::StarMap::SurfaceMarkerType::kQuest; - constexpr std::size_t kMaximumUIChildren = 4096; - constexpr std::size_t kMaximumQuestTargetTextBytes = 4096; + constexpr auto kLargeQuestMarkerType = RE::StarMap::SurfaceMarkerType::kQuest; + constexpr std::size_t kMaximumUIChildren = 4096; + constexpr std::size_t kMaximumMissionDescendants = 128; + constexpr std::size_t kMaximumMissionDepth = 8; + constexpr std::size_t kMaximumQuestTargetTextBytes = 4096; constexpr std::string_view kSelectUserEvent = "Select"; DispatchButtonEvent originalDispatchButtonEvent{}; @@ -98,49 +101,67 @@ namespace TrackQuestSurface::StarMapInput return visible; } - [[nodiscard]] std::optional FindHoveredQuestMarker( - RE::BSInputEventUser* a_user) + [[nodiscard]] bool ResolveHostRoot( + RE::BSInputEventUser* a_user, + RE::Scaleform::GFx::Value& a_hostRoot) { if (!a_user) { logger::info("Select release preserved vanilla: input recipient unavailable"); - return std::nullopt; + return false; } auto* ui = RE::UI::GetSingleton(); if (!ui) { logger::info("Select release preserved vanilla: UI singleton unavailable"); - return std::nullopt; + return false; } const RE::BSFixedString menuName{ RE::StarMap::StarMapMenu::MENU_NAME.data() }; auto menu = ui->GetMenu(menuName); if (!menu) { logger::info("Select release preserved vanilla: GalaxyStarMapMenu unavailable"); - return std::nullopt; + return false; } if (static_cast(menu.get()) != a_user) { logger::info( "Select release preserved vanilla: live GalaxyStarMapMenu does not match input recipient"); - return std::nullopt; + return false; } if (!menu->uiMovie || !menu->uiMovie->asMovieRoot) { logger::info("Select release preserved vanilla: GalaxyStarMapMenu movie unavailable"); - return std::nullopt; + return false; } const char* rootPath = menu->GetRootPath(); - RE::Scaleform::GFx::Value hostRoot; - auto* movieRoot = menu->uiMovie->asMovieRoot.get(); - if (!rootPath || !movieRoot->GetVariable(std::addressof(hostRoot), rootPath) || - !hostRoot.IsObject()) { + auto* movieRoot = menu->uiMovie->asMovieRoot.get(); + if (!rootPath || !movieRoot->GetVariable(std::addressof(a_hostRoot), rootPath) || + !a_hostRoot.IsObject()) { logger::info("Select release preserved vanilla: GalaxyStarMapMenu root unavailable"); + return false; + } + return true; + } + + [[nodiscard]] std::optional SurfaceMapIsVisible( + const RE::Scaleform::GFx::Value& a_hostRoot) + { + RE::Scaleform::GFx::Value surfaceMap; + bool visible{}; + if (!a_hostRoot.GetMember("SurfaceMap_mc", std::addressof(surfaceMap)) || + !surfaceMap.IsObject() || + !ReadGFxBooleanMember(surfaceMap, "visible", visible)) { return std::nullopt; } + return visible; + } + [[nodiscard]] std::optional FindHoveredSurfaceQuestMarker( + const RE::Scaleform::GFx::Value& a_hostRoot) + { RE::Scaleform::GFx::Value surfaceMap; RE::Scaleform::GFx::Value map; RE::Scaleform::GFx::Value markers; - if (!hostRoot.GetMember("SurfaceMap_mc", std::addressof(surfaceMap)) || + if (!a_hostRoot.GetMember("SurfaceMap_mc", std::addressof(surfaceMap)) || !surfaceMap.IsObject() || !surfaceMap.GetMember("Map_mc", std::addressof(map)) || !map.IsObject() || @@ -172,7 +193,7 @@ namespace TrackQuestSurface::StarMapInput const auto childCount = static_cast(*childCountValueUnsigned); for (std::size_t reverseIndex = childCount; reverseIndex > 0; --reverseIndex) { - const auto index = reverseIndex - 1; + const auto index = reverseIndex - 1; RE::Scaleform::GFx::Value child; RE::Scaleform::GFx::Value childIndex{ static_cast(index) }; if (!markers.Invoke( @@ -304,6 +325,276 @@ namespace TrackQuestSurface::StarMapInput return std::nullopt; } + [[nodiscard]] std::optional ReadGFxNumberMember( + const RE::Scaleform::GFx::Value& a_object, + const std::string_view a_name) + { + RE::Scaleform::GFx::Value value; + if (!a_object.IsObject() || + !a_object.GetMember(a_name, std::addressof(value))) { + return std::nullopt; + } + if (value.IsNumber()) { + const auto number = value.GetNumber(); + return std::isfinite(number) ? std::optional{ number } : std::nullopt; + } + if (value.IsInt()) { + return static_cast(value.GetInt()); + } + if (value.IsUInt()) { + return static_cast(value.GetUInt()); + } + return std::nullopt; + } + + [[nodiscard]] std::optional ReadDisplayChildCount( + const RE::Scaleform::GFx::Value& a_object) + { + RE::Scaleform::GFx::Value countValue; + if (!a_object.IsObject() || + !a_object.GetMember("numChildren", std::addressof(countValue))) { + return std::nullopt; + } + const auto count = ReadGFxUInt(countValue); + if (!count || *count > kMaximumUIChildren) { + return std::nullopt; + } + return static_cast(*count); + } + + [[nodiscard]] bool ReadQuestNameplateText( + const RE::Scaleform::GFx::Value& a_missionContainer, + std::string& a_result) + { + RE::Scaleform::GFx::Value questNameplate; + RE::Scaleform::GFx::Value nameplateBase; + RE::Scaleform::GFx::Value textContainer; + RE::Scaleform::GFx::Value textField; + return a_missionContainer.GetMember("Nameplate_mc", std::addressof(questNameplate)) && + questNameplate.IsObject() && + questNameplate.GetMember("Nameplate_mc", std::addressof(nameplateBase)) && + nameplateBase.IsObject() && + nameplateBase.GetMember("NameplateText_mc", std::addressof(textContainer)) && + textContainer.IsObject() && + textContainer.GetMember("text_tf", std::addressof(textField)) && + textField.IsObject() && + ReadGFxStringMember(textField, "text", a_result); + } + + [[nodiscard]] std::optional HitTestAtStageCursor( + RE::Scaleform::GFx::Value& a_displayObject) + { + RE::Scaleform::GFx::Value stage; + if (!a_displayObject.IsObject() || + !a_displayObject.GetMember("stage", std::addressof(stage)) || + !stage.IsObject()) { + return std::nullopt; + } + + const auto mouseX = ReadGFxNumberMember(stage, "mouseX"); + const auto mouseY = ReadGFxNumberMember(stage, "mouseY"); + if (!mouseX || !mouseY) { + return std::nullopt; + } + + std::array arguments{ + RE::Scaleform::GFx::Value{ *mouseX }, + RE::Scaleform::GFx::Value{ *mouseY }, + RE::Scaleform::GFx::Value{ true } + }; + RE::Scaleform::GFx::Value hit; + if (!a_displayObject.Invoke( + "hitTestPoint", + std::addressof(hit), + arguments.data(), + arguments.size()) || + !hit.IsBoolean()) { + return std::nullopt; + } + return hit.GetBoolean(); + } + + enum class MissionSearchResult : std::uint8_t + { + kNone, + kMatch, + kInvalid + }; + + [[nodiscard]] MissionSearchResult FindInactiveMissionIcon( + RE::Scaleform::GFx::Value& a_object, + const std::size_t a_depth, + std::size_t& a_visited, + std::string& a_questTargetText) + { + if (!a_object.IsObject() || a_depth > kMaximumMissionDepth || + ++a_visited > kMaximumMissionDescendants) { + return MissionSearchResult::kInvalid; + } + bool objectVisible{}; + if (!ReadGFxBooleanMember(a_object, "visible", objectVisible)) { + return MissionSearchResult::kInvalid; + } + if (!objectVisible) { + return MissionSearchResult::kNone; + } + + RE::Scaleform::GFx::Value inactiveIcon; + if (a_object.GetMember("ObjectiveAtPOIInactive_mc", std::addressof(inactiveIcon))) { + RE::Scaleform::GFx::Value activeIcon; + bool inactiveVisible{}; + bool activeVisible{}; + if (!inactiveIcon.IsObject() || + !a_object.GetMember("ObjectiveAtPOI_mc", std::addressof(activeIcon)) || + !activeIcon.IsObject() || + !ReadGFxBooleanMember(inactiveIcon, "visible", inactiveVisible) || + !ReadGFxBooleanMember(activeIcon, "visible", activeVisible)) { + return MissionSearchResult::kInvalid; + } + if (!inactiveVisible || activeVisible) { + return MissionSearchResult::kNone; + } + const auto hit = HitTestAtStageCursor(inactiveIcon); + if (!hit) { + return MissionSearchResult::kInvalid; + } + if (!*hit) { + return MissionSearchResult::kNone; + } + return ReadQuestNameplateText(a_object, a_questTargetText) ? + MissionSearchResult::kMatch : + MissionSearchResult::kInvalid; + } + + const auto childCount = ReadDisplayChildCount(a_object); + if (!childCount) { + return MissionSearchResult::kNone; + } + for (std::size_t reverseIndex = *childCount; reverseIndex > 0; --reverseIndex) { + RE::Scaleform::GFx::Value child; + RE::Scaleform::GFx::Value childIndex{ + static_cast(reverseIndex - 1) + }; + if (!a_object.Invoke( + "getChildAt", + std::addressof(child), + std::addressof(childIndex), + 1)) { + return MissionSearchResult::kInvalid; + } + if (!child.IsObject()) { + continue; + } + const auto result = FindInactiveMissionIcon( + child, + a_depth + 1, + a_visited, + a_questTargetText); + if (result != MissionSearchResult::kNone) { + return result; + } + } + return MissionSearchResult::kNone; + } + + [[nodiscard]] std::optional FindHoveredGalaxyQuestMarker( + RE::Scaleform::GFx::Value& a_hostRoot) + { + RE::Scaleform::GFx::Value markersRoot; + RE::Scaleform::GFx::Value systemMarkers; + RE::Scaleform::GFx::Value bodyMarkers; + bool markersVisible{}; + bool systemVisible{}; + bool bodyVisible{}; + if (!a_hostRoot.GetMember("Markers_mc", std::addressof(markersRoot)) || + !markersRoot.IsObject() || + !ReadGFxBooleanMember(markersRoot, "visible", markersVisible) || + !markersVisible || + !markersRoot.GetMember("SystemMarkerContainer_mc", std::addressof(systemMarkers)) || + !systemMarkers.IsObject() || + !markersRoot.GetMember("BodyMarkerContainer_mc", std::addressof(bodyMarkers)) || + !bodyMarkers.IsObject() || + !ReadGFxBooleanMember(systemMarkers, "visible", systemVisible) || + !ReadGFxBooleanMember(bodyMarkers, "visible", bodyVisible) || + systemVisible == bodyVisible) { + logger::info( + "Select release preserved vanilla: Galaxy/System marker containers are unavailable or ambiguous"); + return std::nullopt; + } + + const auto view = systemVisible ? GalaxyMap::View::kGalaxy : GalaxyMap::View::kSystem; + auto& container = systemVisible ? systemMarkers : bodyMarkers; + const auto childCount = ReadDisplayChildCount(container); + if (!childCount) { + logger::info("Select release preserved vanilla: Galaxy/System marker count unavailable"); + return std::nullopt; + } + + for (std::size_t reverseIndex = *childCount; reverseIndex > 0; --reverseIndex) { + const auto index = reverseIndex - 1; + RE::Scaleform::GFx::Value marker; + RE::Scaleform::GFx::Value markerIndex{ static_cast(index) }; + if (!container.Invoke( + "getChildAt", + std::addressof(marker), + std::addressof(markerIndex), + 1)) { + return std::nullopt; + } + if (!marker.IsObject()) { + continue; + } + + bool markerVisible{}; + if (!ReadGFxBooleanMember(marker, "visible", markerVisible) || !markerVisible) { + continue; + } + + std::size_t visited{}; + std::string questTargetText; + const auto missionResult = FindInactiveMissionIcon( + marker, + 0, + visited, + questTargetText); + if (missionResult == MissionSearchResult::kInvalid) { + logger::info( + "Select release preserved vanilla: invalid Galaxy/System mission-icon tree (index={})", + index); + return std::nullopt; + } + if (missionResult != MissionSearchResult::kMatch) { + continue; + } + + RE::Scaleform::GFx::Value bodyIDValue; + if (!marker.GetMember("bodyID", std::addressof(bodyIDValue))) { + return std::nullopt; + } + const auto bodyID = ReadGFxUInt(bodyIDValue); + if (!bodyID || *bodyID == 0 || questTargetText.empty()) { + return std::nullopt; + } + + logger::info( + "Select release resolved {} mission icon: markerID={}, index={}, labelBytes={}", + view == GalaxyMap::View::kGalaxy ? "Galaxy" : "System", + *bodyID, + index, + questTargetText.size()); + return GalaxyMap::Request{ + .view = view, + .markerLocationID = *bodyID, + .questTargetText = std::move(questTargetText) + }; + } + + logger::info( + "Select release preserved vanilla: no inactive mission icon under the cursor in {} visible markers", + *childCount); + return std::nullopt; + } + [[nodiscard]] bool IsExactSelectRelease(const RE::ButtonEvent* a_event) { if (!a_event || @@ -332,19 +623,32 @@ namespace TrackQuestSurface::StarMapInput // RDX is the ButtonEvent. IMenu::OnButtonEvent is called exactly once unless a // unique marker request was accepted and queued. void OnStarMapButton( - RE::BSInputEventUser* a_user, + RE::BSInputEventUser* a_user, const RE::ButtonEvent* a_event) noexcept { bool consumed = false; try { if (IsExactSelectRelease(a_event)) { - const auto request = FindHoveredQuestMarker(a_user); - if (request) { - consumed = SurfaceMap::TryActivate(*request); + RE::Scaleform::GFx::Value hostRoot; + if (ResolveHostRoot(a_user, hostRoot)) { + const auto surfaceVisible = SurfaceMapIsVisible(hostRoot); + if (!surfaceVisible) { + logger::info( + "Select release preserved vanilla: Surface Map visibility state unavailable"); + } else if (*surfaceVisible) { + const auto request = FindHoveredSurfaceQuestMarker(hostRoot); + if (request) { + consumed = SurfaceMap::TryActivate(*request); + } + } else { + const auto request = FindHoveredGalaxyQuestMarker(hostRoot); + if (request) { + consumed = GalaxyMap::TryActivate(*request); + } + } if (!consumed) { logger::info( - "Select release preserved vanilla: native ownership/live quest validation rejected marker 0x{:08X}", - request->markerHandleBits); + "Select release preserved vanilla: no exact inactive quest request was accepted"); } } } diff --git a/src/SurfaceMap.cpp b/src/SurfaceMap.cpp index efa8c06..1a6439b 100644 --- a/src/SurfaceMap.cpp +++ b/src/SurfaceMap.cpp @@ -2,6 +2,8 @@ #include "SurfaceMap.h" +#include "QuestTracking.h" + namespace TrackQuestSurface::SurfaceMap { namespace @@ -18,17 +20,17 @@ namespace TrackQuestSurface::SurfaceMap struct MarkerRecord { - std::uint32_t markerHandleBits{}; + std::uint32_t markerHandleBits{}; RE::StarMap::SurfaceMarkerType markerType{}; - bool isLocation{}; - bool hasQuestTarget{}; - bool questActive{}; - std::string nameText; - std::string extraText; - std::string questTargetText; - std::size_t rawOwnerCount{}; - std::optional visibleOwner; - std::vector owners; + bool isLocation{}; + bool hasQuestTarget{}; + bool questActive{}; + std::string nameText; + std::string extraText; + std::string questTargetText; + std::size_t rawOwnerCount{}; + std::optional visibleOwner; + std::vector owners; }; struct CopiedMarkerRecord @@ -68,7 +70,7 @@ namespace TrackQuestSurface::SurfaceMap struct CapturedQuestSlot { - QuestKey key; + QuestKey key; CapturedQuestState state{}; }; @@ -78,11 +80,11 @@ namespace TrackQuestSurface::SurfaceMap struct QuestPairCapture { std::array slots{}; - std::size_t contributorCalls{}; - std::size_t uniqueForms{}; - std::size_t ambiguousForms{}; - bool overflow{}; - bool invalidInvocation{}; + std::size_t contributorCalls{}; + std::size_t uniqueForms{}; + std::size_t ambiguousForms{}; + bool overflow{}; + bool invalidInvocation{}; void Reset() noexcept { @@ -103,7 +105,7 @@ namespace TrackQuestSurface::SurfaceMap } constexpr std::uint32_t goldenRatio = 0x9E3779B1U; - const auto first = + const auto first = static_cast(a_key.formID * goldenRatio) & (kQuestCaptureSlotCount - 1); for (std::size_t probe = 0; probe < kQuestCaptureSlotCount; ++probe) { @@ -136,7 +138,7 @@ namespace TrackQuestSurface::SurfaceMap } constexpr std::uint32_t goldenRatio = 0x9E3779B1U; - const auto first = + const auto first = static_cast(a_formID * goldenRatio) & (kQuestCaptureSlotCount - 1); for (std::size_t probe = 0; probe < kQuestCaptureSlotCount; ++probe) { @@ -158,10 +160,10 @@ namespace TrackQuestSurface::SurfaceMap // sentinel handle. Preserve every row; a handle alone is not a unique key. using MarkerCache = std::vector; - std::mutex cacheMutex; - MarkerCache markerCache; - BuildSurfaceMarkers originalBuildSurfaceMarkers{}; - ComposeQuestTarget originalComposeQuestTarget{}; + std::mutex cacheMutex; + MarkerCache markerCache; + BuildSurfaceMarkers originalBuildSurfaceMarkers{}; + ComposeQuestTarget originalComposeQuestTarget{}; thread_local QuestPairCapture threadQuestCapture; thread_local QuestPairCapture* activeQuestCapture{}; thread_local std::size_t surfaceGatherDepth{}; @@ -224,9 +226,9 @@ namespace TrackQuestSurface::SurfaceMap } const auto& markers = a_surfaceState->surfaceMarkers; - const auto markerBegin = reinterpret_cast(markers.begin()); - const auto markerEnd = reinterpret_cast(markers.end()); - const auto markerCapacity = + const auto markerBegin = reinterpret_cast(markers.begin()); + const auto markerEnd = reinterpret_cast(markers.end()); + const auto markerCapacity = reinterpret_cast(markers.capacity_end()); if (!markerBegin || markerEnd < markerBegin || markerCapacity < markerEnd || (markerEnd - markerBegin) % sizeof(RE::StarMap::SurfaceMarkerStaticData) != 0 || @@ -245,7 +247,7 @@ namespace TrackQuestSurface::SurfaceMap for (std::size_t index = 0; index < copied.markerCount; ++index) { const auto& marker = markers.begin()[index]; - const auto ownerBegin = + const auto ownerBegin = reinterpret_cast(marker.questOwners.begin()); const auto ownerEnd = reinterpret_cast(marker.questOwners.end()); @@ -308,21 +310,6 @@ namespace TrackQuestSurface::SurfaceMap markerCache = std::move(a_next); } - [[nodiscard]] RE::TESQuest* ResolveQuest(const QuestKey& a_key) noexcept - { - auto* quest = RE::TESForm::LookupByID(a_key.formID); - if (!quest || quest->GetInstanceKey() != a_key) { - return nullptr; - } - return quest; - } - - [[nodiscard]] bool IsInactiveTrackableQuest(const QuestKey& a_key) noexcept - { - const auto* quest = ResolveQuest(a_key); - return quest && quest->IsRunning() && !quest->IsStopped() && !quest->IsTracked(); - } - void RebuildCurrentSurfaceMap() noexcept { if (!surfaceRefreshValidated) { @@ -378,7 +365,7 @@ namespace TrackQuestSurface::SurfaceMap void SnapshotMarkerOwners( RE::StarMap::SurfaceMapState* a_surfaceState, - const QuestPairCapture& a_capture) + const QuestPairCapture& a_capture) { // Phase A is the only phase that touches the owner-thread-only native // vectors. It copies all text and FormIDs before this function resolves @@ -428,7 +415,7 @@ namespace TrackQuestSurface::SurfaceMap // Preserve order across deduplication: Q1,Q2,Q1 represents Q1. visibleFormID = formID; const auto key = a_capture.Resolve(formID); - if (!key || !ResolveQuest(*key)) { + if (!key || !QuestTracking::ResolveQuest(*key)) { valid = false; break; } @@ -674,49 +661,6 @@ namespace TrackQuestSurface::SurfaceMap return owner; } - void ActivateOnMainThread(const QuestKey a_key) noexcept - { - try { - // The engine helper toggles tracking, so this second check is mandatory. - // It also makes duplicate mouse/Select requests harmless. - auto* quest = ResolveQuest(a_key); - if (!quest || !quest->IsRunning() || quest->IsStopped() || quest->IsTracked()) { - logger::warn( - "Skipped stale/already-active quest 0x{:08X}, instance={}", - a_key.formID, - a_key.instanceID); - return; - } - - quest->ToggleTracking(); - quest = ResolveQuest(a_key); - if (quest && quest->IsTracked()) { - try { - logger::info( - "Tracked SurfaceMap quest 0x{:08X}, instance={}", - a_key.formID, - a_key.instanceID); - } catch (...) { - } - RebuildCurrentSurfaceMap(); - } else { - logger::warn( - "Vanilla helper rejected quest 0x{:08X}, instance={}", - a_key.formID, - a_key.instanceID); - } - } catch (const std::exception& error) { - try { - logger::error("Queued Track Quest task failed: {}", error.what()); - } catch (...) { - } - } catch (...) { - try { - logger::error("Queued Track Quest task failed unexpectedly"); - } catch (...) { - } - } - } } void SetOriginalFunctions( @@ -756,26 +700,14 @@ namespace TrackQuestSurface::SurfaceMap return false; } - // ResolveRequest releases cacheMutex before any live form lookup. The - // queued task repeats this exact check because tracking is a toggle. - if (!IsInactiveTrackableQuest(*owner)) { - logger::warn( - "Rejected stale/already-active {} SurfaceMap quest 0x{:08X}, instance={} before queue", - a_request.variant == MarkerVariant::kLargeNameplate ? - "large-nameplate" : - "quest-target", - owner->formID, - owner->instanceID); + // ResolveRequest releases cacheMutex before the shared live-state check. + // The queued task repeats it because the vanilla helper toggles tracking. + if (!QuestTracking::QueueTrack( + *owner, + QuestTracking::Source::kSurface, + RebuildCurrentSurfaceMap)) { return false; } - - const auto* tasks = SFSE::GetTaskInterface(); - if (!tasks) { - logger::error("SFSE TaskInterface is unavailable"); - return false; - } - - tasks->AddTask([key = *owner] { ActivateOnMainThread(key); }); try { logger::info( "Accepted {} SurfaceMap marker 0x{:08X}; queued quest 0x{:08X}, instance={}", diff --git a/src/plugin.cpp b/src/plugin.cpp index fcebb2e..d3d4d67 100644 --- a/src/plugin.cpp +++ b/src/plugin.cpp @@ -4,8 +4,8 @@ SFSE_PLUGIN_VERSION = []() noexcept { SFSE::PluginVersionData version{}; - version.PluginVersion({ 0, 2, 2, 0 }); - version.PluginName("TrackQuestSurfaceNativeOnly"); + version.PluginVersion({ 0, 3, 0, 0 }); + version.PluginName("TrackQuestFromMap"); version.AuthorName("Quantumyilmaz"); version.UsesSigScanning(false); version.UsesAddressLibrary(true); @@ -26,13 +26,13 @@ SFSE_PLUGIN_LOAD(const SFSE::LoadInterface* a_sfse) SFSE::InitInfo initInfo{ .logPattern = "%Y-%m-%d %H:%M:%S.%e [%l] %v", .trampoline = true, - .trampolineSize = 64 + .trampolineSize = 128 }; SFSE::Init(a_sfse, initInfo); const auto runtime = a_sfse->RuntimeVersion(); logger::info( - "TrackQuestSurfaceNativeOnly 0.2.2 loaded; runtime={}, SFSE=0x{:08X}", + "TrackQuestFromMap 0.3.0 loaded; runtime={}, SFSE=0x{:08X}", runtime, a_sfse->SFSEVersion()); @@ -50,7 +50,7 @@ SFSE_PLUGIN_LOAD(const SFSE::LoadInterface* a_sfse) } if (!TrackQuestSurface::Hooks::Install()) { - logger::error("Transactional SurfaceMap/input hooks could not be installed"); + logger::error("Transactional map/input hooks could not be installed"); return false; } return true; diff --git a/xmake.lua b/xmake.lua index df58a3f..009e2da 100644 --- a/xmake.lua +++ b/xmake.lua @@ -21,7 +21,7 @@ local commonlibsf = path.join(os.projectdir(), "lib", "commonlibsf") includes(commonlibsf) set_project("TrackQuestFromMap") -set_version("0.2.2") +set_version("0.3.0") set_license("GPL-3.0-or-later") set_languages("c++23") set_warnings("allextra") @@ -37,13 +37,17 @@ target("TrackQuestSurfaceNativeOnly") add_deps("commonlibsf") add_files( "src/plugin.cpp", + "src/GalaxyMap.cpp", "src/Hooks.cpp", + "src/QuestTracking.cpp", "src/StarMapInput.cpp", "src/SurfaceMap.cpp" ) add_headerfiles( "src/PCH.h", + "src/GalaxyMap.h", "src/Hooks.h", + "src/QuestTracking.h", "src/StarMapInput.h", "src/SurfaceMap.h" )