From 0778c3d78f3f2d7f56a3f8286bbab079720d9d2e Mon Sep 17 00:00:00 2001 From: Quantumyilmaz <47591838+Quantumyilmaz@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:37:17 +0200 Subject: [PATCH 1/4] build: pin QTR CommonLib Surface Map APIs --- SOURCE.md | 11 +++++++++++ lib/commonlibsf | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/SOURCE.md b/SOURCE.md index 6803cfc..448d75a 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,5 +1,16 @@ # 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: diff --git a/lib/commonlibsf b/lib/commonlibsf index 765219c..04a3d88 160000 --- a/lib/commonlibsf +++ b/lib/commonlibsf @@ -1 +1 @@ -Subproject commit 765219c66f58f729f854771a95cdd59bbd76b84b +Subproject commit 04a3d88e2925806355000190c9c3a9df586ebf3f From 32b462af89ea994a9d88eb58da73bd699a2f49cb Mon Sep 17 00:00:00 2001 From: Quantumyilmaz <47591838+Quantumyilmaz@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:23:35 +0200 Subject: [PATCH 2/4] refactor: split Surface Map runtime integration --- src/Hooks.cpp | 261 ++++++++ src/Hooks.h | 9 + src/PCH.h | 30 + src/StarMapInput.cpp | 381 +++++++++++ src/StarMapInput.h | 11 + src/SurfaceActivation.cpp | 1285 ------------------------------------- src/SurfaceActivation.h | 36 -- src/SurfaceMap.cpp | 804 +++++++++++++++++++++++ src/SurfaceMap.h | 48 ++ src/main.cpp | 52 -- src/plugin.cpp | 69 ++ xmake.lua | 17 +- 12 files changed, 1627 insertions(+), 1376 deletions(-) create mode 100644 src/Hooks.cpp create mode 100644 src/Hooks.h create mode 100644 src/PCH.h create mode 100644 src/StarMapInput.cpp create mode 100644 src/StarMapInput.h delete mode 100644 src/SurfaceActivation.cpp delete mode 100644 src/SurfaceActivation.h create mode 100644 src/SurfaceMap.cpp create mode 100644 src/SurfaceMap.h delete mode 100644 src/main.cpp create mode 100644 src/plugin.cpp diff --git a/src/Hooks.cpp b/src/Hooks.cpp new file mode 100644 index 0000000..56a3ef0 --- /dev/null +++ b/src/Hooks.cpp @@ -0,0 +1,261 @@ +#include "PCH.h" + +#include "Hooks.h" + +#include "StarMapInput.h" +#include "SurfaceMap.h" + +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; + constexpr std::ptrdiff_t kStarMapInputCallOffset = 0x10C; + + [[nodiscard]] bool IsRel32Reachable( + const std::uintptr_t a_callsite, + const std::uintptr_t a_target) noexcept + { + constexpr auto callSize = sizeof(REL::ASM::CALL5); + if (a_callsite > std::numeric_limits::max() - callSize) { + return false; + } + + const auto nextInstruction = a_callsite + callSize; + if (a_target >= nextInstruction) { + return a_target - nextInstruction <= + static_cast(std::numeric_limits::max()); + } + + constexpr auto negativeLimit = + static_cast(std::numeric_limits::max()) + 1; + return nextInstruction - a_target <= negativeLimit; + } + + template + [[nodiscard]] bool Matches( + 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; + } + + template + [[nodiscard]] bool Restore( + 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); + } + } + + bool Install() noexcept + { + try { + const auto gatherCallsite = + RE::ID::StarMap::SurfaceMapState::RebuildSurfaceMarkers.address() + + kQuestGatherCallOffset; + const auto composeCallsite = + RE::ID::StarMap::SurfaceMapState::GatherSurfaceQuestTargets.address() + + kQuestComposeCallOffset; + const auto inputCallsite = + RE::ID::StarMap::StarMapMenu::OnButtonEvent.address() + kStarMapInputCallOffset; + + const auto expectedGatherTarget = + RE::ID::StarMap::SurfaceMapState::GatherSurfaceQuestTargets.address(); + const auto expectedComposeTarget = + RE::ID::StarMap::ComposeSurfaceQuestTarget.address(); + const auto expectedInputTarget = 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); + 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); + 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); + return false; + } + + const bool surfaceRefreshValidated = + REL::Pattern< + "40 53 48 83 EC 20 48 8D 99 F0 11 00 00 C6 44 24 30 03">() + .match(RE::ID::StarMap::StarMapMenu::GetSurfaceMapState.address()) && + REL::Pattern< + "48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 48 89 7C 24 20 41 56 48 83 EC 40">() + .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; + } + 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; + } + + SurfaceMap::SetOriginalFunctions( + reinterpret_cast(expectedGatherTarget), + reinterpret_cast(expectedComposeTarget), + surfaceRefreshValidated); + StarMapInput::SetOriginalDispatcher( + reinterpret_cast(expectedInputTarget)); + + auto& trampoline = REL::GetTrampoline(); + constexpr std::size_t requiredTrampolineBytes = 42; + if (trampoline.free_size() < requiredTrampolineBytes) { + logger::error( + "SurfaceMap 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( + reinterpret_cast(SurfaceMap::CaptureAndComposeQuestTarget)); + const auto gatherBranch = 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; + } + + 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()); + + 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"); + } + } 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) { + try { + logger::critical( + "Could not restore original SurfaceMap/input callsites after hook installation failure"); + } catch (...) { + } + std::terminate(); + } + logger::error( + "SurfaceMap/input 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, + 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()); + } catch (...) { + } + } catch (...) { + try { + logger::error("Could not install SurfaceMap/input hooks"); + } catch (...) { + } + } + return false; + } +} diff --git a/src/Hooks.h b/src/Hooks.h new file mode 100644 index 0000000..0e3ad7b --- /dev/null +++ b/src/Hooks.h @@ -0,0 +1,9 @@ +#pragma once + +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. + [[nodiscard]] bool Install() noexcept; +} diff --git a/src/PCH.h b/src/PCH.h new file mode 100644 index 0000000..6f1576b --- /dev/null +++ b/src/PCH.h @@ -0,0 +1,30 @@ +#pragma once + +#include "RE/Starfield.h" +#include "REL/ASM.h" +#include "REL/Pattern.h" +#include "REL/Relocation.h" +#include "REL/Utility.h" +#include "SFSE/SFSE.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace logger = spdlog; diff --git a/src/StarMapInput.cpp b/src/StarMapInput.cpp new file mode 100644 index 0000000..9a801a8 --- /dev/null +++ b/src/StarMapInput.cpp @@ -0,0 +1,381 @@ +#include "PCH.h" + +#include "StarMapInput.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 std::string_view kSelectUserEvent = "Select"; + + DispatchButtonEvent originalDispatchButtonEvent{}; + + [[nodiscard]] std::optional ReadGFxUInt( + const RE::Scaleform::GFx::Value& a_value) noexcept + { + if (a_value.IsUInt()) { + return a_value.GetUInt(); + } + if (a_value.IsInt()) { + return static_cast(a_value.GetInt()); + } + if (a_value.IsNumber()) { + const double number = a_value.GetNumber(); + if (std::isfinite(number) && number >= 0.0 && + std::trunc(number) == number && + number <= static_cast(std::numeric_limits::max())) { + return static_cast(number); + } + } + return std::nullopt; + } + + [[nodiscard]] bool ReadGFxBooleanMember( + const RE::Scaleform::GFx::Value& a_object, + const std::string_view a_name, + bool& a_result) + { + if (!a_object.IsObject()) { + return false; + } + RE::Scaleform::GFx::Value value; + if (!a_object.GetMember(a_name, std::addressof(value)) || !value.IsBoolean()) { + return false; + } + a_result = value.GetBoolean(); + return true; + } + + [[nodiscard]] bool ReadGFxStringMember( + const RE::Scaleform::GFx::Value& a_object, + const std::string_view a_name, + std::string& a_result) + { + if (!a_object.IsObject()) { + return false; + } + RE::Scaleform::GFx::Value value; + if (!a_object.GetMember(a_name, std::addressof(value)) || !value.IsString()) { + return false; + } + + // GetString may point into a managed GFx value. Copy it while `value` is + // alive and never retain the pointer beyond this call. + const auto* text = value.GetString(); + if (!text) { + return false; + } + std::size_t length = 0; + while (length <= kMaximumQuestTargetTextBytes && text[length] != '\0') { + ++length; + } + if (length > kMaximumQuestTargetTextBytes) { + return false; + } + a_result.assign(text, length); + return true; + } + + [[nodiscard]] std::optional ReadGFxNestedVisible( + const RE::Scaleform::GFx::Value& a_object, + const std::string_view a_memberName) + { + if (!a_object.IsObject()) { + return std::nullopt; + } + RE::Scaleform::GFx::Value nested; + bool visible{}; + if (!a_object.GetMember(a_memberName, std::addressof(nested)) || + !nested.IsObject() || + !ReadGFxBooleanMember(nested, "visible", visible)) { + return std::nullopt; + } + return visible; + } + + [[nodiscard]] std::optional FindHoveredQuestMarker( + RE::BSInputEventUser* a_user) + { + if (!a_user) { + logger::info("Select release preserved vanilla: input recipient unavailable"); + return std::nullopt; + } + + auto* ui = RE::UI::GetSingleton(); + if (!ui) { + logger::info("Select release preserved vanilla: UI singleton unavailable"); + return std::nullopt; + } + + 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; + } + if (static_cast(menu.get()) != a_user) { + logger::info( + "Select release preserved vanilla: live GalaxyStarMapMenu does not match input recipient"); + return std::nullopt; + } + if (!menu->uiMovie || !menu->uiMovie->asMovieRoot) { + logger::info("Select release preserved vanilla: GalaxyStarMapMenu movie unavailable"); + return std::nullopt; + } + + 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()) { + logger::info("Select release preserved vanilla: GalaxyStarMapMenu root unavailable"); + return std::nullopt; + } + + RE::Scaleform::GFx::Value surfaceMap; + RE::Scaleform::GFx::Value map; + RE::Scaleform::GFx::Value markers; + if (!hostRoot.GetMember("SurfaceMap_mc", std::addressof(surfaceMap)) || + !surfaceMap.IsObject() || + !surfaceMap.GetMember("Map_mc", std::addressof(map)) || + !map.IsObject() || + !map.GetMember("MarkersContainer_mc", std::addressof(markers)) || + !markers.IsObject()) { + logger::info( + "Select release preserved vanilla: public SurfaceMap_mc.Map_mc.MarkersContainer_mc path unavailable"); + return std::nullopt; + } + + bool surfaceMapVisible{}; + if (!ReadGFxBooleanMember(surfaceMap, "visible", surfaceMapVisible) || !surfaceMapVisible) { + logger::info("Select release preserved vanilla: SurfaceMap_mc is not visibly active"); + return std::nullopt; + } + + RE::Scaleform::GFx::Value childCountValue; + if (!markers.GetMember("numChildren", std::addressof(childCountValue))) { + logger::info("Select release preserved vanilla: marker child count unavailable"); + return std::nullopt; + } + const auto childCountValueUnsigned = ReadGFxUInt(childCountValue); + if (!childCountValueUnsigned || *childCountValueUnsigned > kMaximumUIChildren) { + logger::info( + "Select release preserved vanilla: invalid marker child count (maximum={})", + kMaximumUIChildren); + return std::nullopt; + } + + const auto childCount = static_cast(*childCountValueUnsigned); + for (std::size_t reverseIndex = childCount; reverseIndex > 0; --reverseIndex) { + const auto index = reverseIndex - 1; + RE::Scaleform::GFx::Value child; + RE::Scaleform::GFx::Value childIndex{ static_cast(index) }; + if (!markers.Invoke( + "getChildAt", + std::addressof(child), + std::addressof(childIndex), + 1)) { + logger::info("Select release preserved vanilla: getChildAt({}) failed", index); + return std::nullopt; + } + if (!child.IsObject()) { + continue; + } + + bool childVisible{}; + if (!ReadGFxBooleanMember(child, "visible", childVisible) || !childVisible) { + continue; + } + + const bool questTargetVisible = + ReadGFxNestedVisible(child, "QuestTargetText_mc").value_or(false); + const bool nameplateVisible = + ReadGFxNestedVisible(child, "Nameplate_mc").value_or(false); + if (!questTargetVisible && !nameplateVisible) { + continue; + } + + // SurfaceMarkerContainer moves CurrentHoveredMarker to the top of the + // display list. Reverse order therefore selects the same marker vanilla + // will dispatch, even when a large nameplate overlaps a smaller marker. + bool hasQuestTarget{}; + bool hasActiveQuest{}; + bool isLocation{}; + if (!ReadGFxBooleanMember(child, "hasQuestTarget", hasQuestTarget) || + !ReadGFxBooleanMember(child, "hasActiveQuest", hasActiveQuest) || + !ReadGFxBooleanMember(child, "IsLocation", isLocation)) { + logger::info( + "Select release preserved vanilla: topmost hovered marker has invalid Boolean metadata (index={})", + index); + return std::nullopt; + } + + RE::Scaleform::GFx::Value handleValue; + RE::Scaleform::GFx::Value markerData; + RE::Scaleform::GFx::Value markerTypeValue; + if (!child.GetMember("handleBits", std::addressof(handleValue)) || + !child.GetMember("MarkerData", std::addressof(markerData)) || + !markerData.IsObject() || + !markerData.GetMember("iMarkerType", std::addressof(markerTypeValue))) { + logger::info( + "Select release preserved vanilla: topmost hovered marker has invalid identity metadata (index={})", + index); + return std::nullopt; + } + + const auto handle = ReadGFxUInt(handleValue); + const auto markerType = ReadGFxUInt(markerTypeValue); + if (!handle || !markerType) { + logger::info( + "Select release preserved vanilla: topmost hovered marker has non-integral identity metadata (index={})", + index); + return std::nullopt; + } + + SurfaceMap::Request candidate{ + .markerHandleBits = *handle, + .markerType = *markerType, + .isLocation = isLocation + }; + const bool largeNameplate = + nameplateVisible && + *markerType == static_cast(kLargeQuestMarkerType) && + !isLocation && !hasQuestTarget && !hasActiveQuest; + const bool smallQuestTarget = + questTargetVisible && hasQuestTarget && !hasActiveQuest; + + if (largeNameplate) { + candidate.variant = SurfaceMap::MarkerVariant::kLargeNameplate; + if (!ReadGFxStringMember(markerData, "sNameText", candidate.nameText) || + !ReadGFxStringMember(markerData, "sExtraText", candidate.extraText)) { + logger::info( + "Select release preserved vanilla: large marker has invalid name/extra text (index={})", + index); + return std::nullopt; + } + } else if (smallQuestTarget) { + candidate.variant = SurfaceMap::MarkerVariant::kQuestTarget; + if (!ReadGFxStringMember( + markerData, + "sQuestTargetText", + candidate.questTargetText)) { + logger::info( + "Select release preserved vanilla: quest marker has invalid target text (index={})", + index); + return std::nullopt; + } + } else { + logger::info( + "Select release preserved vanilla: topmost hovered marker is ineligible (index={}, handle=0x{:08X}, type={}, location={}, questTarget={}, active={}, nameplate={}, questLabel={})", + index, + *handle, + *markerType, + isLocation, + hasQuestTarget, + hasActiveQuest, + nameplateVisible, + questTargetVisible); + return std::nullopt; + } + + logger::info( + "Select release resolved topmost {} marker: handle=0x{:08X}, type={}, index={}, children={}, nameBytes={}, extraBytes={}, questTextBytes={}", + candidate.variant == SurfaceMap::MarkerVariant::kLargeNameplate ? + "large-nameplate" : + "quest-target", + candidate.markerHandleBits, + candidate.markerType, + index, + childCount, + candidate.nameText.size(), + candidate.extraText.size(), + candidate.questTargetText.size()); + return candidate; + } + + logger::info( + "Select release preserved vanilla: no visible hovered marker label among {} direct children", + childCount); + return std::nullopt; + } + + [[nodiscard]] bool IsExactSelectRelease(const RE::ButtonEvent* a_event) + { + if (!a_event || + a_event->eventType != RE::InputEvent::EventType::kButton || + a_event->status == RE::InputEvent::Status::kStop || + !std::isfinite(a_event->value) || + !std::isfinite(a_event->heldDownSecs) || + a_event->heldDownSecs < 0.0F || + a_event->value != 0.0F || + a_event->disabled) { + return false; + } + + const auto& userEvent = a_event->QUserEvent(); + return std::string_view{ userEvent.c_str(), userEvent.length() } == kSelectUserEvent; + } + } + + void SetOriginalDispatcher(const DispatchButtonEvent a_dispatchButtonEvent) noexcept + { + originalDispatchButtonEvent = a_dispatchButtonEvent; + } + + // Exact ABI at StarMapMenu::OnButtonEvent + 0x10C: RCX is the + // BSInputEventUser subobject and + // 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, + 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); + if (!consumed) { + logger::info( + "Select release preserved vanilla: native ownership/live quest validation rejected marker 0x{:08X}", + request->markerHandleBits); + } + } + } + } catch (const std::exception& error) { + try { + logger::error("Star Map Select resolver failed; preserving vanilla: {}", error.what()); + } catch (...) { + } + } catch (...) { + try { + logger::error("Star Map Select resolver failed unexpectedly; preserving vanilla"); + } catch (...) { + } + } + + if (consumed) { + const_cast(a_event)->status = RE::InputEvent::Status::kStop; + try { + logger::info("Consumed Star Map Select release after queuing quest activation"); + } catch (...) { + } + return; + } + + if (!originalDispatchButtonEvent) { + try { + logger::critical("Star Map input hook has no vanilla dispatcher"); + } catch (...) { + } + std::terminate(); + } + originalDispatchButtonEvent(a_user, a_event); + } +} diff --git a/src/StarMapInput.h b/src/StarMapInput.h new file mode 100644 index 0000000..b031adf --- /dev/null +++ b/src/StarMapInput.h @@ -0,0 +1,11 @@ +#pragma once + +#include "RE/B/BSInputEventUser.h" + +namespace TrackQuestSurface::StarMapInput +{ + using DispatchButtonEvent = void (*)(RE::BSInputEventUser*, const RE::ButtonEvent*); + + void SetOriginalDispatcher(DispatchButtonEvent a_dispatchButtonEvent) noexcept; + void OnStarMapButton(RE::BSInputEventUser* a_user, const RE::ButtonEvent* a_event) noexcept; +} diff --git a/src/SurfaceActivation.cpp b/src/SurfaceActivation.cpp deleted file mode 100644 index a561498..0000000 --- a/src/SurfaceActivation.cpp +++ /dev/null @@ -1,1285 +0,0 @@ -#include "SurfaceActivation.h" - -#include "RE/Starfield.h" -#include "REL/ASM.h" -#include "REL/Relocation.h" -#include "REL/Utility.h" -#include "RE/I/IMenu.h" -#include "RE/S/ScaleformGFxASMovieRootBase.h" -#include "RE/S/ScaleformGFxValue.h" -#include "RE/U/UI.h" -#include "REX/LOG.h" -#include "SFSE/SFSE.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace TrackQuestSurface::SurfaceActivation { -namespace { -// Reviewed against Starfield.exe 1.16.244.0 and its v5 Address Library. -constexpr REL::ID kSurfaceRebuildID{95000}; // RVA 0x16DB640 -constexpr REL::ID kQuestGatherID{95012}; // RVA 0x16DDEA0 -constexpr REL::ID kQuestComposeID{95013}; // RVA 0x16DE1B0 -constexpr REL::ID kToggleTrackingID{91440}; // RVA 0x15DD710 -constexpr REL::ID kStarMapButtonHandlerID{94684}; // RVA 0x16BA350 -constexpr REL::ID kVanillaButtonDispatcherID{130632}; // RVA 0x25533D0 -constexpr REL::ID kGetSurfaceMapStateID{94755}; // RVA 0x16C1D40 -constexpr REL::ID kRefreshSurfaceMapStateID{95003}; // RVA 0x16DBA10 -constexpr REL::ID kStarMapMenuVtableID{446845}; // RVA 0x4C97BC0 -constexpr REL::ID kSurfaceMapStateVtableID{447074}; // RVA 0x4C99A00 -constexpr std::ptrdiff_t kQuestGatherCallOffset = 0x77; -constexpr std::ptrdiff_t kQuestComposeCallOffset = 0x237; -constexpr std::ptrdiff_t kStarMapInputCallOffset = 0x10C; - -constexpr std::ptrdiff_t kMarkerBeginOffset = 0x8B8; -constexpr std::ptrdiff_t kMarkerEndOffset = 0x8C0; -constexpr std::ptrdiff_t kMarkerCapacityOffset = 0x8C8; -constexpr std::size_t kMarkerStride = 0x80; - -constexpr std::ptrdiff_t kNameTextOffset = 0x28; -constexpr std::ptrdiff_t kExtraTextOffset = 0x30; -constexpr std::ptrdiff_t kQuestTargetTextOffset = 0x38; -constexpr std::ptrdiff_t kHandleOffset = 0x40; -constexpr std::ptrdiff_t kOwnerBeginOffset = 0x48; -constexpr std::ptrdiff_t kOwnerEndOffset = 0x50; -constexpr std::ptrdiff_t kOwnerCapacityOffset = 0x58; -constexpr std::ptrdiff_t kMarkerTypeOffset = 0x60; -constexpr std::ptrdiff_t kIsLocationOffset = 0x68; -constexpr std::ptrdiff_t kHasQuestTargetOffset = 0x69; -constexpr std::ptrdiff_t kQuestActiveOffset = 0x6A; - -constexpr std::ptrdiff_t kQuestInstanceIDOffset = 0x70; -constexpr std::ptrdiff_t kQuestStateOffset = 0x114; -constexpr std::ptrdiff_t kComposeQuestOffset = 0x10; -constexpr std::ptrdiff_t kQuestFormIDOffset = 0x28; -constexpr std::uint32_t kQuestRunning = 1U << 0; -constexpr std::uint32_t kQuestStopped = 1U << 1; -constexpr std::uint32_t kQuestTracked = 1U << 11; -constexpr std::uint16_t kLargeQuestMarkerType = 0x48; - -constexpr std::size_t kMaximumMarkers = 4096; -constexpr std::size_t kMaximumOwners = 128; -constexpr std::size_t kMaximumUIChildren = 4096; -constexpr std::size_t kMaximumQuestTargetTextBytes = 4096; -constexpr std::size_t kQuestCaptureSlotCount = 4096; -static_assert((kQuestCaptureSlotCount & (kQuestCaptureSlotCount - 1)) == 0); - -constexpr std::string_view kGalaxyStarMapMenuName = "GalaxyStarMapMenu"; -constexpr std::string_view kSelectUserEvent = "Select"; - -struct QuestKey { - std::uint32_t formID{}; - std::uint32_t instanceID{}; - - [[nodiscard]] bool operator==(const QuestKey &) const noexcept = default; -}; -static_assert(sizeof(QuestKey) == 0x8); - -struct MarkerRecord { - std::uint32_t markerHandleBits{}; - std::uint16_t 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; -}; - -enum class CapturedQuestState : std::uint8_t { - kEmpty, - kUnique, - kAmbiguous, -}; - -struct CapturedQuestSlot { - QuestKey key; - CapturedQuestState state{}; -}; - -// REL 95012 holds the native map-state lock while it calls REL 95013. Keep -// this capture fixed-capacity and primitive-only: the wrapper must not -// allocate, log, resolve forms, or retain an engine pointer in that window. -struct QuestPairCapture { - std::array slots{}; - std::size_t contributorCalls{}; - std::size_t uniqueForms{}; - std::size_t ambiguousForms{}; - bool overflow{}; - bool invalidInvocation{}; - - void Reset() noexcept { - slots.fill(CapturedQuestSlot{}); - contributorCalls = 0; - uniqueForms = 0; - ambiguousForms = 0; - overflow = false; - invalidInvocation = false; - } - - void Record(const QuestKey &a_key) noexcept { - ++contributorCalls; - if (a_key.formID == 0) { - invalidInvocation = true; - return; - } - - constexpr std::uint32_t goldenRatio = 0x9E3779B1U; - const auto first = static_cast(a_key.formID * goldenRatio) & - (kQuestCaptureSlotCount - 1); - for (std::size_t probe = 0; probe < kQuestCaptureSlotCount; ++probe) { - auto &slot = slots[(first + probe) & (kQuestCaptureSlotCount - 1)]; - if (slot.state == CapturedQuestState::kEmpty) { - slot.key = a_key; - slot.state = CapturedQuestState::kUnique; - ++uniqueForms; - return; - } - if (slot.key.formID != a_key.formID) { - continue; - } - if (slot.state == CapturedQuestState::kUnique && - slot.key.instanceID != a_key.instanceID) { - slot.state = CapturedQuestState::kAmbiguous; - --uniqueForms; - ++ambiguousForms; - } - return; - } - overflow = true; - } - - [[nodiscard]] std::optional - Resolve(const std::uint32_t a_formID) const noexcept { - if (a_formID == 0) { - return std::nullopt; - } - - constexpr std::uint32_t goldenRatio = 0x9E3779B1U; - const auto first = static_cast(a_formID * goldenRatio) & - (kQuestCaptureSlotCount - 1); - for (std::size_t probe = 0; probe < kQuestCaptureSlotCount; ++probe) { - const auto &slot = - slots[(first + probe) & (kQuestCaptureSlotCount - 1)]; - if (slot.state == CapturedQuestState::kEmpty) { - return std::nullopt; - } - if (slot.key.formID == a_formID) { - return slot.state == CapturedQuestState::kUnique - ? std::optional{slot.key} - : std::nullopt; - } - } - return std::nullopt; - } -}; - -// Large marker variants can publish several native rows with the same sentinel -// handle. Preserve every row; a handle alone is not a unique cache key. -using MarkerCache = std::vector; -using BuildSurfaceMarkers = void (*)(void *); -using ComposeQuestTarget = bool (*)(void *, void *); -using ToggleTracking = void (*)(const QuestKey *); -using DispatchButtonEvent = void (*)(RE::BSInputEventUser *, - const RE::ButtonEvent *); -using GetSurfaceMapState = void *(*)(void *); -using RefreshSurfaceMapState = void (*)(void *, void *); - -std::mutex cacheMutex; -MarkerCache markerCache; -BuildSurfaceMarkers originalBuildSurfaceMarkers{}; -ComposeQuestTarget originalComposeQuestTarget{}; -DispatchButtonEvent originalDispatchButtonEvent{}; -thread_local QuestPairCapture threadQuestCapture; -thread_local QuestPairCapture *activeQuestCapture{}; -thread_local std::size_t surfaceGatherDepth{}; -bool surfaceRefreshValidated{}; - -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(T)); - return result; -} - -[[nodiscard]] std::optional -CopyNativeText(const std::byte *a_marker, const std::ptrdiff_t a_offset) { - // These fields are in-place BSFixedStrings. Binding by reference avoids any - // acquire/release while the bytes are copied into plugin-owned storage. - const auto &text = *reinterpret_cast( - a_marker + a_offset); - const auto length = static_cast(text.length()); - if (length > kMaximumQuestTargetTextBytes) { - return std::nullopt; - } - return std::string{text.c_str(), length}; -} - -class ScopedQuestCapture { -public: - explicit ScopedQuestCapture(QuestPairCapture *a_capture) noexcept - : previous_(std::exchange(activeQuestCapture, a_capture)) {} - explicit ScopedQuestCapture(QuestPairCapture &a_capture) noexcept - : ScopedQuestCapture(std::addressof(a_capture)) {} - ~ScopedQuestCapture() { activeQuestCapture = previous_; } - - ScopedQuestCapture(const ScopedQuestCapture &) = delete; - ScopedQuestCapture &operator=(const ScopedQuestCapture &) = delete; - -private: - QuestPairCapture *previous_{}; -}; - -class ScopedGatherDepth { -public: - explicit ScopedGatherDepth(std::size_t &a_depth) noexcept - : depth_(a_depth) { - ++depth_; - } - ~ScopedGatherDepth() { --depth_; } - - ScopedGatherDepth(const ScopedGatherDepth &) = delete; - ScopedGatherDepth &operator=(const ScopedGatherDepth &) = delete; - -private: - std::size_t &depth_; -}; - -// Exact 1.16.244 ABI at REL 95012 + 0x237: RCX is the contributor context, -// RDX is the target data, and AL is the Boolean result. The context owns the -// current TESQuest pointer at +0x10. Only an exact primitive pair is copied. -[[nodiscard]] bool CaptureAndComposeQuestTarget(void *a_context, - void *a_target) noexcept { - if (auto *capture = activeQuestCapture) { - const auto *quest = - a_context ? ReadAt(a_context, kComposeQuestOffset) - : nullptr; - if (quest) { - capture->Record( - QuestKey{.formID = - ReadAt(quest, kQuestFormIDOffset), - .instanceID = - ReadAt(quest, kQuestInstanceIDOffset)}); - } else { - capture->invalidInvocation = true; - } - } - return originalComposeQuestTarget(a_context, a_target); -} - -[[nodiscard]] std::optional -ReadGFxUInt(const RE::Scaleform::GFx::Value &a_value) noexcept { - if (a_value.IsUInt()) { - return a_value.GetUInt(); - } - if (a_value.IsInt()) { - return static_cast(a_value.GetInt()); - } - if (a_value.IsNumber()) { - const double number = a_value.GetNumber(); - if (std::isfinite(number) && number >= 0.0 && - std::trunc(number) == number && - number <= - static_cast(std::numeric_limits::max())) { - return static_cast(number); - } - } - return std::nullopt; -} - -[[nodiscard]] bool -ReadGFxBooleanMember(const RE::Scaleform::GFx::Value &a_object, - const std::string_view a_name, bool &a_result) { - if (!a_object.IsObject()) { - return false; - } - RE::Scaleform::GFx::Value value; - if (!a_object.GetMember(a_name, std::addressof(value)) || - !value.IsBoolean()) { - return false; - } - a_result = value.GetBoolean(); - return true; -} - -[[nodiscard]] bool -ReadGFxStringMember(const RE::Scaleform::GFx::Value &a_object, - const std::string_view a_name, std::string &a_result) { - if (!a_object.IsObject()) { - return false; - } - RE::Scaleform::GFx::Value value; - if (!a_object.GetMember(a_name, std::addressof(value)) || - !value.IsString()) { - return false; - } - - // GetString may point into a managed GFx value. Copy it while `value` is - // alive and never retain the pointer beyond this call. - const auto *text = value.GetString(); - if (!text) { - return false; - } - std::size_t length = 0; - while (length <= kMaximumQuestTargetTextBytes && text[length] != '\0') { - ++length; - } - if (length > kMaximumQuestTargetTextBytes) { - return false; - } - a_result.assign(text, length); - return true; -} - -[[nodiscard]] std::optional -ReadGFxNestedVisible(const RE::Scaleform::GFx::Value &a_object, - const std::string_view a_memberName) { - if (!a_object.IsObject()) { - return std::nullopt; - } - RE::Scaleform::GFx::Value nested; - bool visible{}; - if (!a_object.GetMember(a_memberName, std::addressof(nested)) || - !nested.IsObject() || - !ReadGFxBooleanMember(nested, "visible", visible)) { - return std::nullopt; - } - return visible; -} - -[[nodiscard]] std::optional -FindHoveredQuestMarker(RE::BSInputEventUser *a_user) { - auto *ui = RE::UI::GetSingleton(); - if (!ui) { - REX::INFO("Select release preserved vanilla: UI singleton unavailable"); - return std::nullopt; - } - - const RE::BSFixedString menuName{kGalaxyStarMapMenuName.data()}; - auto menu = ui->GetMenu(menuName); - if (!menu) { - REX::INFO( - "Select release preserved vanilla: GalaxyStarMapMenu unavailable"); - return std::nullopt; - } - if (static_cast(menu.get()) != a_user) { - REX::INFO("Select release preserved vanilla: live GalaxyStarMapMenu does " - "not match input recipient"); - return std::nullopt; - } - if (!menu->uiMovie || !menu->uiMovie->asMovieRoot) { - REX::INFO( - "Select release preserved vanilla: GalaxyStarMapMenu movie unavailable"); - return std::nullopt; - } - - 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()) { - REX::INFO("Select release preserved vanilla: GalaxyStarMapMenu root " - "unavailable"); - return std::nullopt; - } - - RE::Scaleform::GFx::Value surfaceMap; - RE::Scaleform::GFx::Value map; - RE::Scaleform::GFx::Value markers; - if (!hostRoot.GetMember("SurfaceMap_mc", std::addressof(surfaceMap)) || - !surfaceMap.IsObject() || - !surfaceMap.GetMember("Map_mc", std::addressof(map)) || - !map.IsObject() || - !map.GetMember("MarkersContainer_mc", std::addressof(markers)) || - !markers.IsObject()) { - REX::INFO("Select release preserved vanilla: public SurfaceMap_mc.Map_mc." - "MarkersContainer_mc path unavailable"); - return std::nullopt; - } - - bool surfaceMapVisible{}; - if (!ReadGFxBooleanMember(surfaceMap, "visible", surfaceMapVisible) || - !surfaceMapVisible) { - REX::INFO("Select release preserved vanilla: SurfaceMap_mc is not " - "visibly active"); - return std::nullopt; - } - - RE::Scaleform::GFx::Value childCountValue; - if (!markers.GetMember("numChildren", std::addressof(childCountValue))) { - REX::INFO( - "Select release preserved vanilla: marker child count unavailable"); - return std::nullopt; - } - const auto childCountValueUnsigned = ReadGFxUInt(childCountValue); - if (!childCountValueUnsigned || - *childCountValueUnsigned > kMaximumUIChildren) { - REX::INFO("Select release preserved vanilla: invalid marker child count " - "(maximum={})", - kMaximumUIChildren); - return std::nullopt; - } - - const auto childCount = - static_cast(*childCountValueUnsigned); - for (std::size_t reverseIndex = childCount; reverseIndex > 0; - --reverseIndex) { - const auto index = reverseIndex - 1; - RE::Scaleform::GFx::Value child; - RE::Scaleform::GFx::Value childIndex{ - static_cast(index)}; - if (!markers.Invoke("getChildAt", std::addressof(child), - std::addressof(childIndex), 1)) { - REX::INFO("Select release preserved vanilla: getChildAt({}) failed", - index); - return std::nullopt; - } - if (!child.IsObject()) { - continue; - } - - bool childVisible{}; - if (!ReadGFxBooleanMember(child, "visible", childVisible) || - !childVisible) { - continue; - } - - const bool questTargetVisible = - ReadGFxNestedVisible(child, "QuestTargetText_mc").value_or(false); - const bool nameplateVisible = - ReadGFxNestedVisible(child, "Nameplate_mc").value_or(false); - if (!questTargetVisible && !nameplateVisible) { - continue; - } - - // SurfaceMarkerContainer moves CurrentHoveredMarker to the top of the - // display list. Reverse order therefore selects the same marker vanilla - // will dispatch, even when a large nameplate overlaps a smaller marker. - bool hasQuestTarget{}; - bool hasActiveQuest{}; - bool isLocation{}; - if (!ReadGFxBooleanMember(child, "hasQuestTarget", hasQuestTarget) || - !ReadGFxBooleanMember(child, "hasActiveQuest", hasActiveQuest) || - !ReadGFxBooleanMember(child, "IsLocation", isLocation)) { - REX::INFO("Select release preserved vanilla: topmost hovered marker " - "has invalid Boolean metadata (index={})", - index); - return std::nullopt; - } - - RE::Scaleform::GFx::Value handleValue; - RE::Scaleform::GFx::Value markerData; - RE::Scaleform::GFx::Value markerTypeValue; - if (!child.GetMember("handleBits", std::addressof(handleValue)) || - !child.GetMember("MarkerData", std::addressof(markerData)) || - !markerData.IsObject() || - !markerData.GetMember("iMarkerType", - std::addressof(markerTypeValue))) { - REX::INFO("Select release preserved vanilla: topmost hovered marker " - "has invalid identity metadata (index={})", - index); - return std::nullopt; - } - - const auto handle = ReadGFxUInt(handleValue); - const auto markerType = ReadGFxUInt(markerTypeValue); - if (!handle || !markerType) { - REX::INFO("Select release preserved vanilla: topmost hovered marker " - "has non-integral identity metadata (index={})", - index); - return std::nullopt; - } - - Request candidate{.markerHandleBits = *handle, - .markerType = *markerType, - .isLocation = isLocation}; - const bool largeNameplate = - nameplateVisible && *markerType == kLargeQuestMarkerType && - !isLocation && !hasQuestTarget && !hasActiveQuest; - const bool smallQuestTarget = - questTargetVisible && hasQuestTarget && !hasActiveQuest; - - if (largeNameplate) { - candidate.variant = MarkerVariant::kLargeNameplate; - if (!ReadGFxStringMember(markerData, "sNameText", candidate.nameText) || - !ReadGFxStringMember(markerData, "sExtraText", - candidate.extraText)) { - REX::INFO("Select release preserved vanilla: large marker has " - "invalid name/extra text (index={})", - index); - return std::nullopt; - } - } else if (smallQuestTarget) { - candidate.variant = MarkerVariant::kQuestTarget; - if (!ReadGFxStringMember(markerData, "sQuestTargetText", - candidate.questTargetText)) { - REX::INFO("Select release preserved vanilla: quest marker has " - "invalid target text (index={})", - index); - return std::nullopt; - } - } else { - REX::INFO("Select release preserved vanilla: topmost hovered marker " - "is ineligible (index={}, handle=0x{:08X}, type={}, " - "location={}, questTarget={}, active={}, nameplate={}, " - "questLabel={})", - index, *handle, *markerType, isLocation, hasQuestTarget, - hasActiveQuest, nameplateVisible, questTargetVisible); - return std::nullopt; - } - - REX::INFO("Select release resolved topmost {} marker: handle=0x{:08X}, " - "type={}, index={}, children={}, nameBytes={}, extraBytes={}, " - "questTextBytes={}", - candidate.variant == MarkerVariant::kLargeNameplate - ? "large-nameplate" - : "quest-target", - candidate.markerHandleBits, candidate.markerType, index, - childCount, candidate.nameText.size(), candidate.extraText.size(), - candidate.questTargetText.size()); - return candidate; - } - - REX::INFO("Select release preserved vanilla: no visible hovered marker " - "label among {} direct children", - childCount); - return std::nullopt; -} - -[[nodiscard]] bool -IsExactSelectRelease(const RE::ButtonEvent *a_event) { - if (!a_event || - a_event->eventType != RE::InputEvent::EventType::kButton || - a_event->status == RE::InputEvent::Status::kStop || - !std::isfinite(a_event->value) || - !std::isfinite(a_event->heldDownSecs) || a_event->heldDownSecs < 0.0F || - a_event->value != 0.0F || a_event->disabled) { - return false; - } - - // Starfield 1.16.244 returns a pointer/reference from the QUserEvent vslot, - // while this CommonLibSF snapshot declares a by-value return. Calling that - // wrapper therefore applies the wrong hidden-return ABI. Read the incoming - // ButtonEvent's owned string in place; its lifetime covers this dispatch and - // no reference-counted copy or release is performed. - const auto &userEvent = a_event->strUserEvent; - return std::string_view{userEvent.c_str(), userEvent.length()} == - kSelectUserEvent; -} - -// Exact ABI at REL 94684 + 0x10C: RCX is the BSInputEventUser subobject and -// RDX is the ButtonEvent. Vanilla REL 130632 is called exactly once unless a -// unique marker request was accepted and queued. -void OnStarMapButton(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 = TryActivate(*request); - if (!consumed) { - REX::INFO("Select release preserved vanilla: native ownership/live " - "quest validation rejected marker 0x{:08X}", - request->markerHandleBits); - } - } - } - } catch (const std::exception &error) { - REX::ERROR("Star Map Select resolver failed; preserving vanilla: {}", - error.what()); - } catch (...) { - REX::ERROR( - "Star Map Select resolver failed unexpectedly; preserving vanilla"); - } - - if (consumed) { - const_cast(a_event)->status = - RE::InputEvent::Status::kStop; - REX::INFO("Consumed Star Map Select release after queuing quest " - "activation"); - return; - } - - if (!originalDispatchButtonEvent) { - REX::FAIL("Star Map input hook has no vanilla dispatcher"); - } - originalDispatchButtonEvent(a_user, a_event); -} - -[[nodiscard]] std::uintptr_t -ReadRelativeCallTarget(const std::uintptr_t a_callsite) noexcept { - std::int32_t displacement{}; - std::memcpy(std::addressof(displacement), - reinterpret_cast(a_callsite + 1), - sizeof(displacement)); - return static_cast( - static_cast(a_callsite + 5) + displacement); -} - -void PublishCache(MarkerCache a_next) { - std::scoped_lock lock(cacheMutex); - markerCache = std::move(a_next); -} - -[[nodiscard]] RE::TESQuest *ResolveQuest(const QuestKey &a_key) noexcept { - auto *quest = RE::TESForm::LookupByID(a_key.formID); - if (!quest || ReadAt(quest, kQuestInstanceIDOffset) != - a_key.instanceID) { - return nullptr; - } - return quest; -} - -[[nodiscard]] bool IsInactiveTrackableQuest(const QuestKey &a_key) noexcept { - const auto *quest = ResolveQuest(a_key); - if (!quest) { - return false; - } - - const auto state = ReadAt(quest, kQuestStateOffset); - return (state & kQuestRunning) != 0 && (state & kQuestStopped) == 0 && - (state & kQuestTracked) == 0; -} - -void RebuildCurrentSurfaceMap() noexcept { - if (!surfaceRefreshValidated) { - return; - } - - try { - auto *ui = RE::UI::GetSingleton(); - if (!ui) { - return; - } - - const RE::BSFixedString menuName{kGalaxyStarMapMenuName.data()}; - auto menu = ui->GetMenu(menuName); - if (!menu) { - return; - } - if (ReadAt(menu.get(), 0) != - kStarMapMenuVtableID.address()) { - REX::WARN("Skipped Surface Map repaint: unexpected StarMapMenu vtable"); - return; - } - - static REL::Relocation getSurfaceMapState{ - kGetSurfaceMapStateID}; - void *surfaceState = getSurfaceMapState(menu.get()); - if (!surfaceState) { - return; - } - if (ReadAt(surfaceState, 0) != - kSurfaceMapStateVtableID.address()) { - REX::WARN("Skipped Surface Map repaint: unexpected SurfaceMapState " - "vtable"); - return; - } - - static REL::Relocation refreshSurfaceMapState{ - kRefreshSurfaceMapStateID}; - refreshSurfaceMapState(surfaceState, nullptr); - REX::INFO("Rebuilt the open Surface Map after quest activation"); - } catch (const std::exception &error) { - REX::WARN("Surface Map repaint failed safely: {}", error.what()); - } catch (...) { - REX::WARN("Surface Map repaint failed safely"); - } -} - -void SnapshotMarkerOwners(void *a_surfaceState, - const QuestPairCapture &a_capture) { - MarkerCache next; - if (!a_surfaceState) { - PublishCache(std::move(next)); - return; - } - - const auto markerBegin = - ReadAt(a_surfaceState, kMarkerBeginOffset); - const auto markerEnd = - ReadAt(a_surfaceState, kMarkerEndOffset); - const auto markerCapacity = - ReadAt(a_surfaceState, kMarkerCapacityOffset); - if (!markerBegin || markerEnd < markerBegin || markerCapacity < markerEnd || - (markerEnd - markerBegin) % kMarkerStride != 0 || - (markerCapacity - markerBegin) % kMarkerStride != 0) { - REX::WARN("Rejected invalid SurfaceMap marker range"); - PublishCache(std::move(next)); - return; - } - - const auto markerCount = - static_cast((markerEnd - markerBegin) / kMarkerStride); - if (markerCount > kMaximumMarkers) { - REX::WARN("Rejected implausible SurfaceMap marker count {}", markerCount); - PublishCache(std::move(next)); - return; - } - next.reserve(markerCount); - - std::size_t rejectedRows = 0; - for (std::size_t index = 0; index < markerCount; ++index) { - const auto *marker = reinterpret_cast( - markerBegin + index * kMarkerStride); - const auto handle = ReadAt(marker, kHandleOffset); - const auto markerType = - ReadAt(marker, kMarkerTypeOffset); - const auto ownerBegin = ReadAt(marker, kOwnerBeginOffset); - const auto ownerEnd = ReadAt(marker, kOwnerEndOffset); - const auto ownerCapacity = - ReadAt(marker, kOwnerCapacityOffset); - if (ownerBegin == ownerEnd) { - continue; - } - - const auto rawLocation = ReadAt(marker, kIsLocationOffset); - const auto rawHasTarget = - ReadAt(marker, kHasQuestTargetOffset); - const auto rawActive = ReadAt(marker, kQuestActiveOffset); - if (rawLocation > 1 || rawHasTarget > 1 || rawActive > 1) { - ++rejectedRows; - continue; - } - - const auto nameText = CopyNativeText(marker, kNameTextOffset); - const auto extraText = CopyNativeText(marker, kExtraTextOffset); - const auto questTargetText = - CopyNativeText(marker, kQuestTargetTextOffset); - if (!nameText || !extraText || !questTargetText) { - ++rejectedRows; - continue; - } - - MarkerRecord incoming{.markerHandleBits = handle, - .markerType = markerType, - .isLocation = rawLocation != 0, - .hasQuestTarget = rawHasTarget != 0, - .questActive = rawActive != 0, - .nameText = *nameText, - .extraText = *extraText, - .questTargetText = *questTargetText}; - - if (!ownerBegin || ownerEnd < ownerBegin || ownerCapacity < ownerEnd || - (ownerEnd - ownerBegin) % sizeof(std::uint32_t) != 0 || - (ownerCapacity - ownerBegin) % sizeof(std::uint32_t) != 0) { - ++rejectedRows; - continue; - } - - const auto ownerCount = static_cast((ownerEnd - ownerBegin) / - sizeof(std::uint32_t)); - incoming.rawOwnerCount = ownerCount; - if (ownerCount == 0 || ownerCount > kMaximumOwners) { - ++rejectedRows; - continue; - } - - bool valid = true; - std::uint32_t visibleFormID{}; - incoming.owners.reserve(ownerCount); - for (std::size_t ownerIndex = 0; ownerIndex < ownerCount; ++ownerIndex) { - const auto formID = ReadAt( - reinterpret_cast(ownerBegin), - static_cast(ownerIndex * sizeof(std::uint32_t))); - // For an eligible inactive row, REL 95013 overwrites +0x38 before each - // append, so the raw final element produced sQuestTargetText. Preserve - // order across deduplication: Q1,Q2,Q1 visibly represents Q1, not Q2. - // Active rows are never eligible; a large row must have one raw owner. - visibleFormID = formID; - const auto key = a_capture.Resolve(formID); - if (!key || !ResolveQuest(*key)) { - valid = false; - break; - } - if (std::ranges::find(incoming.owners, *key) == incoming.owners.end()) { - incoming.owners.push_back(*key); - } - } - - const auto visibleOwner = a_capture.Resolve(visibleFormID); - if (!valid || incoming.owners.empty() || !visibleOwner || - std::ranges::find(incoming.owners, *visibleOwner) == - incoming.owners.end()) { - ++rejectedRows; - continue; - } - incoming.visibleOwner = *visibleOwner; - - next.push_back(std::move(incoming)); - } - - // Publishing a partial generation could let an unreadable row share an - // exact UI tuple with a retained row and activate the wrong quest. Duplicate - // handles are valid now; any genuinely rejected nonempty-owner row instead - // invalidates this whole generation. - if (rejectedRows != 0) { - REX::WARN("Rejected entire SurfaceMap ownership generation: {} invalid " - "nonempty-owner row(s)", - rejectedRows); - PublishCache({}); - return; - } - - std::size_t questTargetLocations = 0; - std::size_t inactiveQuestTargetLocations = 0; - std::size_t eligibleQuestTargetLocations = 0; - std::size_t largeNameplateQuestMarkers = 0; - std::size_t eligibleLargeNameplateQuestMarkers = 0; - std::size_t multiOwner = 0; - for (const auto &marker : next) { - if (marker.isLocation && marker.hasQuestTarget) { - ++questTargetLocations; - if (!marker.questActive) { - ++inactiveQuestTargetLocations; - if (marker.visibleOwner) { - ++eligibleQuestTargetLocations; - } - } - } - if (marker.markerType == kLargeQuestMarkerType && !marker.isLocation && - !marker.hasQuestTarget) { - ++largeNameplateQuestMarkers; - if (!marker.questActive && marker.rawOwnerCount == 1 && - marker.visibleOwner) { - ++eligibleLargeNameplateQuestMarkers; - } - } - if (marker.owners.size() != 1) { - ++multiOwner; - } - } - REX::INFO("Published SurfaceMap ownership generation: markers={}, " - "questTargetLocations={}, inactiveQuestTargetLocations={}, " - "eligibleQuestTargetLocations={}, largeNameplates={}, " - "eligibleLargeNameplates={}, multiOwner={}, rejected={}, " - "capturedCalls={}, capturedUniqueForms={}, " - "capturedAmbiguousForms={}", - next.size(), questTargetLocations, inactiveQuestTargetLocations, - eligibleQuestTargetLocations, largeNameplateQuestMarkers, - eligibleLargeNameplateQuestMarkers, multiOwner, - rejectedRows, a_capture.contributorCalls, - a_capture.uniqueForms, a_capture.ambiguousForms); - PublishCache(std::move(next)); -} - -void BuildAndSnapshot(void *a_surfaceState) noexcept { - ScopedGatherDepth gatherDepth{surfaceGatherDepth}; - - // Recursion is not expected, but every nested depth must remain suppressed - // and only the true outermost call may reset or publish the TLS generation. - if (surfaceGatherDepth > 1) { - if (auto *outerCapture = activeQuestCapture) { - outerCapture->invalidInvocation = true; - } - ScopedQuestCapture suppressCapture{nullptr}; - originalBuildSurfaceMarkers(a_surfaceState); - return; - } - - // Make the rebuild window fail closed. A reused handle must never resolve - // against the preceding generation while native entries are repopulated. - ResetCache(); - threadQuestCapture.Reset(); - { - ScopedQuestCapture captureScope{threadQuestCapture}; - originalBuildSurfaceMarkers(a_surfaceState); - } - if (threadQuestCapture.overflow || threadQuestCapture.invalidInvocation) { - REX::ERROR("Rejected SurfaceMap ownership generation: captureOverflow={}, " - "invalidComposeInvocation={}", - threadQuestCapture.overflow, - threadQuestCapture.invalidInvocation); - return; - } - try { - SnapshotMarkerOwners(a_surfaceState, threadQuestCapture); - } catch (const std::exception &error) { - REX::ERROR("SurfaceMap ownership snapshot failed: {}", error.what()); - ResetCache(); - } catch (...) { - REX::ERROR("SurfaceMap ownership snapshot failed unexpectedly"); - ResetCache(); - } -} - -[[nodiscard]] std::optional ResolveRequest(const Request &a_request) { - std::scoped_lock lock(cacheMutex); - if (a_request.markerType > std::numeric_limits::max()) { - REX::WARN("Rejected out-of-range SurfaceMap marker type {}", - a_request.markerType); - return std::nullopt; - } - - std::vector uniqueOwners; - std::size_t matchingRows = 0; - bool invalidMatchingRow = false; - for (const auto &marker : markerCache) { - if (marker.markerHandleBits != a_request.markerHandleBits || - marker.markerType != - static_cast(a_request.markerType) || - marker.isLocation != a_request.isLocation) { - continue; - } - - bool exactMatch = false; - switch (a_request.variant) { - case MarkerVariant::kQuestTarget: - exactMatch = marker.hasQuestTarget && !marker.questActive && - marker.questTargetText == a_request.questTargetText; - break; - case MarkerVariant::kLargeNameplate: - exactMatch = a_request.markerType == kLargeQuestMarkerType && - !a_request.isLocation && - marker.markerType == kLargeQuestMarkerType && - !marker.isLocation && !marker.hasQuestTarget && - !marker.questActive && - marker.nameText == a_request.nameText && - marker.extraText == a_request.extraText; - break; - default: - REX::WARN("Rejected SurfaceMap marker request with unknown variant"); - return std::nullopt; - } - if (!exactMatch) { - continue; - } - - ++matchingRows; - if (!marker.visibleOwner || - (a_request.variant == MarkerVariant::kLargeNameplate && - marker.rawOwnerCount != 1)) { - invalidMatchingRow = true; - continue; - } - if (std::ranges::find(uniqueOwners, *marker.visibleOwner) == - uniqueOwners.end()) { - uniqueOwners.push_back(*marker.visibleOwner); - } - } - - if (matchingRows == 0) { - REX::WARN("Rejected unknown {} SurfaceMap marker tuple: handle=0x{:08X}, " - "type={}, location={}, nameBytes={}, extraBytes={}, " - "questTextBytes={}", - a_request.variant == MarkerVariant::kLargeNameplate - ? "large-nameplate" - : "quest-target", - a_request.markerHandleBits, a_request.markerType, - a_request.isLocation, a_request.nameText.size(), - a_request.extraText.size(), a_request.questTargetText.size()); - return std::nullopt; - } - - if (invalidMatchingRow || uniqueOwners.size() != 1) { - REX::WARN("Rejected ambiguous {} SurfaceMap marker tuple: " - "handle=0x{:08X}, matchingRows={}, uniqueOwners={}, " - "invalidMatchingRow={}", - a_request.variant == MarkerVariant::kLargeNameplate - ? "large-nameplate" - : "quest-target", - a_request.markerHandleBits, matchingRows, uniqueOwners.size(), - invalidMatchingRow); - return std::nullopt; - } - - const auto owner = uniqueOwners.front(); - REX::INFO("Resolved {} SurfaceMap marker 0x{:08X}: visible quest " - "0x{:08X}, instance={}, matchingRows={}", - a_request.variant == MarkerVariant::kLargeNameplate - ? "large-nameplate" - : "quest-target", - a_request.markerHandleBits, owner.formID, owner.instanceID, - matchingRows); - return owner; -} - -void ActivateOnMainThread(const QuestKey a_key) noexcept { - // REL 91440 toggles bit 11, so this second check is mandatory. It - // also makes duplicate mouse/Select requests harmless. - if (!IsInactiveTrackableQuest(a_key)) { - REX::WARN("Skipped stale/already-active quest 0x{:08X}, instance={}", - a_key.formID, a_key.instanceID); - return; - } - - static REL::Relocation toggleTracking{kToggleTrackingID}; - toggleTracking(std::addressof(a_key)); - - const auto *quest = ResolveQuest(a_key); - const bool tracked = - quest && - (ReadAt(quest, kQuestStateOffset) & kQuestTracked) != 0; - if (tracked) { - REX::INFO("Tracked SurfaceMap quest 0x{:08X}, instance={}", a_key.formID, - a_key.instanceID); - RebuildCurrentSurfaceMap(); - } else { - REX::WARN("Vanilla helper rejected quest 0x{:08X}, instance={}", - a_key.formID, a_key.instanceID); - } -} -} // namespace - -bool Install() noexcept { - try { - const auto gatherCallsite = - kSurfaceRebuildID.address() + kQuestGatherCallOffset; - const auto composeCallsite = - kQuestGatherID.address() + kQuestComposeCallOffset; - const auto inputCallsite = - kStarMapButtonHandlerID.address() + kStarMapInputCallOffset; - const auto expectedGatherTarget = kQuestGatherID.address(); - const auto expectedComposeTarget = kQuestComposeID.address(); - const auto expectedInputTarget = kVanillaButtonDispatcherID.address(); - constexpr std::array expectedGatherBytes{ - 0x48, 0x8B, 0xCF, 0xE8, 0xE4, 0x27, 0x00, 0x00, - 0x48, 0x83, 0xBF, 0xE8, 0x08, 0x00, 0x00, 0x00}; - constexpr std::array expectedComposeBytes{ - 0x48, 0x8B, 0x13, 0x48, 0x8D, 0x4D, - 0xC7, 0xE8, 0xD4, 0x00, 0x00, 0x00}; - constexpr std::array expectedInputBytes{ - 0x77, 0x0F, 0x84, 0xC0, 0x75, 0x0B, 0x48, 0x8B, 0xD3, 0x49, - 0x8B, 0xCF, 0xE8, 0x6F, 0x8F, 0xE9, 0x00, 0x40, 0x84, 0xED}; - constexpr std::array expectedInputTargetBytes{ - 0x48, 0x89, 0x5C, 0x24, 0x18, 0x55, 0x56, 0x57, - 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57}; - constexpr std::array expectedGetSurfaceStateBytes{ - 0x40, 0x53, 0x48, 0x83, 0xEC, 0x20, 0x48, 0x8D, 0x99, - 0xF0, 0x11, 0x00, 0x00, 0xC6, 0x44, 0x24, 0x30, 0x03}; - constexpr std::array expectedRefreshSurfaceStateBytes{ - 0x48, 0x89, 0x5C, 0x24, 0x08, 0x48, 0x89, 0x6C, 0x24, - 0x10, 0x48, 0x89, 0x74, 0x24, 0x18, 0x48, 0x89, 0x7C, - 0x24, 0x20, 0x41, 0x56, 0x48, 0x83, 0xEC, 0x40}; - if (std::memcmp(reinterpret_cast(gatherCallsite - 3), - expectedGatherBytes.data(), - expectedGatherBytes.size()) != 0) { - REX::ERROR("SurfaceMap gather-hook signature mismatch at 0x{:X}", - gatherCallsite); - return false; - } - if (std::memcmp(reinterpret_cast(composeCallsite - 7), - expectedComposeBytes.data(), - expectedComposeBytes.size()) != 0) { - REX::ERROR("SurfaceMap compose-hook signature mismatch at 0x{:X}", - composeCallsite); - return false; - } - if (std::memcmp(reinterpret_cast(inputCallsite - 12), - expectedInputBytes.data(), expectedInputBytes.size()) != - 0) { - REX::ERROR("Star Map input-hook signature mismatch at 0x{:X}", - inputCallsite); - return false; - } - if (std::memcmp(reinterpret_cast(expectedInputTarget), - expectedInputTargetBytes.data(), - expectedInputTargetBytes.size()) != 0) { - REX::ERROR("Star Map vanilla dispatcher signature mismatch at 0x{:X}", - expectedInputTarget); - return false; - } - - surfaceRefreshValidated = - std::memcmp(reinterpret_cast( - kGetSurfaceMapStateID.address()), - expectedGetSurfaceStateBytes.data(), - expectedGetSurfaceStateBytes.size()) == 0 && - std::memcmp(reinterpret_cast( - kRefreshSurfaceMapStateID.address()), - expectedRefreshSurfaceStateBytes.data(), - expectedRefreshSurfaceStateBytes.size()) == 0; - if (!surfaceRefreshValidated) { - REX::WARN("Surface Map repaint signatures do not match; quest tracking " - "will remain enabled but visual refresh is disabled"); - } - - const auto actualGatherTarget = ReadRelativeCallTarget(gatherCallsite); - if (actualGatherTarget != expectedGatherTarget) { - REX::ERROR("SurfaceMap gather-hook target mismatch: expected 0x{:X}, " - "found 0x{:X}", - expectedGatherTarget, actualGatherTarget); - return false; - } - const auto actualComposeTarget = ReadRelativeCallTarget(composeCallsite); - if (actualComposeTarget != expectedComposeTarget) { - REX::ERROR("SurfaceMap compose-hook target mismatch: expected 0x{:X}, " - "found 0x{:X}", - expectedComposeTarget, actualComposeTarget); - return false; - } - const auto actualInputTarget = ReadRelativeCallTarget(inputCallsite); - if (actualInputTarget != expectedInputTarget) { - REX::ERROR("Star Map input-hook target mismatch: expected 0x{:X}, " - "found 0x{:X}", - expectedInputTarget, actualInputTarget); - return false; - } - - originalBuildSurfaceMarkers = - reinterpret_cast(expectedGatherTarget); - originalComposeQuestTarget = - reinterpret_cast(expectedComposeTarget); - originalDispatchButtonEvent = - reinterpret_cast(expectedInputTarget); - - auto &trampoline = REL::GetTrampoline(); - constexpr std::size_t requiredTrampolineBytes = 42; - if (trampoline.free_size() < requiredTrampolineBytes) { - REX::ERROR("SurfaceMap hooks require {} trampoline bytes; {} remain", - requiredTrampolineBytes, trampoline.free_size()); - return false; - } - - // Allocate every branch island before touching executable callsites. - // Once writes begin, any failure or exception restores all three original - // CALL instructions before plugin load is allowed to fail. - const auto composeBranch = trampoline.allocate_branch5( - reinterpret_cast(CaptureAndComposeQuestTarget)); - const auto gatherBranch = trampoline.allocate_branch5( - reinterpret_cast(BuildAndSnapshot)); - const auto inputBranch = trampoline.allocate_branch5( - reinterpret_cast(OnStarMapButton)); - 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()); - - const auto matches = [](const std::uintptr_t a_address, - const auto &a_bytes) noexcept { - return std::memcmp(reinterpret_cast(a_address), - std::addressof(a_bytes), sizeof(a_bytes)) == 0; - }; - const auto restore = [](const std::uintptr_t a_address, - const auto &a_original) noexcept { - return REL::WriteSafe(a_address, a_original.data(), a_original.size()) && - std::memcmp(reinterpret_cast(a_address), - a_original.data(), a_original.size()) == 0; - }; - - try { - const bool composeWritten = - REL::WriteSafeData(composeCallsite, composePatch) && - matches(composeCallsite, composePatch) && - ReadRelativeCallTarget(composeCallsite) == composeBranch; - const bool gatherWritten = - composeWritten && REL::WriteSafeData(gatherCallsite, gatherPatch) && - matches(gatherCallsite, gatherPatch) && - ReadRelativeCallTarget(gatherCallsite) == gatherBranch; - const bool inputWritten = - gatherWritten && REL::WriteSafeData(inputCallsite, inputPatch) && - matches(inputCallsite, inputPatch) && - ReadRelativeCallTarget(inputCallsite) == inputBranch; - if (!composeWritten || !gatherWritten || !inputWritten) { - throw std::runtime_error("one or more hook writes failed verification"); - } - } catch (...) { - // Do not short-circuit: attempt every restoration even if one fails. - const bool inputRestored = restore(inputCallsite, originalInputCall); - const bool gatherRestored = restore(gatherCallsite, originalGatherCall); - const bool composeRestored = - restore(composeCallsite, originalComposeCall); - if (!inputRestored || !gatherRestored || !composeRestored) { - REX::FAIL("Could not restore original SurfaceMap/input callsites " - "after hook installation failure"); - } - REX::ERROR("SurfaceMap/input hook transaction failed; all original " - "calls restored"); - return false; - } - - try { - REX::INFO("Installed transactional SurfaceMap hooks: gather=0x{:X}, " - "compose=0x{:X}, input=0x{:X}", - gatherCallsite, composeCallsite, inputCallsite); - } catch (...) { - // Logging cannot turn a successfully committed hook set into a reported - // plugin-load failure. - } - return true; - } catch (const std::exception &error) { - REX::ERROR("Could not install SurfaceMap/input hooks: {}", error.what()); - } catch (...) { - REX::ERROR("Could not install SurfaceMap/input hooks"); - } - return false; -} - -void ResetCache() noexcept { - try { - PublishCache({}); - } catch (...) { - // Never unwind into Scaleform or an engine hook. - } -} - -bool TryActivate(const Request &a_request) noexcept { - try { - const auto owner = ResolveRequest(a_request); - if (!owner) { - return false; - } - // ResolveRequest releases cacheMutex before any live form lookup. The - // queued task repeats this exact check because REL 91440 is a toggle. - if (!IsInactiveTrackableQuest(*owner)) { - REX::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); - return false; - } - - const auto *tasks = SFSE::GetTaskInterface(); - if (!tasks) { - REX::ERROR("SFSE TaskInterface is unavailable"); - return false; - } - - tasks->AddTask([key = *owner] { ActivateOnMainThread(key); }); - REX::INFO("Accepted {} SurfaceMap marker 0x{:08X}; queued quest " - "0x{:08X}, instance={}", - a_request.variant == MarkerVariant::kLargeNameplate - ? "large-nameplate" - : "quest-target", - a_request.markerHandleBits, owner->formID, owner->instanceID); - return true; - } catch (const std::exception &error) { - REX::ERROR("Track Quest request failed: {}", error.what()); - } catch (...) { - REX::ERROR("Track Quest request failed unexpectedly"); - } - return false; -} -} // namespace TrackQuestSurface::SurfaceActivation diff --git a/src/SurfaceActivation.h b/src/SurfaceActivation.h deleted file mode 100644 index 2a6cf5b..0000000 --- a/src/SurfaceActivation.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include -#include - -namespace TrackQuestSurface::SurfaceActivation { -enum class MarkerVariant : std::uint8_t { - kQuestTarget, - kLargeNameplate, -}; - -struct Request { - std::uint32_t markerHandleBits{}; - std::uint32_t markerType{}; - bool isLocation{}; - MarkerVariant variant{}; - // Owned immediately from the matching MarkerData string fields. They are - // only exact within-generation discriminators and are never parsed as quest - // identity. - std::string nameText; - std::string extraText; - std::string questTargetText; -}; - -// 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. -[[nodiscard]] bool Install() noexcept; - -// Invalidates all cached marker handles after a rejected/failed generation. -void ResetCache() noexcept; - -// Returns true only when the exact inactive quest represented by the marker's -// visible target label was accepted for SFSE's main-thread task queue. -[[nodiscard]] bool TryActivate(const Request &a_request) noexcept; -} // namespace TrackQuestSurface::SurfaceActivation diff --git a/src/SurfaceMap.cpp b/src/SurfaceMap.cpp new file mode 100644 index 0000000..efa8c06 --- /dev/null +++ b/src/SurfaceMap.cpp @@ -0,0 +1,804 @@ +#include "PCH.h" + +#include "SurfaceMap.h" + +namespace TrackQuestSurface::SurfaceMap +{ + namespace + { + constexpr std::ptrdiff_t kComposeQuestOffset = 0x10; + + constexpr std::size_t kMaximumMarkers = 4096; + constexpr std::size_t kMaximumOwners = 128; + constexpr std::size_t kMaximumQuestTargetTextBytes = 4096; + constexpr std::size_t kQuestCaptureSlotCount = 4096; + static_assert((kQuestCaptureSlotCount & (kQuestCaptureSlotCount - 1)) == 0); + + using QuestKey = RE::QuestInstanceKey; + + struct MarkerRecord + { + 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; + }; + + struct CopiedMarkerRecord + { + std::uint32_t markerHandleBits{}; + RE::StarMap::SurfaceMarkerType markerType{}; + bool isLocation{}; + bool hasQuestTarget{}; + bool questActive{}; + std::string nameText; + std::string extraText; + std::string questTargetText; + std::vector ownerFormIDs; + }; + + enum class MarkerCopyFailure : std::uint8_t + { + kNone, + kInvalidMarkerRange, + kImplausibleMarkerCount + }; + + struct CopiedMarkerGeneration + { + std::vector rows; + MarkerCopyFailure failure{}; + std::size_t markerCount{}; + std::size_t rejectedRows{}; + }; + + enum class CapturedQuestState : std::uint8_t + { + kEmpty, + kUnique, + kAmbiguous + }; + + struct CapturedQuestSlot + { + QuestKey key; + CapturedQuestState state{}; + }; + + // The compose callback runs synchronously inside GatherSurfaceQuestTargets. Keep capture + // fixed-capacity and primitive-only so it does not allocate, log, resolve + // forms, or retain engine pointers while the gather routine is in progress. + struct QuestPairCapture + { + std::array slots{}; + std::size_t contributorCalls{}; + std::size_t uniqueForms{}; + std::size_t ambiguousForms{}; + bool overflow{}; + bool invalidInvocation{}; + + void Reset() noexcept + { + slots.fill(CapturedQuestSlot{}); + contributorCalls = 0; + uniqueForms = 0; + ambiguousForms = 0; + overflow = false; + invalidInvocation = false; + } + + void Record(const QuestKey& a_key) noexcept + { + ++contributorCalls; + if (a_key.formID == 0) { + invalidInvocation = true; + return; + } + + constexpr std::uint32_t goldenRatio = 0x9E3779B1U; + const auto first = + static_cast(a_key.formID * goldenRatio) & + (kQuestCaptureSlotCount - 1); + for (std::size_t probe = 0; probe < kQuestCaptureSlotCount; ++probe) { + auto& slot = slots[(first + probe) & (kQuestCaptureSlotCount - 1)]; + if (slot.state == CapturedQuestState::kEmpty) { + slot.key = a_key; + slot.state = CapturedQuestState::kUnique; + ++uniqueForms; + return; + } + if (slot.key.formID != a_key.formID) { + continue; + } + if (slot.state == CapturedQuestState::kUnique && + slot.key.instanceID != a_key.instanceID) { + slot.state = CapturedQuestState::kAmbiguous; + --uniqueForms; + ++ambiguousForms; + } + return; + } + overflow = true; + } + + [[nodiscard]] std::optional Resolve( + const std::uint32_t a_formID) const noexcept + { + if (a_formID == 0) { + return std::nullopt; + } + + constexpr std::uint32_t goldenRatio = 0x9E3779B1U; + const auto first = + static_cast(a_formID * goldenRatio) & + (kQuestCaptureSlotCount - 1); + for (std::size_t probe = 0; probe < kQuestCaptureSlotCount; ++probe) { + const auto& slot = slots[(first + probe) & (kQuestCaptureSlotCount - 1)]; + if (slot.state == CapturedQuestState::kEmpty) { + return std::nullopt; + } + if (slot.key.formID == a_formID) { + return slot.state == CapturedQuestState::kUnique ? + std::optional{ slot.key } : + std::nullopt; + } + } + return std::nullopt; + } + }; + + // Large marker variants can publish several native rows with the same + // 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{}; + thread_local QuestPairCapture threadQuestCapture; + thread_local QuestPairCapture* activeQuestCapture{}; + thread_local std::size_t surfaceGatherDepth{}; + bool surfaceRefreshValidated{}; + + class ScopedQuestCapture + { + public: + explicit ScopedQuestCapture(QuestPairCapture* a_capture) noexcept : + previous_(std::exchange(activeQuestCapture, a_capture)) + {} + + explicit ScopedQuestCapture(QuestPairCapture& a_capture) noexcept : + ScopedQuestCapture(std::addressof(a_capture)) + {} + + ~ScopedQuestCapture() { activeQuestCapture = previous_; } + + ScopedQuestCapture(const ScopedQuestCapture&) = delete; + ScopedQuestCapture& operator=(const ScopedQuestCapture&) = delete; + + private: + QuestPairCapture* previous_{}; + }; + + class ScopedGatherDepth + { + public: + explicit ScopedGatherDepth(std::size_t& a_depth) noexcept : + depth_(a_depth) + { + ++depth_; + } + + ~ScopedGatherDepth() { --depth_; } + + ScopedGatherDepth(const ScopedGatherDepth&) = delete; + ScopedGatherDepth& operator=(const ScopedGatherDepth&) = delete; + + private: + std::size_t& depth_; + }; + + [[nodiscard]] std::optional CopyNativeText( + const RE::BSFixedString& a_text) + { + const auto length = static_cast(a_text.length()); + if (length > kMaximumQuestTargetTextBytes) { + return std::nullopt; + } + return std::string{ a_text.c_str(), length }; + } + + [[nodiscard]] CopiedMarkerGeneration CopyMarkerGeneration( + const RE::StarMap::SurfaceMapState* a_surfaceState) + { + CopiedMarkerGeneration copied; + if (!a_surfaceState) { + return copied; + } + + const auto& markers = a_surfaceState->surfaceMarkers; + 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 || + (markerCapacity - markerBegin) % sizeof(RE::StarMap::SurfaceMarkerStaticData) != 0) { + copied.failure = MarkerCopyFailure::kInvalidMarkerRange; + return copied; + } + + copied.markerCount = static_cast( + (markerEnd - markerBegin) / sizeof(RE::StarMap::SurfaceMarkerStaticData)); + if (copied.markerCount > kMaximumMarkers) { + copied.failure = MarkerCopyFailure::kImplausibleMarkerCount; + return copied; + } + copied.rows.reserve(copied.markerCount); + + for (std::size_t index = 0; index < copied.markerCount; ++index) { + const auto& marker = markers.begin()[index]; + const auto ownerBegin = + reinterpret_cast(marker.questOwners.begin()); + const auto ownerEnd = + reinterpret_cast(marker.questOwners.end()); + const auto ownerCapacity = + reinterpret_cast(marker.questOwners.capacity_end()); + if (ownerBegin == ownerEnd) { + continue; + } + + if (marker.isLocation > 1 || marker.hasQuestTarget > 1 || marker.questActive > 1) { + ++copied.rejectedRows; + continue; + } + + const auto nameText = CopyNativeText(marker.nameText); + const auto extraText = CopyNativeText(marker.extraText); + const auto questTargetText = CopyNativeText(marker.questTargetText); + if (!nameText || !extraText || !questTargetText) { + ++copied.rejectedRows; + continue; + } + + if (!ownerBegin || ownerEnd < ownerBegin || ownerCapacity < ownerEnd || + (ownerEnd - ownerBegin) % sizeof(RE::TESFormID) != 0 || + (ownerCapacity - ownerBegin) % sizeof(RE::TESFormID) != 0) { + ++copied.rejectedRows; + continue; + } + + const auto ownerCount = static_cast( + (ownerEnd - ownerBegin) / sizeof(RE::TESFormID)); + if (ownerCount == 0 || ownerCount > kMaximumOwners) { + ++copied.rejectedRows; + continue; + } + + CopiedMarkerRecord incoming{ + .markerHandleBits = marker.markerHandleBits, + .markerType = marker.markerType, + .isLocation = marker.IsLocation(), + .hasQuestTarget = marker.HasQuestTarget(), + .questActive = marker.IsQuestActive(), + .nameText = *nameText, + .extraText = *extraText, + .questTargetText = *questTargetText + }; + incoming.ownerFormIDs.reserve(ownerCount); + for (std::size_t ownerIndex = 0; ownerIndex < ownerCount; ++ownerIndex) { + incoming.ownerFormIDs.push_back(marker.questOwners.begin()[ownerIndex]); + } + copied.rows.push_back(std::move(incoming)); + } + + return copied; + } + + void PublishCache(MarkerCache a_next) + { + std::scoped_lock lock(cacheMutex); + 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) { + 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 Surface Map repaint: unexpected StarMapMenu vtable"); + return; + } + + auto* starMapMenu = static_cast(menu.get()); + auto* surfaceState = starMapMenu->GetSurfaceMapState(); + if (!surfaceState) { + return; + } + + std::uintptr_t stateVtable{}; + std::memcpy(std::addressof(stateVtable), surfaceState, sizeof(stateVtable)); + if (stateVtable != RE::StarMap::SurfaceMapState::PRIMARY_VTABLE.address()) { + logger::warn("Skipped Surface Map repaint: unexpected SurfaceMapState vtable"); + return; + } + + surfaceState->Refresh(); + logger::info("Rebuilt the open Surface Map after quest activation"); + } catch (const std::exception& error) { + try { + logger::warn("Surface Map repaint failed safely: {}", error.what()); + } catch (...) { + } + } catch (...) { + try { + logger::warn("Surface Map repaint failed safely"); + } catch (...) { + } + } + } + + void SnapshotMarkerOwners( + RE::StarMap::SurfaceMapState* a_surfaceState, + 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 + // forms, logs, or publishes anything. + const auto copied = CopyMarkerGeneration(a_surfaceState); + + if (copied.failure == MarkerCopyFailure::kInvalidMarkerRange) { + logger::warn("Rejected invalid SurfaceMap marker range"); + PublishCache({}); + return; + } + if (copied.failure == MarkerCopyFailure::kImplausibleMarkerCount) { + logger::warn("Rejected implausible SurfaceMap marker count {}", copied.markerCount); + PublishCache({}); + return; + } + if (copied.rejectedRows != 0) { + logger::warn( + "Rejected entire SurfaceMap ownership generation: {} invalid nonempty-owner row(s)", + copied.rejectedRows); + PublishCache({}); + return; + } + + MarkerCache next; + next.reserve(copied.rows.size()); + std::size_t rejectedRows = 0; + for (const auto& copiedMarker : copied.rows) { + MarkerRecord incoming{ + .markerHandleBits = copiedMarker.markerHandleBits, + .markerType = copiedMarker.markerType, + .isLocation = copiedMarker.isLocation, + .hasQuestTarget = copiedMarker.hasQuestTarget, + .questActive = copiedMarker.questActive, + .nameText = copiedMarker.nameText, + .extraText = copiedMarker.extraText, + .questTargetText = copiedMarker.questTargetText, + .rawOwnerCount = copiedMarker.ownerFormIDs.size() + }; + + bool valid = true; + RE::TESFormID visibleFormID{}; + incoming.owners.reserve(copiedMarker.ownerFormIDs.size()); + for (const auto formID : copiedMarker.ownerFormIDs) { + // For an eligible inactive row, ComposeSurfaceQuestTarget overwrites questTargetText + // before each append, so the raw final owner produced the visible text. + // Preserve order across deduplication: Q1,Q2,Q1 represents Q1. + visibleFormID = formID; + const auto key = a_capture.Resolve(formID); + if (!key || !ResolveQuest(*key)) { + valid = false; + break; + } + if (std::ranges::find(incoming.owners, *key) == incoming.owners.end()) { + incoming.owners.push_back(*key); + } + } + + const auto visibleOwner = a_capture.Resolve(visibleFormID); + if (!valid || incoming.owners.empty() || !visibleOwner || + std::ranges::find(incoming.owners, *visibleOwner) == incoming.owners.end()) { + ++rejectedRows; + continue; + } + incoming.visibleOwner = *visibleOwner; + next.push_back(std::move(incoming)); + } + + // Publishing a partial generation could let an unreadable row share an + // exact UI tuple with a retained row and activate the wrong quest. + if (rejectedRows != 0) { + logger::warn( + "Rejected entire SurfaceMap ownership generation: {} invalid nonempty-owner row(s)", + rejectedRows); + PublishCache({}); + return; + } + + std::size_t questTargetLocations = 0; + std::size_t inactiveQuestTargetLocations = 0; + std::size_t eligibleQuestTargetLocations = 0; + std::size_t largeNameplateQuestMarkers = 0; + std::size_t eligibleLargeNameplateQuestMarkers = 0; + std::size_t multiOwner = 0; + for (const auto& marker : next) { + if (marker.isLocation && marker.hasQuestTarget) { + ++questTargetLocations; + if (!marker.questActive) { + ++inactiveQuestTargetLocations; + if (marker.visibleOwner) { + ++eligibleQuestTargetLocations; + } + } + } + if (marker.markerType == RE::StarMap::SurfaceMarkerType::kQuest && + !marker.isLocation && !marker.hasQuestTarget) { + ++largeNameplateQuestMarkers; + if (!marker.questActive && marker.rawOwnerCount == 1 && marker.visibleOwner) { + ++eligibleLargeNameplateQuestMarkers; + } + } + if (marker.owners.size() != 1) { + ++multiOwner; + } + } + + const auto publishedSize = next.size(); + PublishCache(std::move(next)); + try { + logger::info( + "Published SurfaceMap ownership generation: markers={}, questTargetLocations={}, inactiveQuestTargetLocations={}, eligibleQuestTargetLocations={}, largeNameplates={}, eligibleLargeNameplates={}, multiOwner={}, rejected={}, capturedCalls={}, capturedUniqueForms={}, capturedAmbiguousForms={}", + publishedSize, + questTargetLocations, + inactiveQuestTargetLocations, + eligibleQuestTargetLocations, + largeNameplateQuestMarkers, + eligibleLargeNameplateQuestMarkers, + multiOwner, + rejectedRows, + a_capture.contributorCalls, + a_capture.uniqueForms, + a_capture.ambiguousForms); + } catch (...) { + } + } + + [[nodiscard]] bool CaptureAndComposeQuestTargetImpl( + void* a_context, + void* a_target) noexcept + { + if (auto* capture = activeQuestCapture) { + RE::TESQuest* quest{}; + if (a_context) { + std::memcpy( + std::addressof(quest), + static_cast(a_context) + kComposeQuestOffset, + sizeof(quest)); + } + if (quest) { + capture->Record(quest->GetInstanceKey()); + } else { + capture->invalidInvocation = true; + } + } + return originalComposeQuestTarget(a_context, a_target); + } + + void BuildAndSnapshotImpl(RE::StarMap::SurfaceMapState* a_surfaceState) noexcept + { + ScopedGatherDepth gatherDepth{ surfaceGatherDepth }; + + // This state is owner-thread-only and non-reentrant. Suppress every nested + // capture and only let the true outermost call reset or publish a generation. + if (surfaceGatherDepth > 1) { + if (auto* outerCapture = activeQuestCapture) { + outerCapture->invalidInvocation = true; + } + ScopedQuestCapture suppressCapture{ nullptr }; + originalBuildSurfaceMarkers(a_surfaceState); + return; + } + + // A reused handle must never resolve against the preceding generation while + // native entries are being repopulated. + ResetCache(); + threadQuestCapture.Reset(); + { + ScopedQuestCapture captureScope{ threadQuestCapture }; + originalBuildSurfaceMarkers(a_surfaceState); + } + + if (threadQuestCapture.overflow || threadQuestCapture.invalidInvocation) { + try { + logger::error( + "Rejected SurfaceMap ownership generation: captureOverflow={}, invalidComposeInvocation={}", + threadQuestCapture.overflow, + threadQuestCapture.invalidInvocation); + } catch (...) { + } + return; + } + + try { + SnapshotMarkerOwners(a_surfaceState, threadQuestCapture); + } catch (const std::exception& error) { + try { + logger::error("SurfaceMap ownership snapshot failed: {}", error.what()); + } catch (...) { + } + ResetCache(); + } catch (...) { + try { + logger::error("SurfaceMap ownership snapshot failed unexpectedly"); + } catch (...) { + } + ResetCache(); + } + } + + [[nodiscard]] std::optional ResolveRequest(const Request& a_request) + { + std::scoped_lock lock(cacheMutex); + if (a_request.markerType > std::numeric_limits::max()) { + logger::warn("Rejected out-of-range SurfaceMap marker type {}", a_request.markerType); + return std::nullopt; + } + + const auto requestedType = + static_cast(a_request.markerType); + std::vector uniqueOwners; + std::size_t matchingRows = 0; + bool invalidMatchingRow = false; + for (const auto& marker : markerCache) { + if (marker.markerHandleBits != a_request.markerHandleBits || + marker.markerType != requestedType || + marker.isLocation != a_request.isLocation) { + continue; + } + + bool exactMatch = false; + switch (a_request.variant) { + case MarkerVariant::kQuestTarget: + exactMatch = marker.hasQuestTarget && !marker.questActive && + marker.questTargetText == a_request.questTargetText; + break; + case MarkerVariant::kLargeNameplate: + exactMatch = + a_request.markerType == + static_cast(RE::StarMap::SurfaceMarkerType::kQuest) && + !a_request.isLocation && + marker.markerType == RE::StarMap::SurfaceMarkerType::kQuest && + !marker.isLocation && !marker.hasQuestTarget && !marker.questActive && + marker.nameText == a_request.nameText && + marker.extraText == a_request.extraText; + break; + default: + logger::warn("Rejected SurfaceMap marker request with unknown variant"); + return std::nullopt; + } + if (!exactMatch) { + continue; + } + + ++matchingRows; + if (!marker.visibleOwner || + (a_request.variant == MarkerVariant::kLargeNameplate && marker.rawOwnerCount != 1)) { + invalidMatchingRow = true; + continue; + } + if (std::ranges::find(uniqueOwners, *marker.visibleOwner) == uniqueOwners.end()) { + uniqueOwners.push_back(*marker.visibleOwner); + } + } + + if (matchingRows == 0) { + logger::warn( + "Rejected unknown {} SurfaceMap marker tuple: handle=0x{:08X}, type={}, location={}, nameBytes={}, extraBytes={}, questTextBytes={}", + a_request.variant == MarkerVariant::kLargeNameplate ? + "large-nameplate" : + "quest-target", + a_request.markerHandleBits, + a_request.markerType, + a_request.isLocation, + a_request.nameText.size(), + a_request.extraText.size(), + a_request.questTargetText.size()); + return std::nullopt; + } + + if (invalidMatchingRow || uniqueOwners.size() != 1) { + logger::warn( + "Rejected ambiguous {} SurfaceMap marker tuple: handle=0x{:08X}, matchingRows={}, uniqueOwners={}, invalidMatchingRow={}", + a_request.variant == MarkerVariant::kLargeNameplate ? + "large-nameplate" : + "quest-target", + a_request.markerHandleBits, + matchingRows, + uniqueOwners.size(), + invalidMatchingRow); + return std::nullopt; + } + + const auto owner = uniqueOwners.front(); + logger::info( + "Resolved {} SurfaceMap marker 0x{:08X}: visible quest 0x{:08X}, instance={}, matchingRows={}", + a_request.variant == MarkerVariant::kLargeNameplate ? + "large-nameplate" : + "quest-target", + a_request.markerHandleBits, + owner.formID, + owner.instanceID, + matchingRows); + 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( + const BuildSurfaceMarkers a_buildSurfaceMarkers, + const ComposeQuestTarget a_composeQuestTarget, + const bool a_surfaceRefreshValidated) noexcept + { + originalBuildSurfaceMarkers = a_buildSurfaceMarkers; + originalComposeQuestTarget = a_composeQuestTarget; + surfaceRefreshValidated = a_surfaceRefreshValidated; + } + + bool CaptureAndComposeQuestTarget(void* a_context, void* a_target) noexcept + { + return CaptureAndComposeQuestTargetImpl(a_context, a_target); + } + + void BuildAndSnapshot(RE::StarMap::SurfaceMapState* a_surfaceState) noexcept + { + BuildAndSnapshotImpl(a_surfaceState); + } + + void ResetCache() noexcept + { + try { + PublishCache({}); + } catch (...) { + // Never unwind into Scaleform or an engine hook. + } + } + + bool TryActivate(const Request& a_request) noexcept + { + try { + const auto owner = ResolveRequest(a_request); + if (!owner) { + 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); + 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={}", + a_request.variant == MarkerVariant::kLargeNameplate ? + "large-nameplate" : + "quest-target", + a_request.markerHandleBits, + owner->formID, + owner->instanceID); + } catch (...) { + } + return true; + } catch (const std::exception& error) { + try { + logger::error("Track Quest request failed: {}", error.what()); + } catch (...) { + } + } catch (...) { + try { + logger::error("Track Quest request failed unexpectedly"); + } catch (...) { + } + } + return false; + } +} diff --git a/src/SurfaceMap.h b/src/SurfaceMap.h new file mode 100644 index 0000000..093074a --- /dev/null +++ b/src/SurfaceMap.h @@ -0,0 +1,48 @@ +#pragma once + +#include "RE/S/StarMap.h" + +#include +#include + +namespace TrackQuestSurface::SurfaceMap +{ + enum class MarkerVariant : std::uint8_t + { + kQuestTarget, + kLargeNameplate, + }; + + struct Request + { + std::uint32_t markerHandleBits{}; + std::uint32_t markerType{}; + bool isLocation{}; + MarkerVariant variant{}; + + // Owned immediately from the matching MarkerData string fields. They are + // only exact within-generation discriminators and are never parsed as quest + // identity. + std::string nameText; + std::string extraText; + std::string questTargetText; + }; + + using BuildSurfaceMarkers = void (*)(RE::StarMap::SurfaceMapState*); + using ComposeQuestTarget = bool (*)(void*, void*); + + void SetOriginalFunctions( + BuildSurfaceMarkers a_buildSurfaceMarkers, + ComposeQuestTarget a_composeQuestTarget, + bool a_surfaceRefreshValidated) noexcept; + + [[nodiscard]] bool CaptureAndComposeQuestTarget(void* a_context, void* a_target) noexcept; + void BuildAndSnapshot(RE::StarMap::SurfaceMapState* a_surfaceState) noexcept; + + // Invalidates all cached marker handles after a rejected/failed generation. + void ResetCache() noexcept; + + // Returns true only when the exact inactive quest represented by the marker's + // visible target label was accepted for SFSE's main-thread task queue. + [[nodiscard]] bool TryActivate(const Request& a_request) noexcept; +} diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index db1e3d4..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,52 +0,0 @@ -#include "SurfaceActivation.h" - -#include "RE/Starfield.h" -#include "SFSE/SFSE.h" - -SFSE_PLUGIN_VERSION = []() noexcept { - SFSE::PluginVersionData version{}; - version.PluginVersion({0, 2, 2, 0}); - version.PluginName("TrackQuestSurfaceNativeOnly"); - version.AuthorName("Quantumyilmaz"); - version.UsesSigScanning(false); - version.UsesAddressLibrary(true); - version.HasNoStructUse(false); - version.IsLayoutDependent(true); - version.CompatibleVersions({SFSE::RUNTIME_SF_1_16_244}); - version.MinimumRequiredXSEVersion(SFSE::SFSE_PACK_LATEST); - return version; -}(); - -SFSE_PLUGIN_LOAD(const SFSE::LoadInterface *a_sfse) { - if (!a_sfse) { - return false; - } - - SFSE::InitInfo initInfo{.logPattern = "%Y-%m-%d %H:%M:%S.%e [%l] %v", - .trampoline = true, - .trampolineSize = 64}; - SFSE::Init(a_sfse, initInfo); - - const auto runtime = a_sfse->RuntimeVersion(); - REX::INFO( - "TrackQuestSurfaceNativeOnly 0.2.2 loaded; runtime={}, SFSE=0x{:08X}", - runtime, a_sfse->SFSEVersion()); - - if (runtime != SFSE::RUNTIME_SF_1_16_244) { - REX::ERROR("Unsupported runtime {}; this prototype is gated to {}", runtime, - SFSE::RUNTIME_SF_1_16_244); - return false; - } - - const auto *taskInterface = SFSE::GetTaskInterface(); - if (!taskInterface) { - REX::ERROR("Required SFSE TaskInterface is unavailable"); - return false; - } - - if (!TrackQuestSurface::SurfaceActivation::Install()) { - REX::ERROR("Transactional SurfaceMap/input hooks could not be installed"); - return false; - } - return true; -} diff --git a/src/plugin.cpp b/src/plugin.cpp new file mode 100644 index 0000000..fcebb2e --- /dev/null +++ b/src/plugin.cpp @@ -0,0 +1,69 @@ +#include "PCH.h" + +#include "Hooks.h" + +SFSE_PLUGIN_VERSION = []() noexcept { + SFSE::PluginVersionData version{}; + version.PluginVersion({ 0, 2, 2, 0 }); + version.PluginName("TrackQuestSurfaceNativeOnly"); + version.AuthorName("Quantumyilmaz"); + version.UsesSigScanning(false); + version.UsesAddressLibrary(true); + version.HasNoStructUse(false); + version.IsLayoutDependent(true); + version.CompatibleVersions({ SFSE::RUNTIME_SF_1_16_244 }); + version.MinimumRequiredXSEVersion(SFSE::SFSE_PACK_LATEST); + return version; +}(); + +SFSE_PLUGIN_LOAD(const SFSE::LoadInterface* a_sfse) +{ + try { + if (!a_sfse) { + return false; + } + + SFSE::InitInfo initInfo{ + .logPattern = "%Y-%m-%d %H:%M:%S.%e [%l] %v", + .trampoline = true, + .trampolineSize = 64 + }; + SFSE::Init(a_sfse, initInfo); + + const auto runtime = a_sfse->RuntimeVersion(); + logger::info( + "TrackQuestSurfaceNativeOnly 0.2.2 loaded; runtime={}, SFSE=0x{:08X}", + runtime, + a_sfse->SFSEVersion()); + + if (runtime != SFSE::RUNTIME_SF_1_16_244) { + logger::error( + "Unsupported runtime {}; this prototype is gated to {}", + runtime, + SFSE::RUNTIME_SF_1_16_244); + return false; + } + + if (!SFSE::GetTaskInterface()) { + logger::error("Required SFSE TaskInterface is unavailable"); + return false; + } + + if (!TrackQuestSurface::Hooks::Install()) { + logger::error("Transactional SurfaceMap/input hooks could not be installed"); + return false; + } + return true; + } catch (const std::exception& error) { + try { + logger::error("Plugin load failed: {}", error.what()); + } catch (...) { + } + } catch (...) { + try { + logger::error("Plugin load failed unexpectedly"); + } catch (...) { + } + } + return false; +} diff --git a/xmake.lua b/xmake.lua index 9d12812..df58a3f 100644 --- a/xmake.lua +++ b/xmake.lua @@ -17,7 +17,7 @@ if is_plat("windows") then }) end -local commonlibsf = os.getenv("COMMONLIBSF_PATH") or "lib/commonlibsf" +local commonlibsf = path.join(os.projectdir(), "lib", "commonlibsf") includes(commonlibsf) set_project("TrackQuestFromMap") @@ -32,10 +32,21 @@ add_rules("plugin.vsxmake.autoupdate") target("TrackQuestSurfaceNativeOnly") set_kind("shared") set_arch("x64") + set_pcxxheader("src/PCH.h") add_defines("_SILENCE_CXX23_ALIGNED_STORAGE_DEPRECATION_WARNING") add_deps("commonlibsf") - add_files("src/*.cpp") - add_headerfiles("src/*.h") + add_files( + "src/plugin.cpp", + "src/Hooks.cpp", + "src/StarMapInput.cpp", + "src/SurfaceMap.cpp" + ) + add_headerfiles( + "src/PCH.h", + "src/Hooks.h", + "src/StarMapInput.h", + "src/SurfaceMap.h" + ) add_includedirs("src") -- Deliberately do not apply CommonLibSF's plugin packaging rule. This From dee041867d152265ef93275ea861ab54106b8a01 Mon Sep 17 00:00:00 2001 From: Quantumyilmaz <47591838+Quantumyilmaz@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:23:49 +0200 Subject: [PATCH 3/4] ci: verify builds and release payload contracts --- .github/dependabot.yml | 7 + .github/workflows/ci.yml | 67 +++++ docs/REPOSITORY_AUTOMATION.md | 40 +++ scripts/New-RecursiveSourceArchive.ps1 | 342 +++++++++++++++++++++++++ scripts/Test-BinaryPayload.ps1 | 86 +++++++ scripts/Test-SubmodulePins.ps1 | 140 ++++++++++ 6 files changed, 682 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 docs/REPOSITORY_AUTOMATION.md create mode 100644 scripts/New-RecursiveSourceArchive.ps1 create mode 100644 scripts/Test-BinaryPayload.ps1 create mode 100644 scripts/Test-SubmodulePins.ps1 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9a350e5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..45acd43 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +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/docs/REPOSITORY_AUTOMATION.md b/docs/REPOSITORY_AUTOMATION.md new file mode 100644 index 0000000..4265813 --- /dev/null +++ b/docs/REPOSITORY_AUTOMATION.md @@ -0,0 +1,40 @@ +# 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/scripts/New-RecursiveSourceArchive.ps1 b/scripts/New-RecursiveSourceArchive.ps1 new file mode 100644 index 0000000..97d318e --- /dev/null +++ b/scripts/New-RecursiveSourceArchive.ps1 @@ -0,0 +1,342 @@ +[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 new file mode 100644 index 0000000..3cc6a83 --- /dev/null +++ b/scripts/Test-BinaryPayload.ps1 @@ -0,0 +1,86 @@ +[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 new file mode 100644 index 0000000..ead5d9f --- /dev/null +++ b/scripts/Test-SubmodulePins.ps1 @@ -0,0 +1,140 @@ +[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.' From dfd8799c4aa9a2a25625ea510c7048d10dd87925 Mon Sep 17 00:00:00 2001 From: Quantumyilmaz <47591838+Quantumyilmaz@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:24:04 +0200 Subject: [PATCH 4/4] docs: record CommonLib ownership and verification --- CHANGELOG.md | 14 ++++++++++++ CONTRIBUTING.md | 4 ++-- MANIFEST.md | 4 ++++ README.md | 12 +++++----- docs/ARCHITECTURE.md | 53 +++++++++++++++++++++++++++++++------------- docs/DEVELOPMENT.md | 25 ++++++++++++++------- docs/ROADMAP.md | 4 ++-- 7 files changed, 83 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff4c258..cfd984b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e30979b..e8250f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,8 +33,8 @@ xmake f -c -m release -a x64 -p windows -y xmake -r -y TrackQuestSurfaceNativeOnly ``` -`COMMONLIBSF_PATH` is a developer-only override. Release builds use the pinned -`lib/commonlibsf` submodule. The build target must remain side-effect free. +Builds always use the pinned `lib/commonlibsf` submodule. The build target must +remain side-effect free. ## Change workflow diff --git a/MANIFEST.md b/MANIFEST.md index 5484809..a7fdacd 100644 --- a/MANIFEST.md +++ b/MANIFEST.md @@ -1,5 +1,9 @@ # 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 diff --git a/README.md b/README.md index 5a745b5..e26b425 100644 --- a/README.md +++ b/README.md @@ -94,14 +94,16 @@ dependency are present: ```powershell git clone --recursive https://github.com/QTR-Modding/TrackQuestFromMap.git cd TrackQuestFromMap -xmake f -m release -a x64 -y -xmake -y TrackQuestSurfaceNativeOnly +xmake f -c -m release -a x64 -p windows -y +xmake -r -y TrackQuestSurfaceNativeOnly ``` -`COMMONLIBSF_PATH` may point at an equivalent local checkout. Building has no -install, deploy, mod-manager, or game-launch step. +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 are listed in [SOURCE.md](SOURCE.md). +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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 12dfdd5..dd2c40d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,14 +16,21 @@ sent to Flash. The plugin bridges those two views without modifying a SWF. ## Ownership capture -Version 0.2.2 transactionally hooks the reviewed Surface Map gather and -quest-composition calls. While the game's map-state lock is held, the inner -hook copies only bounded primitive FormID/instance pairs into thread-local -storage. It does not allocate, log, look up forms, touch UI, or retain engine -pointers under that lock. - -After gather returns, the plugin copies each native marker row into owned -storage: +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 @@ -32,6 +39,10 @@ storage: 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 @@ -80,14 +91,24 @@ not undo successful tracking. ## Reviewed 1.16.244 contracts -- Surface rebuild: `REL::ID(95000) + 0x77 -> REL::ID(95012)` -- Quest composition: `REL::ID(95012) + 0x237 -> REL::ID(95013)` -- Star Map input: `REL::ID(94684) + 0x10C -> REL::ID(130632)` -- Tracking helper: `REL::ID(91440)` -- Current Surface Map state accessor: `REL::ID(94755)` -- Surface Map refresh: `REL::ID(95003)` -- Star Map and Surface Map primary vtables: `REL::ID(446845)` and - `REL::ID(447074)` +- 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/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 60f963f..263a060 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -3,7 +3,9 @@ ## Style - C++23, Windows x64, all-extra warnings. -- Two-space indentation and attached opening braces. +- 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. @@ -11,11 +13,14 @@ 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 deliberately stays byte-identical to the gameplay- -tested prototype source. It predates the preferred uppercase `PCH.h` and -`logger::` style. Normalize those in a separate no-behavior-change pull request -and repeat binary plus gameplay verification. +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 @@ -29,9 +34,13 @@ and repeat binary plus gameplay verification. ## Locks, ownership, and threads -- Under an engine lock, perform bounded primitive/thread-local capture only. -- Do not allocate, log, resolve forms, inspect UI, or retain an engine pointer - under that lock. +- 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0ef2d74..58cbdf8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,13 +6,13 @@ - [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. -- [ ] Normalize the tested prototype to the preferred `PCH.h` and `logger::` - project style in a no-behavior-change PR. - [ ] Add galaxy/system-map support after independently tracing its marker ownership path. - [ ] Add orbital/planet-overview support after independently tracing its