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