diff --git a/.gitignore b/.gitignore
index 7cf5e46..f93fd7b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,4 +33,5 @@ tools/python-detect/models/
nul
# Extras
-lib/extras
\ No newline at end of file
+lib/extras
+lib/ExileCore-master
\ No newline at end of file
diff --git a/components.json b/components.json
index e62d2d5..845e076 100644
--- a/components.json
+++ b/components.json
@@ -9,6 +9,7 @@
"ControlZone",
"CritterAI",
"DiesAfterTime",
+ "F",
"Functions",
"GlobalAudioParamEvents",
"HideoutDoodad",
diff --git a/entities.json b/entities.json
index aa69ea4..9a01522 100644
--- a/entities.json
+++ b/entities.json
@@ -11,20 +11,35 @@
"Metadata/Chests/EzomyteChest_02",
"Metadata/Chests/EzomyteChest_05",
"Metadata/Chests/EzomyteChest_06",
+ "Metadata/Chests/EzomyteStatueGreen1",
+ "Metadata/Chests/EzomyteStatueGreen2",
+ "Metadata/Chests/EzomyteStatueGreen3",
+ "Metadata/Chests/EzomyteStatueGreen4",
+ "Metadata/Chests/EzomyteStatueGreen5",
+ "Metadata/Chests/EzomyteStatueGreen7",
+ "Metadata/Chests/GallowsTutorialChest2",
"Metadata/Chests/LeagueIncursion/EncounterChest",
+ "Metadata/Chests/MossyBoulder1",
+ "Metadata/Chests/MossyBoulder2",
"Metadata/Chests/MossyChest11",
+ "Metadata/Chests/MossyChest11MagicAndRare",
"Metadata/Chests/MossyChest13",
"Metadata/Chests/MossyChest14",
+ "Metadata/Chests/MossyChest14MagicAndRare",
+ "Metadata/Chests/MossyChest17",
"Metadata/Chests/MossyChest20",
"Metadata/Chests/MossyChest21",
"Metadata/Chests/MossyChest26",
"Metadata/Chests/MuddyChest1",
+ "Metadata/Chests/SirenEggs/SirenEgg_02",
"Metadata/Critters/BloodWorm/BloodWormBrown",
"Metadata/Critters/Chicken/Chicken_kingsmarch",
"Metadata/Critters/Crow/Crow",
"Metadata/Critters/Ferret/Ferret",
"Metadata/Critters/Hedgehog/HedgehogSlow",
+ "Metadata/Critters/Spider/NurseryWebSpider",
"Metadata/Critters/Weta/Basic",
+ "Metadata/Effects/BeamEffect",
"Metadata/Effects/Effect",
"Metadata/Effects/Microtransactions/Town_Portals/PersonSplitPortal/_PersonSplitPortalPrespawnDummy",
"Metadata/Effects/Microtransactions/Town_Portals/PersonSplitPortal/_PersonSplitPortalPrespawnDummyMarble",
@@ -32,19 +47,37 @@
"Metadata/Effects/Microtransactions/foot_prints/harvest02/footprints_harvest",
"Metadata/Effects/PermanentEffect",
"Metadata/Effects/ServerEffect",
+ "Metadata/Effects/SleepableBeamEffect",
+ "Metadata/Effects/Spells/ground_effects/VisibleServerGroundEffect",
"Metadata/Effects/Spells/monsters_effects/Act1_FOUR/CarrionCrone/IceSpike",
+ "Metadata/Effects/Spells/monsters_effects/Act1_FOUR/MudBurrower/mudburrower_chasm",
+ "Metadata/Effects/Spells/monsters_effects/Act1_FOUR/MudBurrower/mudburrower_chasm_body",
"Metadata/Effects/Spells/sandstorm_swipe/sandstorm_swipe_storm",
"Metadata/MiscellaneousObjects/AreaTransitionBlockage",
"Metadata/MiscellaneousObjects/AreaTransitionDoodad",
"Metadata/MiscellaneousObjects/AreaTransition_Animate",
+ "Metadata/MiscellaneousObjects/BossTargetMarkerSerialized",
+ "Metadata/MiscellaneousObjects/BossTargetMarkerSerialized2",
+ "Metadata/MiscellaneousObjects/CameraZoom/MinorZoomIn",
+ "Metadata/MiscellaneousObjects/CameraZoom/TreeOfSouls",
"Metadata/MiscellaneousObjects/Checkpoint",
+ "Metadata/MiscellaneousObjects/CheckpointTutorial",
"Metadata/MiscellaneousObjects/Doodad",
"Metadata/MiscellaneousObjects/DoodadInvisible",
"Metadata/MiscellaneousObjects/DoodadNoBlocking",
+ "Metadata/MiscellaneousObjects/Environment/EnvLineEnd",
+ "Metadata/MiscellaneousObjects/Environment/EnvLineStart",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_20_1",
+ "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_5_1",
+ "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_6_1",
+ "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_8_1",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_4.75_1",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_6_4",
+ "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_8_8",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_4.75_1",
+ "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_6_4",
+ "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_6_6",
+ "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_8_8",
"Metadata/MiscellaneousObjects/GuildStash",
"Metadata/MiscellaneousObjects/HealingWell",
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_1",
@@ -58,19 +91,46 @@
"Metadata/MiscellaneousObjects/ReviveIcon",
"Metadata/MiscellaneousObjects/ServerDoodadHidden",
"Metadata/MiscellaneousObjects/Stash",
+ "Metadata/MiscellaneousObjects/TargetMarker1",
+ "Metadata/MiscellaneousObjects/TutorialArrow",
"Metadata/MiscellaneousObjects/Waypoint",
"Metadata/MiscellaneousObjects/WorldItem",
"Metadata/Monsters/BansheeRemake/WitchHut/Objects/AmbushLocation",
"Metadata/Monsters/BansheeRemake/WitchHutBanshee",
+ "Metadata/Monsters/CarnivorousPlantEater/OldForest/BossRoomMinimapIcon",
+ "Metadata/Monsters/CarnivorousPlantEater/OldForest/CarnivorousPlantEaterOldForest_",
+ "Metadata/Monsters/Daemon/FungalBurstDaemon",
+ "Metadata/Monsters/FungusZombie/FungalBurstMushrooms/FungalBurstSpawner",
"Metadata/Monsters/FungusZombie/FungusZombieLarge",
"Metadata/Monsters/FungusZombie/FungusZombieMedium",
"Metadata/Monsters/Hags/Objects/BossRoomMinimapIcon",
"Metadata/Monsters/Hags/UrchinHag1",
"Metadata/Monsters/Hags/UrchinHagBoss",
+ "Metadata/Monsters/HuhuGrub/CinematicHuhuGrub",
"Metadata/Monsters/HuhuGrub/HuhuGrubLarvaeEmerge1",
+ "Metadata/Monsters/HuhuGrub/HuhuGrubLarvaeEmergeSummoned1_",
"Metadata/Monsters/HuhuGrub/HuhuGrubLarvaeRanged1",
"Metadata/Monsters/InvisibleFire/MDCarrionCroneWave",
+ "Metadata/Monsters/MonsterMods/GroundOnDeath/ShockedGroundDaemonParent",
"Metadata/Monsters/MonsterMods/OnDeathColdExplosionParent",
+ "Metadata/Monsters/MudBurrower/Arena_Blocker",
+ "Metadata/Monsters/MudBurrower/Arena_Blocker_Visual",
+ "Metadata/Monsters/MudBurrower/MudBurrowerBodyBoss",
+ "Metadata/Monsters/MudBurrower/MudBurrowerBodyDaemon",
+ "Metadata/Monsters/MudBurrower/MudBurrowerHeadBoss",
+ "Metadata/Monsters/MudBurrower/MudBurrowerTailBoss_",
+ "Metadata/Monsters/MudBurrower/Objects/BossRoomMinimapIcon",
+ "Metadata/Monsters/MudGolem/MudGolemWallAnimated",
+ "Metadata/Monsters/MudGolem/MudGolemWet1",
+ "Metadata/Monsters/MudGolem/MudGolemWetEncased1",
+ "Metadata/Monsters/NPC/DogTrader_",
+ "Metadata/Monsters/QuillCrab/QuillCrab",
+ "Metadata/Monsters/QuillCrab/QuillCrabBig",
+ "Metadata/Monsters/Skeletons/RetchSkeletonOneHandSword",
+ "Metadata/Monsters/Skeletons/RetchSkeletonOneHandSwordShield",
+ "Metadata/Monsters/SnakeFlowerMan/BloomSerpentEmerge1",
+ "Metadata/Monsters/SwollenMiller/Objects/BossRoomMinimapIcon",
+ "Metadata/Monsters/SwollenMiller/SwollenMillerBoss",
"Metadata/Monsters/Urchins/MeleeUrchin1",
"Metadata/Monsters/Urchins/SlingUrchin1",
"Metadata/Monsters/Werewolves/WerewolfPack1",
@@ -84,6 +144,14 @@
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxePhysics__",
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmed",
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmedPhysics",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedOneHandAxe",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedOneHandAxeHighAggroRangeMiller",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedOneHandAxePhysics",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedOneHandAxePhysicsHighAggroRangeMiller",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedUnarmed",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedUnarmedHighAggroRangeMiller",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedUnarmedPhysics",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedUnarmedPhysicsHighAggroRangeMiller",
"Metadata/NPC/Four_Act1/ClearfellPosting1",
"Metadata/NPC/Four_Act1/ClearfellPosting3",
"Metadata/NPC/Four_Act1/DogTrader_Entrance",
@@ -96,6 +164,9 @@
"Metadata/NPC/Four_Act1/HoodedMentor",
"Metadata/NPC/Four_Act1/HoodedMentorAfterIronCount",
"Metadata/NPC/Four_Act1/HoodedMentorInjured",
+ "Metadata/NPC/Four_Act1/HoodedMentorOldForest",
+ "Metadata/NPC/Four_Act1/HoodedMentorOldForestInTree",
+ "Metadata/NPC/Four_Act1/OldForestGlyph1",
"Metadata/NPC/Four_Act1/Renly",
"Metadata/NPC/Four_Act1/RenlyAfterIronCount",
"Metadata/NPC/Four_Act1/RenlyIntro",
@@ -103,6 +174,8 @@
"Metadata/NPC/Four_Act1/UnaAfterHealHoodedMentor",
"Metadata/NPC/Four_Act1/UnaAfterIronCount",
"Metadata/NPC/Four_Act1/UnaHoodedOneInjured",
+ "Metadata/NPC/Four_Act1/UnaTreeSummon",
+ "Metadata/NPC/Four_Act1/UnaTreeSummonKneeling",
"Metadata/NPC/League/Incursion/AlvaIncursionWild",
"Metadata/Pet/AzmeriStag/AzmeriStag",
"Metadata/Pet/BabyBossesHumans/BabyBrutus/BabyBrutus",
@@ -128,20 +201,48 @@
"Metadata/Pet/ScavengerBat/ScavengerBat",
"Metadata/Pet/WayfinderWolf/WayfinderWolf",
"Metadata/Projectiles/CarrionCroneIceSpear",
+ "Metadata/Projectiles/Fireball",
"Metadata/Projectiles/HagBossIceShard",
+ "Metadata/Projectiles/HuhuGrubLarvaeMortar",
"Metadata/Projectiles/IceSpear",
+ "Metadata/Projectiles/MudBurrowerAcidMortarSmall",
+ "Metadata/Projectiles/MudBurrowerBloodProj",
+ "Metadata/Projectiles/MudBurrowerGoopMortar",
+ "Metadata/Projectiles/MudBurrowerGoopProjectile",
+ "Metadata/Projectiles/QuillCrabShrapnel",
+ "Metadata/Projectiles/QuillCrabSpike",
"Metadata/Projectiles/SlingUrchinProjectile",
"Metadata/Projectiles/Spark",
"Metadata/Projectiles/Twister",
+ "Metadata/QuestObjects/Four_Act1/TreeOfSoulsRoots",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1_CountKilled",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull2",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull2_CountKilled",
+ "Metadata/Terrain/Gallows/Act1/1_1/Objects/EncampmentSpikeLadder",
+ "Metadata/Terrain/Gallows/Act1/1_1/Objects/TutorialBlocker1",
+ "Metadata/Terrain/Gallows/Act1/1_1/Objects/TutorialBlocker2",
+ "Metadata/Terrain/Gallows/Act1/1_1/Objects/TutorialBlocker3",
+ "Metadata/Terrain/Gallows/Act1/1_1/Objects/TutorialBlocker4",
+ "Metadata/Terrain/Gallows/Act1/1_1/Objects/TutorialNPCZombie",
"Metadata/Terrain/Gallows/Act1/1_2/Objects/CampsiteChest",
"Metadata/Terrain/Gallows/Act1/1_2/Objects/CampsiteController",
"Metadata/Terrain/Gallows/Act1/1_2/Objects/RuleSet",
+ "Metadata/Terrain/Gallows/Act1/1_3/Objects/MudGolemSpawners/MudGolemSpawnerBase01_02",
+ "Metadata/Terrain/Gallows/Act1/1_3/Objects/MudGolemSpawners/MudGolemSpawnerBase01_06",
+ "Metadata/Terrain/Gallows/Act1/1_3/Objects/MudGolemSpawners/MudGolemSpawnerBase02_011",
+ "Metadata/Terrain/Gallows/Act1/1_3/Objects/MudGolemSpawners/MudGolemSpawnerBase02_03",
+ "Metadata/Terrain/Gallows/Act1/1_3/Objects/SecretRoomMinimapIcon",
+ "Metadata/Terrain/Gallows/Act1/1_4/Objects/AreaTransition_4a",
+ "Metadata/Terrain/Gallows/Act1/1_4/Objects/BossRootDoor",
"Metadata/Terrain/Gallows/Act1/1_4/Objects/HagCauldron",
+ "Metadata/Terrain/Gallows/Act1/1_4/Objects/HoodedMentorController",
"Metadata/Terrain/Gallows/Act1/1_4/Objects/SecretRoomMinimapIcon",
+ "Metadata/Terrain/Gallows/Act1/1_4/Objects/SummonAlly",
+ "Metadata/Terrain/Gallows/Act1/1_4/Objects/TreeOfSoulsBodies",
+ "Metadata/Terrain/Gallows/Act1/1_4/Objects/TreeOfSoulsNailStake1",
+ "Metadata/Terrain/Gallows/Act1/1_4/Objects/TreeOfSoulsNailStake2",
+ "Metadata/Terrain/Gallows/Act1/1_4/Objects/TreeOfSoulsNailStake3",
"Metadata/Terrain/Gallows/Act1/1_4/Objects/WitchHutTitle",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/Act1_finished_LightController",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBenchEzomyte",
@@ -149,12 +250,14 @@
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBench_EnableRendering",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_DisableRendering",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_EnableRendering",
+ "Metadata/Terrain/Tools/AudioTools/G1_1/TownEntrance",
"Metadata/Terrain/Tools/AudioTools/G1_2/BurrowEntrance",
"Metadata/Terrain/Tools/AudioTools/G1_2/ForestEntrance",
"Metadata/Terrain/Tools/AudioTools/G1_2/HagArena",
"Metadata/Terrain/Tools/AudioTools/G1_2/RiverRapidsMedium",
"Metadata/Terrain/Tools/AudioTools/G1_3/TunnelA",
"Metadata/Terrain/Tools/AudioTools/G1_4/WitchHutIndoorAudio",
+ "Metadata/Terrain/Tools/AudioTools/G1_5/OldForestEntrance",
"Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio",
"Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio"
]
\ No newline at end of file
diff --git a/offsets.json b/offsets.json
index 2c1be35..97d1e2b 100644
--- a/offsets.json
+++ b/offsets.json
@@ -50,10 +50,17 @@
"QuestCompanionObjPtrOffset": "0x10",
"QuestTrackedMarker": "0x43020000",
"QuestObjEncounterStateOffset": 8,
- "QuestDatRowSize": "0x77",
+ "QuestDatRowSize": "0x68",
"QuestDatNameOffset": 0,
+ "QuestDatOrderOffset": "0x10",
+ "QuestDatTextOffset": "0x34",
+ "QuestDatMessageOffset": "0x3D",
"QuestDatInternalIdOffset": "0x6B",
"QuestDatActOffset": "0x73",
+ "QuestStateObjectOffset": "0x900",
+ "QuestStateVectorOffset": "0x240",
+ "QuestStateEntrySize": 12,
+ "QuestStateMaxEntries": "0x100",
"ComponentListOffset": "0x10",
"EntityHeaderOffset": 8,
"ComponentLookupOffset": "0x28",
@@ -94,5 +101,15 @@
"UiElementVisibleBit": 11,
"UiElementSizeOffset": "0x288",
"UiElementTextOffset": "0x448",
- "UiElementScanRange": "0x1000"
+ "UiElementScanRange": "0x1000",
+ "QuestLinkedListOffset": "0x358",
+ "QuestLinkedListNodeSize": "0x28",
+ "QuestNodeQuestPtrOffset": "0x10",
+ "QuestNodeStateIdOffset": "0x20",
+ "QuestObjNamePtrOffset": 0,
+ "QuestLinkedListMaxNodes": "0x100",
+ "TrackedQuestPanelChildIndex": 6,
+ "TrackedQuestPanelSubChildIndex": 1,
+ "TrackedQuestLinkedListOffset": "0x318",
+ "QuestStateObjTextOffset": "0x34"
}
diff --git a/profiles/_assignments.json b/profiles/_assignments.json
index 62b7268..c1f9fba 100644
--- a/profiles/_assignments.json
+++ b/profiles/_assignments.json
@@ -1,3 +1,5 @@
{
- "GooGoGaaGa": "GooGoGaaGa_Default_Copy"
+ "GooGoGaaGa": "GooGoGaaGa_Default_Copy",
+ "terdsare": "terdsare_Default",
+ "dudemoko": "dudemoko_Default"
}
\ No newline at end of file
diff --git a/profiles/dudemoko_Default.json b/profiles/dudemoko_Default.json
new file mode 100644
index 0000000..d338f17
--- /dev/null
+++ b/profiles/dudemoko_Default.json
@@ -0,0 +1,166 @@
+{
+ "Name": "dudemoko_Default",
+ "CreatedAt": "2026-03-05T05:44:54.8014147Z",
+ "LastModified": "2026-03-05T05:44:54.8014154Z",
+ "Flasks": {
+ "LifeFlaskThreshold": 50,
+ "ManaFlaskThreshold": 50,
+ "FlaskCooldownMs": 4000,
+ "LifeFlaskScanCode": 2,
+ "ManaFlaskScanCode": 3
+ },
+ "Combat": {
+ "GlobalCooldownMs": 500,
+ "AttackRange": 600,
+ "SafeRange": 400,
+ "KiteEnabled": false,
+ "KiteRange": 300,
+ "KiteDelayMs": 200
+ },
+ "Skills": [
+ {
+ "SlotIndex": 0,
+ "Label": "LMB",
+ "SkillName": null,
+ "InputType": "LeftClick",
+ "ScanCode": 0,
+ "Priority": 0,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 1,
+ "Label": "RMB",
+ "SkillName": null,
+ "InputType": "RightClick",
+ "ScanCode": 0,
+ "Priority": 1,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 2,
+ "Label": "MMB",
+ "SkillName": null,
+ "InputType": "MiddleClick",
+ "ScanCode": 0,
+ "Priority": 2,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 3,
+ "Label": "Q",
+ "SkillName": null,
+ "InputType": "KeyPress",
+ "ScanCode": 16,
+ "Priority": 3,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 4,
+ "Label": "E",
+ "SkillName": null,
+ "InputType": "KeyPress",
+ "ScanCode": 18,
+ "Priority": 4,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 5,
+ "Label": "R",
+ "SkillName": null,
+ "InputType": "KeyPress",
+ "ScanCode": 19,
+ "Priority": 5,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 6,
+ "Label": "T",
+ "SkillName": null,
+ "InputType": "KeyPress",
+ "ScanCode": 20,
+ "Priority": 6,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 7,
+ "Label": "F",
+ "SkillName": null,
+ "InputType": "KeyPress",
+ "ScanCode": 33,
+ "Priority": 7,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/profiles/terdsare_Default.json b/profiles/terdsare_Default.json
new file mode 100644
index 0000000..a759145
--- /dev/null
+++ b/profiles/terdsare_Default.json
@@ -0,0 +1,166 @@
+{
+ "Name": "terdsare_Default",
+ "CreatedAt": "2026-03-05T04:11:41.4102481Z",
+ "LastModified": "2026-03-05T04:11:41.4102483Z",
+ "Flasks": {
+ "LifeFlaskThreshold": 50,
+ "ManaFlaskThreshold": 50,
+ "FlaskCooldownMs": 4000,
+ "LifeFlaskScanCode": 2,
+ "ManaFlaskScanCode": 3
+ },
+ "Combat": {
+ "GlobalCooldownMs": 500,
+ "AttackRange": 600,
+ "SafeRange": 400,
+ "KiteEnabled": false,
+ "KiteRange": 300,
+ "KiteDelayMs": 200
+ },
+ "Skills": [
+ {
+ "SlotIndex": 0,
+ "Label": "LMB",
+ "SkillName": null,
+ "InputType": "LeftClick",
+ "ScanCode": 0,
+ "Priority": 0,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 1,
+ "Label": "RMB",
+ "SkillName": null,
+ "InputType": "RightClick",
+ "ScanCode": 0,
+ "Priority": 1,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 2,
+ "Label": "MMB",
+ "SkillName": null,
+ "InputType": "MiddleClick",
+ "ScanCode": 0,
+ "Priority": 2,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 3,
+ "Label": "Q",
+ "SkillName": null,
+ "InputType": "KeyPress",
+ "ScanCode": 16,
+ "Priority": 3,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 4,
+ "Label": "E",
+ "SkillName": null,
+ "InputType": "KeyPress",
+ "ScanCode": 18,
+ "Priority": 4,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 5,
+ "Label": "R",
+ "SkillName": null,
+ "InputType": "KeyPress",
+ "ScanCode": 19,
+ "Priority": 5,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 6,
+ "Label": "T",
+ "SkillName": null,
+ "InputType": "KeyPress",
+ "ScanCode": 20,
+ "Priority": 6,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ },
+ {
+ "SlotIndex": 7,
+ "Label": "F",
+ "SkillName": null,
+ "InputType": "KeyPress",
+ "ScanCode": 33,
+ "Priority": 7,
+ "IsEnabled": true,
+ "CooldownMs": 300,
+ "RangeMin": 0,
+ "RangeMax": 600,
+ "TargetSelection": "Nearest",
+ "RequiresTarget": true,
+ "IsAura": false,
+ "IsMovementSkill": false,
+ "MinMonstersInRange": 1,
+ "MaintainPressed": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/Automata.Ui/ViewModels/MemoryViewModel.cs b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
index 417ea0b..dbfba36 100644
--- a/src/Automata.Ui/ViewModels/MemoryViewModel.cs
+++ b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
@@ -16,7 +16,10 @@ public partial class MemoryNodeViewModel : ObservableObject
[ObservableProperty] private string _name;
[ObservableProperty] private string _value = "";
[ObservableProperty] private string _valueColor = "#484f58";
- [ObservableProperty] private bool _isExpanded = true;
+ [ObservableProperty] private bool _isExpanded;
+
+ /// Optional back-reference to a UIElementNode for lazy child population.
+ public UIElementNode? UiElement { get; set; }
public ObservableCollection Children { get; } = [];
@@ -93,8 +96,6 @@ public partial class MemoryViewModel : ObservableObject
private MemoryNodeViewModel? _currentStateNode;
private MemoryNodeViewModel? _isLoadingNode;
private MemoryNodeViewModel? _escapeStateNode;
- private MemoryNodeViewModel? _activeStatesNode;
- private MemoryNodeViewModel? _statesNode;
private MemoryNodeViewModel? _terrainCells;
private MemoryNodeViewModel? _terrainGrid;
private MemoryNodeViewModel? _terrainWalkable;
@@ -102,7 +103,6 @@ public partial class MemoryViewModel : ObservableObject
private MemoryNodeViewModel? _entityTypesNode;
private MemoryNodeViewModel? _entityListNode;
private MemoryNodeViewModel? _skillsNode;
- private MemoryNodeViewModel? _questsNode;
private MemoryNodeViewModel? _areaRawName;
private MemoryNodeViewModel? _areaDisplayName;
private MemoryNodeViewModel? _areaAct;
@@ -110,6 +110,8 @@ public partial class MemoryViewModel : ObservableObject
private MemoryNodeViewModel? _areaHasWaypoint;
private MemoryNodeViewModel? _areaMonsterLevel;
private MemoryNodeViewModel? _worldAreaId;
+ private MemoryNodeViewModel? _uiElementsNode;
+ private MemoryNodeViewModel? _questLinkedListNode;
partial void OnIsEnabledChanged(bool value)
{
@@ -178,8 +180,6 @@ public partial class MemoryViewModel : ObservableObject
_currentStateNode = new MemoryNodeViewModel("Current State:");
_isLoadingNode = new MemoryNodeViewModel("Loading:");
_escapeStateNode = new MemoryNodeViewModel("Escape:");
- _activeStatesNode = new MemoryNodeViewModel("Controller") { IsExpanded = true };
- _statesNode = new MemoryNodeViewModel("State Slots") { IsExpanded = true };
gameState.Children.Add(_gsPattern);
gameState.Children.Add(_gsBase);
gameState.Children.Add(_gsController);
@@ -188,8 +188,6 @@ public partial class MemoryViewModel : ObservableObject
gameState.Children.Add(_currentStateNode);
gameState.Children.Add(_isLoadingNode);
gameState.Children.Add(_escapeStateNode);
- gameState.Children.Add(_activeStatesNode);
- gameState.Children.Add(_statesNode);
// InGameState children
var inGameStateGroup = new MemoryNodeViewModel("InGameState");
@@ -215,14 +213,12 @@ public partial class MemoryViewModel : ObservableObject
_playerLife = new MemoryNodeViewModel("Life:") { Value = "?", ValueColor = "#484f58" };
_playerMana = new MemoryNodeViewModel("Mana:") { Value = "?", ValueColor = "#484f58" };
_playerEs = new MemoryNodeViewModel("ES:") { Value = "?", ValueColor = "#484f58" };
- _skillsNode = new MemoryNodeViewModel("Skills") { IsExpanded = false };
- _questsNode = new MemoryNodeViewModel("Quests") { IsExpanded = false };
+ _skillsNode = new MemoryNodeViewModel("Skills");
player.Children.Add(_playerPos);
player.Children.Add(_playerLife);
player.Children.Add(_playerMana);
player.Children.Add(_playerEs);
player.Children.Add(_skillsNode);
- player.Children.Add(_questsNode);
// Entities
var entitiesGroup = new MemoryNodeViewModel("Entities");
@@ -259,11 +255,19 @@ public partial class MemoryViewModel : ObservableObject
areaTemplateGroup.Children.Add(_areaMonsterLevel);
areaTemplateGroup.Children.Add(_worldAreaId);
+ // Quest Linked Lists (all quests + tracked merged from GameUi)
+ _questLinkedListNode = new MemoryNodeViewModel("Quests");
+
+ // UIElements tree
+ _uiElementsNode = new MemoryNodeViewModel("UIElements") { IsExpanded = false };
+
inGameStateGroup.Children.Add(areaInstanceGroup);
inGameStateGroup.Children.Add(areaTemplateGroup);
inGameStateGroup.Children.Add(player);
inGameStateGroup.Children.Add(entitiesGroup);
inGameStateGroup.Children.Add(terrain);
+ inGameStateGroup.Children.Add(_questLinkedListNode);
+ inGameStateGroup.Children.Add(_uiElementsNode);
RootNodes.Add(process);
RootNodes.Add(gameState);
@@ -334,103 +338,6 @@ public partial class MemoryViewModel : ObservableObject
_isLoadingNode!.Set(snap.IsLoading ? "Loading..." : "Ready", !snap.IsLoading);
_escapeStateNode!.Set(snap.IsEscapeOpen ? "Open" : "Closed", !snap.IsEscapeOpen);
- // Controller dump — show qwords before state slots to find active state offset
- if (_activeStatesNode is not null)
- {
- var pre = snap.ControllerPreSlots;
- _activeStatesNode.Value = pre.Length > 0
- ? $"controller+0x00..0x{pre.Length * 8:X} ({pre.Length} qwords)"
- : "no data";
- _activeStatesNode.ValueColor = "#8b949e";
-
- while (_activeStatesNode.Children.Count > pre.Length)
- _activeStatesNode.Children.RemoveAt(_activeStatesNode.Children.Count - 1);
-
- for (var i = 0; i < pre.Length; i++)
- {
- var (off, val, match, changed, derefInfo) = pre[i];
- var label = $"+0x{off:X2}:";
- var changeTag = changed ? " [CHANGED]" : "";
- var derefTag = derefInfo != null ? $" ({derefInfo})" : "";
- var display = val == 0
- ? "0"
- : match != null
- ? $"0x{val:X} ← {match}{changeTag}"
- : $"0x{val:X}{derefTag}{changeTag}";
- var color = changed ? "#f85149"
- : match != null ? "#3fb950"
- : derefInfo != null && derefInfo.Contains('→') && derefInfo.Contains("State") ? "#d29922" // yellow for indirect state match
- : val == 0 ? "#484f58"
- : "#8b949e";
-
- if (i < _activeStatesNode.Children.Count)
- {
- _activeStatesNode.Children[i].Name = label;
- _activeStatesNode.Children[i].Value = display;
- _activeStatesNode.Children[i].ValueColor = color;
- }
- else
- {
- var node = new MemoryNodeViewModel(label) { Value = display, ValueColor = color };
- _activeStatesNode.Children.Add(node);
- }
- }
- }
-
- // State Slots — show pointer + int32 at +0x08 for each state slot
- if (_statesNode is not null && snap.StateSlots.Length > 0)
- {
- var slots = snap.StateSlots;
- var needed = slots.Length;
-
- while (_statesNode.Children.Count > needed)
- _statesNode.Children.RemoveAt(_statesNode.Children.Count - 1);
-
- for (var i = 0; i < needed; i++)
- {
- var ptr = slots[i];
- var stateName = i < GameMemoryReader.StateNames.Length ? GameMemoryReader.StateNames[i] : $"State{i}";
- var label = $"[{i}] {stateName}:";
- string val;
- string color;
-
- if (ptr == 0)
- {
- val = "null";
- color = "#484f58";
- }
- else
- {
- var int32Val = snap.StateSlotValues?.Length > i ? snap.StateSlotValues[i] : 0;
- var isActive = snap.ActiveStates.Contains(ptr);
- var activeTag = isActive ? " [ACTIVE]" : "";
- val = $"0x{ptr:X} [+0x08]={int32Val}{activeTag}";
-
- // Green if current state, cyan if active, default gray
- if (i < (int)GameStateType.GameNotLoaded && (GameStateType)i == snap.CurrentGameState)
- color = "#3fb950"; // green — current state
- else if (isActive)
- color = "#58a6ff"; // blue — active but not current
- else
- color = "#8b949e"; // gray — inactive
- }
-
- if (i < _statesNode.Children.Count)
- {
- _statesNode.Children[i].Name = label;
- _statesNode.Children[i].Set(val, true);
- _statesNode.Children[i].ValueColor = color;
- }
- else
- {
- var node = new MemoryNodeViewModel(label);
- node.Set(val, true);
- node.ValueColor = color;
- _statesNode.Children.Add(node);
- }
- }
- }
-
// Status text — show resolved current state
if (snap.Attached)
{
@@ -593,48 +500,106 @@ public partial class MemoryViewModel : ObservableObject
}
}
- // Quest states with rich info from companion vector
- if (_questsNode is not null)
+ // Quest Linked Lists (all quests + tracked merged from GameUi)
+ if (_questLinkedListNode is not null)
{
- if (snap.QuestFlags is { Count: > 0 })
+ if (snap.QuestLinkedList is { Count: > 0 })
{
- var named = snap.QuestFlags.Count(q => q.QuestName is not null);
- _questsNode.Value = $"{snap.QuestFlags.Count} quest states ({named} named)";
- _questsNode.ValueColor = "#3fb950";
+ var active = snap.QuestLinkedList.Count(q => q.StateId > 0);
+ var tracked = snap.QuestLinkedList.Count(q => q.IsTracked);
+ _questLinkedListNode.Value = $"{snap.QuestLinkedList.Count} total, {active} active, {tracked} tracked";
+ _questLinkedListNode.ValueColor = "#3fb950";
- while (_questsNode.Children.Count > snap.QuestFlags.Count)
- _questsNode.Children.RemoveAt(_questsNode.Children.Count - 1);
-
- for (var i = 0; i < snap.QuestFlags.Count; i++)
+ // Only update children if expanded
+ if (_questLinkedListNode.IsExpanded)
{
- var q = snap.QuestFlags[i];
- var trackedPrefix = q.IsTracked ? "[T] " : "";
- var stateLabel = q.StateId switch { 1 => "locked", 2 => "started", _ => $"s{q.StateId}" };
- var label = $"{trackedPrefix}{q.QuestName ?? (q.QuestStateIndex > 0 ? $"#{q.QuestStateIndex}" : $"[{i}]")}";
- var value = q.InternalId is not null
- ? $"idx={q.QuestStateIndex} {stateLabel} id={q.InternalId}"
- : $"idx={q.QuestStateIndex} {stateLabel}";
+ var sorted = snap.QuestLinkedList
+ .Where(q => q.StateId > 0)
+ .OrderByDescending(q => q.IsTracked)
+ .ThenByDescending(q => q.StateId > 0)
+ .ThenBy(q => q.Act)
+ .ThenBy(q => q.DisplayName)
+ .ToList();
- var color = q.IsTracked ? "#58a6ff" : q.StateId == 2 ? "#8b949e" : "#484f58";
+ while (_questLinkedListNode.Children.Count > sorted.Count)
+ _questLinkedListNode.Children.RemoveAt(_questLinkedListNode.Children.Count - 1);
- if (i < _questsNode.Children.Count)
+ for (var i = 0; i < sorted.Count; i++)
{
- _questsNode.Children[i].Name = label;
- _questsNode.Children[i].Value = value;
- _questsNode.Children[i].ValueColor = color;
- }
- else
- {
- var node = new MemoryNodeViewModel(label) { Value = value, ValueColor = color };
- _questsNode.Children.Add(node);
+ var q = sorted[i];
+ var prefix = q.IsTracked ? "[T] " : "";
+ var stateLabel = $"step {q.StateId}";
+ var label = $"{prefix}{q.DisplayName ?? q.InternalId ?? $"[{i}]"}";
+ var value = $"Act{q.Act} {stateLabel}";
+
+ var color = q.IsTracked ? "#58a6ff" : "#d29922";
+
+ MemoryNodeViewModel node;
+ if (i < _questLinkedListNode.Children.Count)
+ {
+ node = _questLinkedListNode.Children[i];
+ node.Name = label;
+ node.Value = value;
+ node.ValueColor = color;
+ }
+ else
+ {
+ node = new MemoryNodeViewModel(label) { Value = value, ValueColor = color };
+ _questLinkedListNode.Children.Add(node);
+ }
+
+ // Populate detail children when quest node is expanded
+ if (node.IsExpanded)
+ {
+ var details = new List<(string key, string val)>
+ {
+ ("InternalId:", q.InternalId ?? "—"),
+ ("DisplayName:", q.DisplayName ?? "—"),
+ ("Act:", q.Act.ToString()),
+ ("StateId:", q.StateId.ToString()),
+ ("IsTracked:", q.IsTracked.ToString()),
+ };
+ if (q.ObjectiveText is not null)
+ details.Add(("Objective:", q.ObjectiveText));
+ if (q.QuestDatPtr != 0)
+ details.Add(("QuestDatPtr:", $"0x{q.QuestDatPtr:X}"));
+
+ while (node.Children.Count > details.Count)
+ node.Children.RemoveAt(node.Children.Count - 1);
+
+ for (var j = 0; j < details.Count; j++)
+ {
+ var (key, val) = details[j];
+ if (j < node.Children.Count)
+ {
+ node.Children[j].Name = key;
+ node.Children[j].Value = val;
+ node.Children[j].ValueColor = "#8b949e";
+ }
+ else
+ node.Children.Add(new MemoryNodeViewModel(key) { Value = val, ValueColor = "#8b949e" });
+ }
+ }
+ else
+ {
+ // Seed placeholder so expand arrow shows
+ if (node.Children.Count == 0)
+ node.Children.Add(new MemoryNodeViewModel("...") { ValueColor = "#484f58" });
+ }
}
}
+ else
+ {
+ // Seed placeholder so expand arrow shows
+ if (_questLinkedListNode.Children.Count == 0)
+ _questLinkedListNode.Children.Add(new MemoryNodeViewModel("...") { ValueColor = "#484f58" });
+ }
}
else
{
- _questsNode.Value = "—";
- _questsNode.ValueColor = "#484f58";
- _questsNode.Children.Clear();
+ _questLinkedListNode.Value = "—";
+ _questLinkedListNode.ValueColor = "#484f58";
+ _questLinkedListNode.Children.Clear();
}
}
@@ -688,9 +653,101 @@ public partial class MemoryViewModel : ObservableObject
_terrainWalkable!.Set("?", false);
}
+ // UIElements tree
+ UpdateUiElementsTree(snap.GameUiPtr);
+
UpdateMinimap(snap);
}
+ private void UpdateUiElementsTree(nint gameUiPtr)
+ {
+ if (_uiElementsNode is null) return;
+
+ var uiElements = _reader?.UIElements;
+ if (gameUiPtr == 0 || uiElements is null)
+ {
+ _uiElementsNode.Value = "—";
+ _uiElementsNode.ValueColor = "#484f58";
+ _uiElementsNode.Children.Clear();
+ return;
+ }
+
+ _uiElementsNode.Value = $"GameUi 0x{gameUiPtr:X}";
+ _uiElementsNode.ValueColor = "#3fb950";
+ _uiElementsNode.UiElement = new UIElementNode { Address = gameUiPtr, ChildCount = 1 };
+
+ // Read and sync children for all expanded nodes
+ SyncUiNodeLazy(_uiElementsNode, uiElements);
+ }
+
+ ///
+ /// Recursively syncs UI element tree nodes. For expanded nodes, reads children
+ /// from live memory and recurses. For collapsed nodes with children, ensures
+ /// a placeholder exists so the expand arrow is visible.
+ ///
+ private static void SyncUiNodeLazy(MemoryNodeViewModel vm, Roboto.Memory.Objects.UIElements uiElements)
+ {
+ var uiEl = vm.UiElement;
+ if (uiEl is null || uiEl.Address == 0) return;
+
+ if (!vm.IsExpanded)
+ {
+ // Collapsed — just ensure placeholder for expand arrow
+ if (uiEl.ChildCount > 0 && vm.Children.Count == 0)
+ vm.Children.Add(new MemoryNodeViewModel("...") { Value = "", ValueColor = "#484f58", IsExpanded = false });
+ return;
+ }
+
+ // Expanded — read children from live memory
+ var children = uiElements.ReadChildren(uiEl.Address);
+ if (children is null || children.Count == 0)
+ {
+ // Read failed or no children — show error hint
+ vm.Children.Clear();
+ if (uiEl.ChildCount > 0)
+ vm.Children.Add(new MemoryNodeViewModel("(read failed)") { Value = $"ChildCount={uiEl.ChildCount}", ValueColor = "#f85149", IsExpanded = false });
+ return;
+ }
+
+ // Remove placeholder
+ if (vm.Children.Count == 1 && vm.Children[0].UiElement is null)
+ vm.Children.Clear();
+
+ // Trim excess
+ while (vm.Children.Count > children.Count)
+ vm.Children.RemoveAt(vm.Children.Count - 1);
+
+ for (var i = 0; i < children.Count; i++)
+ {
+ var child = children[i];
+ var label = child.StringId ?? $"[{i}]";
+ var visTag = child.IsVisible ? "" : " [hidden]";
+ var childTag = child.ChildCount > 0 ? $" ({child.ChildCount} ch)" : "";
+ var textTag = child.Text is not null ? $" \"{child.Text}\"" : "";
+ var value = $"0x{child.Address:X}{visTag}{childTag}{textTag}";
+ var color = child.IsVisible ? "#3fb950" : "#484f58";
+
+ MemoryNodeViewModel childVm;
+ if (i < vm.Children.Count)
+ {
+ childVm = vm.Children[i];
+ childVm.Name = label;
+ childVm.Value = value;
+ childVm.ValueColor = color;
+ childVm.UiElement = child;
+ }
+ else
+ {
+ childVm = new MemoryNodeViewModel(label)
+ { Value = value, ValueColor = color, IsExpanded = false, UiElement = child };
+ vm.Children.Add(childVm);
+ }
+
+ // Recurse — will read grandchildren if this child is expanded, or add placeholder if collapsed
+ SyncUiNodeLazy(childVm, uiElements);
+ }
+ }
+
private void UpdateMinimap(GameStateSnapshot snap)
{
// Skip rendering entirely during loading — terrain data is stale/invalid
@@ -1390,6 +1447,90 @@ public partial class MemoryViewModel : ObservableObject
ScanResult = _reader.Diagnostics!.ScanQuestStateObjects();
}
+ [RelayCommand]
+ private void ScanQuestOffsetsExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.ScanQuestStateOffsets();
+ }
+
+ [RelayCommand]
+ private void ScanActiveQuestsExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.ScanActiveQuests();
+ }
+
+ [RelayCommand]
+ private void ScanQuestContainersExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.ScanQuestStateContainers();
+ }
+
+ [RelayCommand]
+ private void ScanWorldDataVectorsExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.ScanWorldDataVectors();
+ }
+
+ [RelayCommand]
+ private void ProbeCompanionQuestObjectsExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.ProbeCompanionQuestObjects();
+ }
+
+ [RelayCommand]
+ private void ScanQuestLinkedListExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.ScanQuestLinkedList();
+ }
+
+ [RelayCommand]
+ private void ReadQuestLinkedListsExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.ReadQuestLinkedLists();
+ }
+
[RelayCommand]
private void ScanAreaTemplateExecute()
{
@@ -1401,4 +1542,18 @@ public partial class MemoryViewModel : ObservableObject
ScanResult = _reader.Diagnostics!.ScanAreaTemplate();
}
+
+ [RelayCommand]
+ private void ProbeQuestAddressesExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ var addresses = "3E129519BB0";
+
+ ScanResult = _reader.Diagnostics!.ProbeQuestAddresses(addresses, 4);
+ }
}
diff --git a/src/Automata.Ui/ViewModels/RobotoViewModel.cs b/src/Automata.Ui/ViewModels/RobotoViewModel.cs
index 20e083d..61e7215 100644
--- a/src/Automata.Ui/ViewModels/RobotoViewModel.cs
+++ b/src/Automata.Ui/ViewModels/RobotoViewModel.cs
@@ -150,7 +150,19 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
var config = new BotConfig();
var reader = new GameMemoryReader();
var humanizer = new Humanizer(config);
- var input = new InterceptionInputController(humanizer);
+ // Try Interception driver first, fall back to SendInput
+ var interception = new InterceptionInputController(humanizer);
+ IInputController input;
+ if (interception.Initialize())
+ {
+ input = interception;
+ }
+ else
+ {
+ var sendInput = new SendInputController(humanizer);
+ sendInput.Initialize();
+ input = sendInput;
+ }
_engine = new BotEngine(config, reader, input, humanizer);
diff --git a/src/Automata.Ui/Views/MainWindow.axaml b/src/Automata.Ui/Views/MainWindow.axaml
index 8239e31..382e11b 100644
--- a/src/Automata.Ui/Views/MainWindow.axaml
+++ b/src/Automata.Ui/Views/MainWindow.axaml
@@ -784,8 +784,24 @@
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Roboto.Core/GameState.cs b/src/Roboto.Core/GameState.cs
index 9ffdbee..b000ffb 100644
--- a/src/Roboto.Core/GameState.cs
+++ b/src/Roboto.Core/GameState.cs
@@ -22,6 +22,8 @@ public class GameState
public DangerLevel Danger { get; set; }
public Matrix4x4? CameraMatrix { get; set; }
public IReadOnlyList ActiveQuests { get; set; } = [];
+ /// Active quests as shown in the game UI (title + objectives).
+ public IReadOnlyList UiQuests { get; set; } = [];
// Derived (computed once per tick by GameStateEnricher)
public ThreatMap Threats { get; set; } = new();
diff --git a/src/Roboto.Core/UiQuestInfo.cs b/src/Roboto.Core/UiQuestInfo.cs
new file mode 100644
index 0000000..e120874
--- /dev/null
+++ b/src/Roboto.Core/UiQuestInfo.cs
@@ -0,0 +1,12 @@
+namespace Roboto.Core;
+
+///
+/// Active quest info as displayed in the game UI.
+///
+public sealed class UiQuestInfo
+{
+ /// Quest title (e.g. "Treacherous Ground").
+ public string? Title { get; init; }
+ /// Current quest objective texts.
+ public IReadOnlyList Objectives { get; init; } = [];
+}
diff --git a/src/Roboto.Data/GameDataCache.cs b/src/Roboto.Data/GameDataCache.cs
index 05116cb..0ca85c0 100644
--- a/src/Roboto.Data/GameDataCache.cs
+++ b/src/Roboto.Data/GameDataCache.cs
@@ -1,5 +1,6 @@
using System.Numerics;
using Roboto.Core;
+using Roboto.Memory;
namespace Roboto.Data;
@@ -89,6 +90,18 @@ public sealed class GameDataCache
public volatile string? CurrentAreaName;
public volatile string? CharacterName;
+ // ── UI tree root pointer (updated at 10Hz) — tree is read on-demand ──
+ public volatile nint GameUiPtr;
+
+ // ── Quest groups from UI element tree (updated at 10Hz) ──
+ public volatile IReadOnlyList? UiQuestGroups;
+
+ // ── Quest linked lists from GameUi (updated at 10Hz) ──
+ public volatile IReadOnlyList? QuestLinkedList;
+
+ // ── Quest states from AreaInstance sub-object (updated at 10Hz) ──
+ public volatile IReadOnlyList? QuestStates;
+
// ── Full GameState (updated at 10Hz) — for systems that need the complete object ──
public volatile GameState? LatestState;
diff --git a/src/Roboto.Data/MemoryPoller.cs b/src/Roboto.Data/MemoryPoller.cs
index 1c87b7b..a32110b 100644
--- a/src/Roboto.Data/MemoryPoller.cs
+++ b/src/Roboto.Data/MemoryPoller.cs
@@ -144,6 +144,10 @@ public sealed class MemoryPoller : IDisposable
_cache.AreaHash = state.AreaHash;
_cache.AreaLevel = state.AreaLevel;
_cache.CharacterName = state.Player.CharacterName;
+ _cache.GameUiPtr = snap.GameUiPtr;
+ _cache.UiQuestGroups = snap.UiQuestGroups;
+ _cache.QuestLinkedList = snap.QuestLinkedList;
+ _cache.QuestStates = snap.QuestStates;
_cache.LatestState = state;
_cache.ColdTickTimestamp = Environment.TickCount64;
@@ -331,25 +335,42 @@ public sealed class MemoryPoller : IDisposable
if (snap.QuestFlags is { Count: > 0 })
{
- state.ActiveQuests = snap.QuestFlags.Select(q => new QuestProgress
- {
- QuestStateIndex = q.QuestStateIndex,
- QuestName = q.QuestName,
- InternalId = q.InternalId,
- StateId = q.StateId,
- IsTracked = q.IsTracked,
- StateText = q.StateText,
- ProgressText = q.ProgressText,
- }).ToList();
+ // StateId: 1=available/in-progress, 2=completed, 3+=special
+ // Filter to non-completed quests for ActiveQuests
+ state.ActiveQuests = snap.QuestFlags
+ .Where(q => q.StateId != 2) // exclude completed
+ .Select(q => new QuestProgress
+ {
+ QuestStateIndex = q.QuestStateIndex,
+ QuestName = q.QuestName,
+ InternalId = q.InternalId,
+ StateId = q.StateId,
+ IsTracked = q.IsTracked,
+ StateText = q.StateText,
+ ProgressText = q.ProgressText,
+ }).ToList();
- if (_lastQuestCount != snap.QuestFlags.Count)
+ var activeCount = state.ActiveQuests.Count;
+ if (_lastQuestCount != activeCount)
{
- var indices = string.Join(", ", snap.QuestFlags.Select(q => q.QuestStateIndex));
- Log.Debug("Quest state indices ({Count}): [{Indices}]", snap.QuestFlags.Count, indices);
- _lastQuestCount = snap.QuestFlags.Count;
+ Log.Debug("Active quests: {Active}/{Total} (filtered ES!=2)",
+ activeCount, snap.QuestFlags.Count);
+ _lastQuestCount = activeCount;
}
}
+ if (snap.UiQuestGroups is { Count: > 0 })
+ {
+ state.UiQuests = snap.UiQuestGroups.Select(g => new UiQuestInfo
+ {
+ Title = g.Title,
+ Objectives = g.Steps
+ .Where(s => s.Text is not null)
+ .Select(s => s.Text!)
+ .ToList(),
+ }).ToList();
+ }
+
if (snap.Terrain is not null)
{
state.Terrain = new WalkabilitySnapshot
diff --git a/src/Roboto.Input/SendInputController.cs b/src/Roboto.Input/SendInputController.cs
new file mode 100644
index 0000000..242cb31
--- /dev/null
+++ b/src/Roboto.Input/SendInputController.cs
@@ -0,0 +1,300 @@
+using System.Runtime.InteropServices;
+using Roboto.Core;
+using Serilog;
+
+namespace Roboto.Input;
+
+///
+/// Fallback input controller using Win32 SendInput with KEYEVENTF_SCANCODE.
+/// Games read scan codes, so this works for POE2 without the Interception driver.
+///
+public sealed partial class SendInputController : IInputController
+{
+ private static readonly Random Rng = new();
+ private readonly Humanizer? _humanizer;
+
+ public bool IsInitialized { get; private set; }
+
+ public SendInputController(Humanizer? humanizer = null)
+ {
+ _humanizer = humanizer;
+ }
+
+ public bool Initialize()
+ {
+ IsInitialized = true;
+ Log.Information("SendInput controller initialized (fallback)");
+ return true;
+ }
+
+ // ── Keyboard ──
+
+ public void KeyDown(ushort scanCode)
+ {
+ var input = MakeKeyScanInput(scanCode, keyUp: false);
+ SendInput(1, [input], INPUT_SIZE);
+ }
+
+ public void KeyUp(ushort scanCode)
+ {
+ var input = MakeKeyScanInput(scanCode, keyUp: true);
+ SendInput(1, [input], INPUT_SIZE);
+ }
+
+ public void KeyPress(ushort scanCode, int holdMs = 50)
+ {
+ if (_humanizer is not null)
+ {
+ if (_humanizer.ShouldThrottle()) return;
+ holdMs = _humanizer.GaussianDelay(holdMs);
+ _humanizer.RecordAction();
+ }
+
+ KeyDown(scanCode);
+ Thread.Sleep(holdMs);
+ KeyUp(scanCode);
+ }
+
+ // ── Mouse movement ──
+
+ public void MouseMoveTo(int x, int y)
+ {
+ SetCursorPos(x, y);
+ }
+
+ public void MouseMoveBy(int dx, int dy)
+ {
+ if (!GetCursorPos(out var pt)) return;
+ SetCursorPos(pt.X + dx, pt.Y + dy);
+ }
+
+ public void SmoothMoveTo(int x, int y)
+ {
+ if (!GetCursorPos(out var pt)) { MouseMoveTo(x, y); return; }
+
+ var dx = (double)(x - pt.X);
+ var dy = (double)(y - pt.Y);
+ var distance = Math.Sqrt(dx * dx + dy * dy);
+
+ if (distance < 15) { MouseMoveTo(x, y); return; }
+
+ var perpX = -dy / distance;
+ var perpY = dx / distance;
+ var spread = distance * 0.15;
+
+ var cp1X = pt.X + dx * 0.3 + perpX * (Rng.NextDouble() - 0.5) * spread;
+ var cp1Y = pt.Y + dy * 0.3 + perpY * (Rng.NextDouble() - 0.5) * spread;
+ var cp2X = pt.X + dx * 0.7 + perpX * (Rng.NextDouble() - 0.5) * spread;
+ var cp2Y = pt.Y + dy * 0.7 + perpY * (Rng.NextDouble() - 0.5) * spread;
+
+ var steps = Math.Clamp((int)Math.Round(distance / 15), 10, 40);
+
+ for (var i = 1; i <= steps; i++)
+ {
+ var t = EaseInOutQuad((double)i / steps);
+ var (bx, by) = CubicBezier(t, pt.X, pt.Y, cp1X, cp1Y, cp2X, cp2Y, x, y);
+ MouseMoveTo((int)Math.Round(bx), (int)Math.Round(by));
+ Thread.Sleep(2 + Rng.Next(3));
+ }
+
+ MouseMoveTo(x, y);
+ }
+
+ // ── Mouse clicks ──
+
+ public void LeftClick(int x, int y)
+ {
+ if (_humanizer is not null)
+ {
+ if (_humanizer.ShouldThrottle()) return;
+ (x, y) = _humanizer.JitterPosition(x, y);
+ Thread.Sleep(_humanizer.GaussianDelay(10));
+ _humanizer.RecordAction();
+ }
+ SmoothMoveTo(x, y);
+ Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
+ MouseClick(MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, _humanizer?.GaussianDelay(50) ?? 50);
+ }
+
+ public void RightClick(int x, int y)
+ {
+ if (_humanizer is not null)
+ {
+ if (_humanizer.ShouldThrottle()) return;
+ (x, y) = _humanizer.JitterPosition(x, y);
+ Thread.Sleep(_humanizer.GaussianDelay(10));
+ _humanizer.RecordAction();
+ }
+ SmoothMoveTo(x, y);
+ Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
+ MouseClick(MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, _humanizer?.GaussianDelay(50) ?? 50);
+ }
+
+ public void MiddleClick(int x, int y)
+ {
+ if (_humanizer is not null)
+ {
+ if (_humanizer.ShouldThrottle()) return;
+ (x, y) = _humanizer.JitterPosition(x, y);
+ Thread.Sleep(_humanizer.GaussianDelay(10));
+ _humanizer.RecordAction();
+ }
+ SmoothMoveTo(x, y);
+ Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
+ MouseClick(MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, _humanizer?.GaussianDelay(50) ?? 50);
+ }
+
+ public void LeftDown()
+ {
+ var input = MakeMouseInput(MOUSEEVENTF_LEFTDOWN);
+ SendInput(1, [input], INPUT_SIZE);
+ }
+
+ public void LeftUp()
+ {
+ var input = MakeMouseInput(MOUSEEVENTF_LEFTUP);
+ SendInput(1, [input], INPUT_SIZE);
+ }
+
+ public void RightDown()
+ {
+ var input = MakeMouseInput(MOUSEEVENTF_RIGHTDOWN);
+ SendInput(1, [input], INPUT_SIZE);
+ }
+
+ public void RightUp()
+ {
+ var input = MakeMouseInput(MOUSEEVENTF_RIGHTUP);
+ SendInput(1, [input], INPUT_SIZE);
+ }
+
+ // ── Private helpers ──
+
+ private void MouseClick(uint downFlag, uint upFlag, int holdMs)
+ {
+ var down = MakeMouseInput(downFlag);
+ var up = MakeMouseInput(upFlag);
+ SendInput(1, [down], INPUT_SIZE);
+ Thread.Sleep(holdMs);
+ SendInput(1, [up], INPUT_SIZE);
+ }
+
+ private static double EaseInOutQuad(double t) =>
+ t < 0.5 ? 2 * t * t : 1 - Math.Pow(-2 * t + 2, 2) / 2;
+
+ private static (double X, double Y) CubicBezier(double t,
+ double p0x, double p0y, double p1x, double p1y,
+ double p2x, double p2y, double p3x, double p3y)
+ {
+ var u = 1 - t;
+ var u2 = u * u;
+ var u3 = u2 * u;
+ var t2 = t * t;
+ var t3 = t2 * t;
+ return (
+ u3 * p0x + 3 * u2 * t * p1x + 3 * u * t2 * p2x + t3 * p3x,
+ u3 * p0y + 3 * u2 * t * p1y + 3 * u * t2 * p2y + t3 * p3y
+ );
+ }
+
+ // ── Win32 SendInput P/Invoke ──
+
+ private const int INPUT_KEYBOARD = 1;
+ private const int INPUT_MOUSE = 0;
+ private const uint KEYEVENTF_SCANCODE = 0x0008;
+ private const uint KEYEVENTF_KEYUP = 0x0002;
+ private const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
+ private const uint MOUSEEVENTF_LEFTUP = 0x0004;
+ private const uint MOUSEEVENTF_RIGHTDOWN = 0x0008;
+ private const uint MOUSEEVENTF_RIGHTUP = 0x0010;
+ private const uint MOUSEEVENTF_MIDDLEDOWN = 0x0020;
+ private const uint MOUSEEVENTF_MIDDLEUP = 0x0040;
+
+ private static readonly int INPUT_SIZE = Marshal.SizeOf();
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct POINT { public int X; public int Y; }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct INPUT
+ {
+ public int type;
+ public INPUT_UNION union;
+ }
+
+ [StructLayout(LayoutKind.Explicit)]
+ private struct INPUT_UNION
+ {
+ [FieldOffset(0)] public KEYBDINPUT ki;
+ [FieldOffset(0)] public MOUSEINPUT mi;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct KEYBDINPUT
+ {
+ public ushort wVk;
+ public ushort wScan;
+ public uint dwFlags;
+ public uint time;
+ public nint dwExtraInfo;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MOUSEINPUT
+ {
+ public int dx;
+ public int dy;
+ public uint mouseData;
+ public uint dwFlags;
+ public uint time;
+ public nint dwExtraInfo;
+ }
+
+ private static INPUT MakeKeyScanInput(ushort scanCode, bool keyUp)
+ {
+ var flags = KEYEVENTF_SCANCODE;
+ if (keyUp) flags |= KEYEVENTF_KEYUP;
+
+ return new INPUT
+ {
+ type = INPUT_KEYBOARD,
+ union = new INPUT_UNION
+ {
+ ki = new KEYBDINPUT
+ {
+ wVk = 0,
+ wScan = scanCode,
+ dwFlags = flags,
+ time = 0,
+ dwExtraInfo = 0,
+ }
+ }
+ };
+ }
+
+ private static INPUT MakeMouseInput(uint flags)
+ {
+ return new INPUT
+ {
+ type = INPUT_MOUSE,
+ union = new INPUT_UNION
+ {
+ mi = new MOUSEINPUT
+ {
+ dwFlags = flags,
+ }
+ }
+ };
+ }
+
+ [LibraryImport("user32.dll", SetLastError = true)]
+ private static partial uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
+
+ [LibraryImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static partial bool SetCursorPos(int x, int y);
+
+ [LibraryImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static partial bool GetCursorPos(out POINT lpPoint);
+}
diff --git a/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs b/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs
index 7940a6c..7ff377d 100644
--- a/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs
+++ b/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs
@@ -4836,6 +4836,167 @@ public sealed class MemoryDiagnostics
}
}
+ ///
+ /// Practical diagnostic: reads ALL companion entries and shows encounter states, tracked flags,
+ /// quest names, and raw companion bytes. Aims to identify which quests are active vs completed.
+ ///
+ public string ScanActiveQuests()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (ingameData == 0) return "Error: AreaInstance not resolved";
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+ var sb = new StringBuilder();
+
+ var serverData = mem.ReadPointer(ingameData + offsets.ServerDataOffset);
+ if (serverData == 0) return "Error: ServerData is null";
+
+ var psdVecBegin = mem.ReadPointer(serverData + offsets.PlayerServerDataOffset);
+ if (psdVecBegin == 0) return "Error: PSD vector is null";
+ var psd = mem.ReadPointer(psdVecBegin);
+ if (psd == 0) return "Error: PSD[0] is null";
+
+ var questFlagsAddr = psd + offsets.QuestFlagsOffset;
+
+ // Read int32 index vector
+ var idxBegin = mem.ReadPointer(questFlagsAddr);
+ var idxEnd = mem.ReadPointer(questFlagsAddr + 8);
+ if (idxBegin == 0 || idxEnd <= idxBegin) return "Error: int32 vector empty";
+ var idxCount = (int)(idxEnd - idxBegin) / 4;
+ var idxData = mem.ReadBytes(idxBegin, idxCount * 4);
+
+ // Read companion vector
+ var compBegin = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset);
+ var compEnd = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset + 8);
+ if (compBegin == 0 || compEnd <= compBegin) return "Error: companion vector empty";
+ var compEntrySize = offsets.QuestCompanionEntrySize;
+ var compCount = (int)(compEnd - compBegin) / compEntrySize;
+ var compData = mem.ReadBytes(compBegin, compCount * compEntrySize);
+ if (compData is null || idxData is null) return "Error: failed to read vectors";
+
+ sb.AppendLine($"Int32 vector: {idxCount} entries | Companion vector: {compCount} entries × {compEntrySize} bytes");
+
+ // Load quest name lookup (same fallback logic as GameMemoryReader)
+ var questNameLookup = new QuestNameLookup();
+ var questNamesPath = Path.Combine(AppContext.BaseDirectory, "quest_names.json");
+ if (!File.Exists(questNamesPath))
+ questNamesPath = "quest_names.json";
+ questNameLookup.Load(questNamesPath);
+ if (questNameLookup.IsLoaded)
+ sb.AppendLine($"Loaded {questNameLookup.Count} quest names from {questNamesPath}");
+ else
+ sb.AppendLine($"WARNING: quest_names.json not found (tried {questNamesPath})");
+
+ // ── Raw companion entry dump (first 5) ──
+ sb.AppendLine($"\nRaw companion bytes (first 5 entries, {compEntrySize} bytes each):");
+ sb.AppendLine(new string('─', 110));
+ for (var i = 0; i < Math.Min(5, compCount); i++)
+ {
+ var off = i * compEntrySize;
+ var hex = BitConverter.ToString(compData, off, compEntrySize).Replace("-", " ");
+ sb.AppendLine($" [{i,3}]: {hex}");
+ }
+
+ // ── Read encounter states from quest state objects ──
+ sb.AppendLine($"\nReading encounter states from ALL {compCount} quest state objects...");
+ sb.AppendLine(new string('═', 110));
+
+ var stateDistribution = new Dictionary();
+ var trackedEntries = new List();
+ var questEntries = new List<(int idx, int questStateId, byte encounterState, uint f4, nint objPtr, int vecCount, string? name, string? internalId)>();
+
+ for (var i = 0; i < compCount; i++)
+ {
+ var compOff = i * compEntrySize;
+ var questStateId = BitConverter.ToInt32(compData, compOff);
+ var f4 = BitConverter.ToUInt32(compData, compOff + 4);
+ var objPtr = (nint)BitConverter.ToInt64(compData, compOff + offsets.QuestCompanionObjPtrOffset);
+
+ byte encounterState = 0;
+ var vecCount = 0;
+
+ if (objPtr != 0 && ((ulong)objPtr >> 32) is > 0 and < 0x7FFF)
+ {
+ // Read encounter state
+ if (offsets.QuestObjEncounterStateOffset > 0)
+ {
+ var stateByte = mem.ReadBytes(objPtr + offsets.QuestObjEncounterStateOffset, 1);
+ if (stateByte is { Length: 1 })
+ encounterState = stateByte[0];
+ }
+
+ // Read inline vector count (end - begin) / 8
+ var vecBeginVal = mem.ReadPointer(objPtr + 0x18);
+ var vecEndVal = mem.ReadPointer(objPtr + 0x20);
+ if (vecBeginVal != 0 && vecEndVal > vecBeginVal)
+ vecCount = (int)(vecEndVal - vecBeginVal) / 8;
+ }
+
+ stateDistribution.TryAdd(encounterState, 0);
+ stateDistribution[encounterState]++;
+
+ // Check tracked flag
+ if (f4 == offsets.QuestTrackedMarker)
+ trackedEntries.Add(i);
+
+ // Resolve quest name
+ string? name = null, internalId = null;
+ if (questNameLookup.TryGet(questStateId, out var entry))
+ {
+ name = entry?.Name;
+ internalId = entry?.InternalId;
+ }
+
+ questEntries.Add((i, questStateId, encounterState, f4, objPtr, vecCount, name, internalId));
+ }
+
+ // ── Encounter state distribution ──
+ sb.AppendLine($"\nEncounter state distribution:");
+ foreach (var (state, count) in stateDistribution.OrderBy(kv => kv.Key))
+ sb.AppendLine($" state={state}: {count} entries ({count * 100 / compCount}%)");
+
+ // ── Tracked quest ──
+ sb.AppendLine($"\nTracked quests (f4 == 0x{offsets.QuestTrackedMarker:X}):");
+ if (trackedEntries.Count == 0)
+ {
+ sb.AppendLine(" NONE found with expected marker!");
+ // Scan for most common f4 values to understand the field
+ var f4Counts = questEntries.GroupBy(e => e.f4).OrderByDescending(g => g.Count()).Take(10);
+ sb.AppendLine(" Most common +0x04 values:");
+ foreach (var group in f4Counts)
+ sb.AppendLine($" 0x{group.Key:X8}: {group.Count()} entries");
+ }
+ else
+ {
+ foreach (var idx in trackedEntries)
+ {
+ var e = questEntries[idx];
+ sb.AppendLine($" [{idx}] QuestStateId={e.questStateId} \"{e.name}\" ({e.internalId})");
+ }
+ }
+
+ // ── Full quest table ──
+ sb.AppendLine($"\nAll quests ({compCount} entries):");
+ sb.AppendLine($" {"#",4} {"StateIdx",8} {"ES",2} {"Vec",3} {"f4",10} {"Name",-30} {"InternalId",-30}");
+ sb.AppendLine(new string('─', 110));
+
+ foreach (var e in questEntries)
+ {
+ var tracked = e.f4 == offsets.QuestTrackedMarker ? " ◄TRACKED" : "";
+ sb.AppendLine($" {e.idx,4} {e.questStateId,8} {e.encounterState,2} {e.vecCount,3} 0x{e.f4:X8} {(e.name ?? "?"),-30} {(e.internalId ?? ""),-30}{tracked}");
+ }
+
+ return sb.ToString();
+ }
+
///
/// Dumps quest state objects to discover the Quest pointer offset.
/// Follows: ServerData → PSD → QuestFlags companion vector → quest state object → probe for Quest dat ptr.
@@ -4999,6 +5160,567 @@ public sealed class MemoryDiagnostics
return sb.ToString();
}
+ ///
+ /// Cross-correlates quest state objects to discover consistent offsets for
+ /// QuestStateId (int32), QuestStateText (wchar*), QuestProgressText (wchar*), and QuestPtr (ptr→Quest).
+ /// Includes RTTI resolution, raw hex dumps, and broader string detection.
+ ///
+ public string ScanQuestStateOffsets()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (ingameData == 0) return "Error: AreaInstance not resolved";
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+ var sb = new StringBuilder();
+
+ var serverData = mem.ReadPointer(ingameData + offsets.ServerDataOffset);
+ if (serverData == 0) return "Error: ServerData is null";
+
+ var psdVecBegin = mem.ReadPointer(serverData + offsets.PlayerServerDataOffset);
+ if (psdVecBegin == 0) return "Error: PSD vector is null";
+ var psd = mem.ReadPointer(psdVecBegin);
+ if (psd == 0) return "Error: PSD[0] is null";
+
+ var questFlagsAddr = psd + offsets.QuestFlagsOffset;
+
+ // Read companion vector
+ var compBegin = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset);
+ var compEnd = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset + 8);
+ if (compBegin == 0 || compEnd <= compBegin) return "Error: companion vector empty";
+ var compEntrySize = offsets.QuestCompanionEntrySize;
+ var compCount = (int)(compEnd - compBegin) / compEntrySize;
+ if (compCount <= 0 || compCount > 500) return $"Error: bad companion count ({compCount})";
+
+ var compData = mem.ReadBytes(compBegin, compCount * compEntrySize);
+ if (compData is null) return "Error: failed to read companion data";
+
+ sb.AppendLine($"Companion vector: {compCount} entries × {compEntrySize} bytes");
+ sb.AppendLine(new string('═', 110));
+
+ // Collect quest state objects and their raw data
+ const int probeSize = 0x300;
+ var entries = new List<(int index, int questStateId, uint trackedFlag, nint objPtr, byte[] objData)>();
+
+ // Sample more broadly: first 10, then every 10th, to get diverse QuestStateIds
+ var sampled = new HashSet();
+ for (var i = 0; i < compCount && entries.Count < 20; i++)
+ {
+ // First 10, then every 10th entry for diversity
+ if (i >= 10 && i % 10 != 0) continue;
+
+ var compOff = i * compEntrySize;
+ var questStateId = BitConverter.ToInt32(compData, compOff);
+ var trackedFlag = BitConverter.ToUInt32(compData, compOff + offsets.QuestCompanionTrackedOffset);
+ var objPtr = (nint)BitConverter.ToInt64(compData, compOff + offsets.QuestCompanionObjPtrOffset);
+
+ if (objPtr == 0 || (ulong)objPtr >> 32 is 0 or >= 0x7FFF) continue;
+
+ var objData = mem.ReadBytes(objPtr, probeSize);
+ if (objData is null) continue;
+
+ entries.Add((i, questStateId, trackedFlag, objPtr, objData));
+ sampled.Add(i);
+ }
+
+ if (entries.Count == 0) return "Error: no valid quest state objects found";
+ sb.AppendLine($"Valid quest state objects: {entries.Count} (from {compCount} companions)");
+
+ // ── Phase 0: RTTI analysis — identify object types ──
+ sb.AppendLine($"\nPhase 0: RTTI / vtable analysis");
+ sb.AppendLine(new string('─', 110));
+
+ var vtableCounts = new Dictionary();
+ foreach (var (index, questStateId, trackedFlag, objPtr, objData) in entries)
+ {
+ var vtable = (nint)BitConverter.ToInt64(objData, 0);
+ if (!vtableCounts.ContainsKey(vtable))
+ {
+ string? rttiName = null;
+ if (vtable != 0 && _ctx.IsModuleAddress(vtable))
+ rttiName = _rtti.ResolveRttiName(vtable);
+ vtableCounts[vtable] = (rttiName, 0);
+ }
+ var (name, count) = vtableCounts[vtable];
+ vtableCounts[vtable] = (name, count + 1);
+ }
+
+ foreach (var (vtable, (name, count)) in vtableCounts.OrderByDescending(kv => kv.Value.count))
+ {
+ sb.AppendLine($" vtable 0x{vtable:X}: {count}/{entries.Count} RTTI: {name ?? "(not resolved)"}");
+ }
+
+ // ── Phase 0b: Companion entry dump (first 5) ──
+ sb.AppendLine($"\nCompanion entries (first 5):");
+ sb.AppendLine(new string('─', 110));
+ for (var i = 0; i < Math.Min(5, entries.Count); i++)
+ {
+ var (index, questStateId, trackedFlag, objPtr, _) = entries[i];
+ var isTracked = trackedFlag == offsets.QuestTrackedMarker;
+ sb.AppendLine($" [{index}] QuestStateId={questStateId} tracked={isTracked} (0x{trackedFlag:X}) objPtr=0x{objPtr:X}");
+ }
+
+ // ── Phase 1: Find QuestStateId offset (int32 matching companion's questStateId) ──
+ // Only count entries with questStateId >= 5 to reduce false positives from small numbers
+ sb.AppendLine($"\nPhase 1: Scanning for QuestStateId (int32 match, only entries with id>=5)");
+ sb.AppendLine(new string('─', 110));
+
+ var significantEntries = entries.Where(e => e.questStateId >= 5).ToList();
+ sb.AppendLine($" Entries with questStateId >= 5: {significantEntries.Count}");
+
+ var idOffsetCounts = new Dictionary();
+ var idOffsetSamples = new Dictionary>();
+
+ foreach (var (index, questStateId, _, objPtr, objData) in significantEntries)
+ {
+ for (var off = 0; off + 4 <= objData.Length; off += 4)
+ {
+ var val = BitConverter.ToInt32(objData, off);
+ if (val == questStateId)
+ {
+ idOffsetCounts.TryAdd(off, 0);
+ idOffsetCounts[off]++;
+ if (!idOffsetSamples.ContainsKey(off))
+ idOffsetSamples[off] = new List();
+ if (idOffsetSamples[off].Count < 5)
+ idOffsetSamples[off].Add($"[{index}]={questStateId}");
+ }
+ }
+ }
+
+ // Also run on ALL entries (including id < 5) for offsets that scored well on significant ones
+ var allIdOffsetCounts = new Dictionary();
+ foreach (var (index, questStateId, _, objPtr, objData) in entries)
+ {
+ for (var off = 0; off + 4 <= objData.Length; off += 4)
+ {
+ var val = BitConverter.ToInt32(objData, off);
+ if (val == questStateId)
+ {
+ allIdOffsetCounts.TryAdd(off, 0);
+ allIdOffsetCounts[off]++;
+ }
+ }
+ }
+
+ var bestIdOffsets = idOffsetCounts
+ .OrderByDescending(kv => kv.Value)
+ .Take(8)
+ .ToList();
+
+ foreach (var (off, count) in bestIdOffsets)
+ {
+ var allCount = allIdOffsetCounts.GetValueOrDefault(off, 0);
+ var samples = string.Join(", ", idOffsetSamples[off]);
+ sb.AppendLine($" +0x{off:X3}: {count}/{significantEntries.Count} (sig) {allCount}/{entries.Count} (all) samples: {samples}");
+ }
+
+ if (bestIdOffsets.Count == 0)
+ sb.AppendLine(" (no int32 matches found)");
+
+ // ── Phase 2: Find wchar* string offsets (direct pointer → readable text, relaxed validation) ──
+ sb.AppendLine($"\nPhase 2: Scanning for wchar* string pointers (direct, relaxed)");
+ sb.AppendLine(new string('─', 110));
+
+ var strOffsetHits = new Dictionary>();
+
+ foreach (var (index, questStateId, _, objPtr, objData) in entries)
+ {
+ for (var off = 0; off + 8 <= objData.Length; off += 8)
+ {
+ var ptr = (nint)BitConverter.ToInt64(objData, off);
+ if (ptr == 0 || (ulong)ptr >> 32 is 0 or >= 0x7FFF) continue;
+ if (Math.Abs((long)ptr - (long)objPtr) < probeSize) continue;
+
+ // Skip vtable-like pointers (module addresses)
+ if (_ctx.IsModuleAddress(ptr)) continue;
+
+ var strBytes = mem.ReadBytes(ptr, 512);
+ if (strBytes is null) continue;
+
+ // Try both strict Latin and relaxed Unicode
+ var str = TryDecodeLatinWcharString(strBytes) ?? TryDecodeWcharString(strBytes);
+ if (str is null) continue;
+
+ if (!strOffsetHits.ContainsKey(off))
+ strOffsetHits[off] = new List<(int, string)>();
+ strOffsetHits[off].Add((index, str));
+ }
+ }
+
+ var bestStrOffsets = strOffsetHits
+ .OrderByDescending(kv => kv.Value.Count)
+ .Take(10)
+ .ToList();
+
+ foreach (var (off, hits) in bestStrOffsets)
+ {
+ var pct = hits.Count * 100 / entries.Count;
+ sb.AppendLine($" +0x{off:X3}: {hits.Count}/{entries.Count} hits ({pct}%)");
+ foreach (var (idx, text) in hits.Take(3))
+ sb.AppendLine($" [{idx}] \"{(text.Length > 80 ? text[..80] + "..." : text)}\"");
+ }
+
+ if (bestStrOffsets.Count == 0)
+ sb.AppendLine(" (no direct wchar* strings found)");
+
+ // ── Phase 3: Find ptr → ptr → wchar* (two-level deref, e.g. QuestPtr → Quest.Name) ──
+ sb.AppendLine($"\nPhase 3: Scanning for ptr → ptr → wchar* (two-level deref)");
+ sb.AppendLine(new string('─', 110));
+
+ var derefOffsetHits = new Dictionary<(int off, int subOff), List<(int index, string text)>>();
+
+ foreach (var (index, questStateId, _, objPtr, objData) in entries)
+ {
+ for (var off = 0; off + 8 <= objData.Length; off += 8)
+ {
+ var ptr = (nint)BitConverter.ToInt64(objData, off);
+ if (ptr == 0 || (ulong)ptr >> 32 is 0 or >= 0x7FFF) continue;
+ if (Math.Abs((long)ptr - (long)objPtr) < probeSize) continue;
+ if (_ctx.IsModuleAddress(ptr)) continue;
+ if (strOffsetHits.ContainsKey(off)) continue;
+
+ var subData = mem.ReadBytes(ptr, 0x100);
+ if (subData is null) continue;
+
+ for (var subOff = 0; subOff + 8 <= subData.Length; subOff += 8)
+ {
+ var strPtr = (nint)BitConverter.ToInt64(subData, subOff);
+ if (strPtr == 0 || (ulong)strPtr >> 32 is 0 or >= 0x7FFF) continue;
+
+ var strBytes = mem.ReadBytes(strPtr, 512);
+ if (strBytes is null) continue;
+
+ var str = TryDecodeLatinWcharString(strBytes) ?? TryDecodeWcharString(strBytes);
+ if (str is null) continue;
+
+ var key = (off, subOff);
+ if (!derefOffsetHits.ContainsKey(key))
+ derefOffsetHits[key] = new List<(int, string)>();
+ derefOffsetHits[key].Add((index, str));
+ }
+ }
+ }
+
+ var bestDerefOffsets = derefOffsetHits
+ .OrderByDescending(kv => kv.Value.Count)
+ .Take(10)
+ .ToList();
+
+ foreach (var ((off, subOff), hits) in bestDerefOffsets)
+ {
+ var pct = hits.Count * 100 / entries.Count;
+ sb.AppendLine($" +0x{off:X3} → +0x{subOff:X2}: {hits.Count}/{entries.Count} hits ({pct}%)");
+ foreach (var (idx, text) in hits.Take(3))
+ sb.AppendLine($" [{idx}] \"{(text.Length > 80 ? text[..80] + "..." : text)}\"");
+ }
+
+ if (bestDerefOffsets.Count == 0)
+ sb.AppendLine(" (no two-level deref strings found)");
+
+ // ── Phase 4: Find MSVC std::wstring using ReadMsvcWString ──
+ sb.AppendLine($"\nPhase 4: Scanning for MSVC std::wstring (via ReadMsvcWString, 32-byte struct)");
+ sb.AppendLine(new string('─', 110));
+
+ var wstrOffsetHits = new Dictionary>();
+
+ foreach (var (index, questStateId, _, objPtr, objData) in entries)
+ {
+ for (var off = 0; off + 0x20 <= objData.Length; off += 8)
+ {
+ var str = _strings.ReadMsvcWString(objPtr + off);
+ if (str is null || str.Length < 3) continue;
+
+ if (!wstrOffsetHits.ContainsKey(off))
+ wstrOffsetHits[off] = new List<(int, string)>();
+ wstrOffsetHits[off].Add((index, str));
+ }
+ }
+
+ var bestWstrOffsets = wstrOffsetHits
+ .OrderByDescending(kv => kv.Value.Count)
+ .Take(10)
+ .ToList();
+
+ foreach (var (off, hits) in bestWstrOffsets)
+ {
+ var pct = hits.Count * 100 / entries.Count;
+ sb.AppendLine($" +0x{off:X3}: {hits.Count}/{entries.Count} hits ({pct}%)");
+ foreach (var (idx, text) in hits.Take(3))
+ sb.AppendLine($" [{idx}] \"{(text.Length > 80 ? text[..80] + "..." : text)}\"");
+ }
+
+ if (bestWstrOffsets.Count == 0)
+ sb.AppendLine(" (no std::wstring fields found)");
+
+ // ── Phase 5: Raw hex dump of first 3 objects ──
+ sb.AppendLine($"\nPhase 5: Raw hex dump (first 3 objects, 0x{probeSize:X} bytes each)");
+ sb.AppendLine(new string('─', 110));
+
+ for (var ei = 0; ei < Math.Min(3, entries.Count); ei++)
+ {
+ var (index, questStateId, trackedFlag, objPtr, objData) = entries[ei];
+ sb.AppendLine($"\n [{index}] QuestStateId={questStateId} objPtr=0x{objPtr:X}");
+
+ for (var row = 0; row < Math.Min(0x100, objData.Length); row += 16)
+ {
+ var hexPart = BitConverter.ToString(objData, row, Math.Min(16, objData.Length - row)).Replace("-", " ");
+ // Show qwords and annotate pointers
+ var q0 = row + 8 <= objData.Length ? BitConverter.ToInt64(objData, row) : 0;
+ var q1 = row + 16 <= objData.Length ? BitConverter.ToInt64(objData, row + 8) : 0;
+ var ann0 = AnnotateQword(q0);
+ var ann1 = AnnotateQword(q1);
+ sb.AppendLine($" +0x{row:X3}: {hexPart,-48} {ann0} {ann1}");
+ }
+ }
+
+ // ── Phase 6: Pointer classification for all offsets ──
+ sb.AppendLine($"\nPhase 6: Pointer classification (all offsets, consistency across entries)");
+ sb.AppendLine(new string('─', 110));
+
+ // For each 8-byte offset, classify what kind of value it holds across all entries
+ for (var off = 0; off + 8 <= probeSize; off += 8)
+ {
+ var heapPtrCount = 0;
+ var modulePtrCount = 0;
+ var zeroCount = 0;
+ var smallIntCount = 0;
+ nint samplePtr = 0;
+
+ foreach (var (_, _, _, objPtr, objData) in entries)
+ {
+ var val = BitConverter.ToInt64(objData, off);
+ if (val == 0) { zeroCount++; continue; }
+ var high = (ulong)val >> 32;
+ if (high is > 0 and < 0x7FFF)
+ {
+ if (_ctx.IsModuleAddress((nint)val))
+ modulePtrCount++;
+ else
+ {
+ heapPtrCount++;
+ if (samplePtr == 0) samplePtr = (nint)val;
+ }
+ }
+ else if (val is > 0 and < 0x10000)
+ smallIntCount++;
+ }
+
+ // Only show offsets with consistent patterns (>50% agreement)
+ var total = entries.Count;
+ if (heapPtrCount * 2 >= total)
+ {
+ // Check if all heap pointers point to objects with same vtable
+ string? commonRtti = null;
+ var sameVtable = true;
+ nint firstVtable = 0;
+ foreach (var (_, _, _, _, objData) in entries)
+ {
+ var ptr = (nint)BitConverter.ToInt64(objData, off);
+ if (ptr == 0 || (ulong)ptr >> 32 is 0 or >= 0x7FFF) continue;
+ if (_ctx.IsModuleAddress(ptr)) continue;
+ var targetVtable = mem.ReadPointer(ptr);
+ if (targetVtable == 0) continue;
+ if (firstVtable == 0)
+ {
+ firstVtable = targetVtable;
+ if (_ctx.IsModuleAddress(targetVtable))
+ commonRtti = _rtti.ResolveRttiName(targetVtable);
+ }
+ else if (targetVtable != firstVtable)
+ sameVtable = false;
+ }
+
+ var vtableInfo = sameVtable && commonRtti is not null ? $" target RTTI: {commonRtti}" : "";
+ sb.AppendLine($" +0x{off:X3}: heap ptr {heapPtrCount}/{total} sample=0x{samplePtr:X}{vtableInfo}");
+ }
+ else if (modulePtrCount * 2 >= total)
+ sb.AppendLine($" +0x{off:X3}: module ptr (vtable?) {modulePtrCount}/{total}");
+ }
+
+ // ── Phase 7: Detect inline StdVector at +0x018 and follow elements ──
+ sb.AppendLine($"\nPhase 7: Inline StdVector at +0x018 — following vector elements");
+ sb.AppendLine(new string('─', 110));
+
+ // Verify the StdVector pattern: begin = objPtr+0x30, end/cap relative to objPtr
+ var vecElementSummary = new Dictionary>(); // vecIdx → info per entry
+
+ for (var ei = 0; ei < Math.Min(5, entries.Count); ei++)
+ {
+ var (index, questStateId, _, objPtr, objData) = entries[ei];
+ var vecBeginVal = (nint)BitConverter.ToInt64(objData, 0x18);
+ var vecEndVal = (nint)BitConverter.ToInt64(objData, 0x20);
+ var vecCapVal = (nint)BitConverter.ToInt64(objData, 0x28);
+
+ if (vecBeginVal != objPtr + 0x30)
+ {
+ sb.AppendLine($" [{index}] StdVector begin != objPtr+0x30 (0x{vecBeginVal:X} vs 0x{objPtr + 0x30:X}) — not inline vector?");
+ continue;
+ }
+
+ var vecCount = (int)(vecEndVal - vecBeginVal) / 8;
+ var vecCap = (int)(vecCapVal - vecBeginVal) / 8;
+ sb.AppendLine($" [{index}] QuestStateId={questStateId}: {vecCount} elements (cap={vecCap})");
+
+ // Follow first few vector elements
+ var dumpCount = Math.Min(vecCount, 5);
+ for (var vi = 0; vi < dumpCount; vi++)
+ {
+ var elemPtr = (nint)BitConverter.ToInt64(objData, 0x30 + vi * 8);
+ if (elemPtr == 0 || (ulong)elemPtr >> 32 is 0 or >= 0x7FFF) continue;
+
+ var elemData = mem.ReadBytes(elemPtr, 0x80);
+ if (elemData is null) continue;
+
+ // Check vtable / RTTI
+ var elemVtable = (nint)BitConverter.ToInt64(elemData, 0);
+ string? elemRtti = null;
+ if (elemVtable != 0 && _ctx.IsModuleAddress(elemVtable))
+ elemRtti = _rtti.ResolveRttiName(elemVtable);
+
+ // Probe for strings at every 8-byte offset
+ var foundStrings = new List<(int off, string text)>();
+ for (var soff = 0; soff + 8 <= elemData.Length; soff += 8)
+ {
+ var sptr = (nint)BitConverter.ToInt64(elemData, soff);
+ if (sptr == 0 || (ulong)sptr >> 32 is 0 or >= 0x7FFF) continue;
+ if (_ctx.IsModuleAddress(sptr)) continue;
+ if (Math.Abs((long)sptr - (long)elemPtr) < 0x80) continue;
+
+ var sdata = mem.ReadBytes(sptr, 256);
+ if (sdata is null) continue;
+ var str = TryDecodeLatinWcharString(sdata);
+ if (str is not null)
+ foundStrings.Add((soff, str));
+ }
+
+ // Also try ReadMsvcWString at each offset
+ for (var soff = 0; soff + 0x20 <= elemData.Length; soff += 8)
+ {
+ var str = _strings.ReadMsvcWString(elemPtr + soff);
+ if (str is not null && str.Length >= 3 && !foundStrings.Any(f => f.text == str))
+ foundStrings.Add((soff, $"[wstr] {str}"));
+ }
+
+ var rttiStr = elemRtti is not null ? $" RTTI={elemRtti}" : "";
+ sb.AppendLine($" vec[{vi}] 0x{elemPtr:X} vtable=0x{elemVtable:X}{rttiStr}");
+ foreach (var (soff, text) in foundStrings.Take(4))
+ sb.AppendLine($" +0x{soff:X2}: \"{(text.Length > 70 ? text[..70] + "..." : text)}\"");
+ if (foundStrings.Count == 0)
+ sb.AppendLine($" (no strings)");
+ }
+ }
+
+ // ── Phase 8: Follow consistent pointer fields after vector ──
+ // These are at fixed offsets: +0x0B0, +0x0C0, +0x150, +0x158, +0x180, +0x188, +0x190, +0x198
+ sb.AppendLine($"\nPhase 8: Following consistent pointer fields (post-vector fixed layout)");
+ sb.AppendLine(new string('─', 110));
+
+ var fixedPtrOffsets = new[] { 0x0B0, 0x0C0, 0x0C8, 0x150, 0x158, 0x180, 0x188, 0x190, 0x198,
+ 0x1A0, 0x1A8, 0x1B0, 0x1B8, 0x1C0, 0x1C8, 0x1D0, 0x1D8,
+ 0x1E0, 0x1E8, 0x1F0, 0x1F8, 0x200, 0x210, 0x228, 0x230, 0x280 };
+
+ foreach (var foff in fixedPtrOffsets)
+ {
+ if (foff + 8 > probeSize) continue;
+
+ // Check consistency: how many entries have a valid heap pointer here?
+ var validCount = 0;
+ var strings = new List<(int idx, string text, string source)>();
+
+ foreach (var (index, questStateId, _, objPtr, objData) in entries.Take(8))
+ {
+ var val = (nint)BitConverter.ToInt64(objData, foff);
+ if (val == 0 || (ulong)val >> 32 is 0 or >= 0x7FFF) continue;
+ if (_ctx.IsModuleAddress(val)) continue;
+ // Skip self-references (within object)
+ if (Math.Abs((long)val - (long)objPtr) < probeSize) continue;
+ validCount++;
+
+ // Try as direct wchar*
+ var sdata = mem.ReadBytes(val, 256);
+ if (sdata is not null)
+ {
+ var str = TryDecodeLatinWcharString(sdata);
+ if (str is not null)
+ {
+ strings.Add((index, str, "wchar*"));
+ continue;
+ }
+ }
+
+ // Try as std::wstring
+ var wstr = _strings.ReadMsvcWString(val);
+ if (wstr is not null && wstr.Length >= 3)
+ {
+ strings.Add((index, wstr, "wstring@target"));
+ continue;
+ }
+
+ // Try one deref: val → ptr → wchar* (Quest → Quest.Name pattern)
+ var targetData = mem.ReadBytes(val, 0x60);
+ if (targetData is null) continue;
+ for (var toff = 0; toff + 8 <= targetData.Length; toff += 8)
+ {
+ var tptr = (nint)BitConverter.ToInt64(targetData, toff);
+ if (tptr == 0 || (ulong)tptr >> 32 is 0 or >= 0x7FFF) continue;
+ if (_ctx.IsModuleAddress(tptr)) continue;
+ var tdata = mem.ReadBytes(tptr, 256);
+ if (tdata is null) continue;
+ var tstr = TryDecodeLatinWcharString(tdata);
+ if (tstr is not null)
+ {
+ strings.Add((index, tstr, $"deref+0x{toff:X2}"));
+ break; // one per entry is enough
+ }
+ }
+ }
+
+ if (strings.Count == 0) continue; // Skip offsets with no strings at all
+
+ sb.AppendLine($" +0x{foff:X3}: {validCount}/8 valid ptrs, {strings.Count} strings:");
+ foreach (var (idx, text, source) in strings.Take(5))
+ sb.AppendLine($" [{idx}] ({source}) \"{(text.Length > 70 ? text[..70] + "..." : text)}\"");
+ }
+
+ // ── Phase 9: Structure summary ──
+ sb.AppendLine($"\nPhase 9: Structure layout summary");
+ sb.AppendLine(new string('═', 110));
+ sb.AppendLine(" +0x000: vtable (module ptr)");
+ sb.AppendLine(" +0x008: int32 encounter_state + int32 unknown");
+ sb.AppendLine(" +0x010: module ptr (second vtable or vfptr?)");
+ sb.AppendLine(" +0x018: StdVector begin (= objPtr+0x30)");
+ sb.AppendLine(" +0x020: StdVector end (variable per entry)");
+ sb.AppendLine(" +0x028: StdVector capacity (= objPtr+0xB0)");
+ sb.AppendLine(" +0x030..0xAF: inline vector data (variable length, cap=16 ptrs)");
+ sb.AppendLine(" +0x0B0: linked list head (both ptrs → objPtr+0xC8)");
+ sb.AppendLine(" +0x0C0: ptr → objPtr+0x148 (another inline struct?)");
+ sb.AppendLine(" +0x150..0x198: consistent heap pointer fields");
+
+ return sb.ToString();
+ }
+
+ /// Annotates a qword value for hex dumps: identifies pointers, small ints, etc.
+ private string AnnotateQword(long val)
+ {
+ if (val == 0) return "NULL";
+ var high = (ulong)val >> 32;
+ if (high is > 0 and < 0x7FFF)
+ {
+ if (_ctx.IsModuleAddress((nint)val)) return $"[module]";
+ return $"0x{val:X}";
+ }
+ if (val is > 0 and < 0x10000) return $"int:{val}";
+ return "";
+ }
+
/// Tries to decode bytes as a null-terminated wchar (UTF-16LE) string. Returns null if not valid text.
private static string? TryDecodeWcharString(byte[] data)
{
@@ -5580,4 +6302,1140 @@ public sealed class MemoryDiagnostics
sb.AppendLine($" MonsterLvl (+0x{offsets.AreaTemplateMonsterLevelOffset:X2}): {monsterLevel}");
sb.AppendLine($" WorldAreaId(+0x{offsets.AreaTemplateWorldAreaIdOffset:X2}): {worldAreaId}");
}
+
+ ///
+ /// Follows the pointer chain from GameStateBase to quest state data:
+ /// [GameStateBase+0x18] → [+0] → [+0] → +0xD90 → container
+ /// Then walks the tree/list structure reading quest state entries.
+ /// Each entry: +0x10 → deref → quest internal ID, +0x20 = active state int32.
+ ///
+ public string ProbeQuestAddresses(string addressListText = "", int expectedQuestStateId = 0)
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var mem = _ctx.Memory;
+ var sb = new StringBuilder();
+
+ sb.AppendLine($"GameStateBase: 0x{_ctx.GameStateBase:X}");
+
+ // ── Follow pointer chain: [GameStateBase+0x18] → [+0] → [+0] → +0xD90 ──
+ var ptr1 = mem.ReadPointer(_ctx.GameStateBase + 0x18);
+ sb.AppendLine($"[GameStateBase+0x18] = 0x{ptr1:X}");
+ if (ptr1 == 0) return sb.AppendLine("ERROR: ptr1 is null").ToString();
+
+ var ptr2 = mem.ReadPointer(ptr1);
+ sb.AppendLine($"[ptr1+0x00] = 0x{ptr2:X}");
+ if (ptr2 == 0) return sb.AppendLine("ERROR: ptr2 is null").ToString();
+
+ var ptr3 = mem.ReadPointer(ptr2);
+ sb.AppendLine($"[ptr2+0x00] = 0x{ptr3:X}");
+ if (ptr3 == 0) return sb.AppendLine("ERROR: ptr3 is null").ToString();
+
+ var containerBase = ptr3 + 0xD90;
+ sb.AppendLine($"Container base (ptr3+0xD90) = 0x{containerBase:X}");
+ sb.AppendLine();
+
+ // ── Hexdump the first 0x100 bytes at container base ──
+ sb.AppendLine("═══ Hexdump at container base ═══");
+ var headerDump = mem.ReadBytes(containerBase, 0x100);
+ if (headerDump is not null)
+ {
+ for (var off = 0; off < headerDump.Length; off += 16)
+ {
+ var hex = BitConverter.ToString(headerDump, off, Math.Min(16, headerDump.Length - off)).Replace("-", " ");
+ sb.Append($" +0x{off:X3}: {hex,-48}");
+
+ // Annotate pointers
+ for (var p = 0; p < 16 && off + p + 7 < headerDump.Length; p += 8)
+ {
+ var pv = (nint)BitConverter.ToInt64(headerDump, off + p);
+ if (pv != 0 && ((ulong)pv >> 32) is > 0 and < 0x7FFF)
+ sb.Append($" ptr");
+ }
+ sb.AppendLine();
+ }
+ }
+
+ // ── Try interpreting as vector header: begin/end/cap at +0x00 ──
+ sb.AppendLine();
+ sb.AppendLine("═══ Vector interpretation at container base ═══");
+ var vecBegin = mem.ReadPointer(containerBase);
+ var vecEnd = mem.ReadPointer(containerBase + 8);
+ var vecCap = mem.ReadPointer(containerBase + 16);
+ sb.AppendLine($" begin=0x{vecBegin:X} end=0x{vecEnd:X} cap=0x{vecCap:X}");
+ if (vecBegin != 0 && vecEnd > vecBegin && (vecEnd - vecBegin) < 0x100000)
+ {
+ var vecSize = (int)(vecEnd - vecBegin);
+ sb.AppendLine($" Size: {vecSize} bytes ({vecSize / 8} ptrs)");
+ }
+
+ // ── Try different offsets around D90 for vector headers ──
+ sb.AppendLine();
+ sb.AppendLine("═══ Scanning ptr3 for vector-like patterns (begin < end < cap) ═══");
+ var ptr3Region = mem.ReadBytes(ptr3 + 0xC00, 0x400); // scan D00..FF0 range
+ if (ptr3Region is not null)
+ {
+ for (var off = 0; off < ptr3Region.Length - 23; off += 8)
+ {
+ var b = (nint)BitConverter.ToInt64(ptr3Region, off);
+ var e = (nint)BitConverter.ToInt64(ptr3Region, off + 8);
+ var c = (nint)BitConverter.ToInt64(ptr3Region, off + 16);
+ if (b == 0 || e == 0) continue;
+ if (e <= b || c < e) continue;
+ if ((e - b) > 0x100000 || (c - b) > 0x100000) continue;
+ if (((ulong)b >> 32) is 0 or >= 0x7FFF) continue;
+
+ var actualOff = 0xC00 + off;
+ var count = (int)(e - b);
+ sb.AppendLine($" ptr3+0x{actualOff:X}: begin=0x{b:X} end=0x{e:X} ({count} bytes / {count / 8} ptrs)");
+ }
+ }
+
+ // ── Broad scan: read large region of ptr3 and look for quest entry pattern ──
+ // Entry pattern: at some offset, [+0x10] = ptr → ptr → PascalCase quest ID string
+ sb.AppendLine();
+ sb.AppendLine("═══ Broad quest entry scan (ptr3 region) ═══");
+
+ var scanSize = 0x4000; // 16KB from ptr3
+ var scanDump = mem.ReadBytes(ptr3, scanSize);
+ if (scanDump is null) return sb.AppendLine("ERROR: failed to read ptr3 region").ToString();
+
+ var entries = new List<(int offset, nint addr, int state, string? name)>();
+
+ for (var off = 0; off < scanDump.Length - 0x24; off += 8)
+ {
+ var candidateIdPtr = (nint)BitConverter.ToInt64(scanDump, off + 0x10);
+ if (candidateIdPtr == 0 || ((ulong)candidateIdPtr >> 32) is 0 or >= 0x7FFF) continue;
+
+ var namePtr2 = mem.ReadPointer(candidateIdPtr);
+ if (namePtr2 == 0 || ((ulong)namePtr2 >> 32) is 0 or >= 0x7FFF) continue;
+
+ var name = _strings.ReadNullTermWString(namePtr2);
+ if (name is null || name.Length == 0 || name.Length > 100) continue;
+ if (!name.All(c => char.IsLetterOrDigit(c) || c == '_')) continue;
+
+ var state = BitConverter.ToInt32(scanDump, off + 0x20);
+ if (state < -1 || state > 1000) continue;
+
+ entries.Add((off, ptr3 + off, state, name));
+ }
+
+ sb.AppendLine($"Found {entries.Count} quest entries in ptr3 region:");
+ sb.AppendLine($" {"ptr3+Off",-10} {"Address",-18} {"State",6} {"Quest ID"}");
+ sb.AppendLine(new string('─', 80));
+
+ foreach (var (off, addr, state, name) in entries)
+ sb.AppendLine($" +0x{off:X4} 0x{addr:X} {state,6} {name}");
+
+ if (entries.Count >= 2)
+ {
+ sb.AppendLine();
+ sb.AppendLine("Entry spacing:");
+ for (var i = 1; i < Math.Min(entries.Count, 20); i++)
+ {
+ var delta = entries[i].offset - entries[i - 1].offset;
+ sb.AppendLine($" [{i - 1}]→[{i}]: +0x{delta:X} ({delta} bytes)");
+ }
+ }
+
+ // ── If no entries in ptr3, try following vector pointers ──
+ if (entries.Count == 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine("═══ Following pointers from ptr3 region ═══");
+ // Try all pointer-like values in ptr3+0xC00..0x1000 and scan THEIR memory for quest entries
+ var ptrRegion = mem.ReadBytes(ptr3 + 0xC00, 0x400);
+ if (ptrRegion is not null)
+ {
+ var followedPtrs = new HashSet();
+ for (var off = 0; off < ptrRegion.Length - 7; off += 8)
+ {
+ var ptrVal = (nint)BitConverter.ToInt64(ptrRegion, off);
+ if (ptrVal == 0 || ((ulong)ptrVal >> 32) is 0 or >= 0x7FFF) continue;
+ if (!followedPtrs.Add(ptrVal)) continue;
+ if (followedPtrs.Count > 20) break;
+
+ // Read 0x2000 bytes from this pointer and scan for quest entries
+ var subDump = mem.ReadBytes(ptrVal, 0x2000);
+ if (subDump is null) continue;
+
+ for (var subOff = 0; subOff < subDump.Length - 0x24; subOff += 8)
+ {
+ var candidateIdPtr = (nint)BitConverter.ToInt64(subDump, subOff + 0x10);
+ if (candidateIdPtr == 0 || ((ulong)candidateIdPtr >> 32) is 0 or >= 0x7FFF) continue;
+
+ var np = mem.ReadPointer(candidateIdPtr);
+ if (np == 0 || ((ulong)np >> 32) is 0 or >= 0x7FFF) continue;
+
+ var nm = _strings.ReadNullTermWString(np);
+ if (nm is null || nm.Length == 0 || nm.Length > 100) continue;
+ if (!nm.All(c => char.IsLetterOrDigit(c) || c == '_')) continue;
+
+ var st = BitConverter.ToInt32(subDump, subOff + 0x20);
+ if (st < -1 || st > 1000) continue;
+
+ sb.AppendLine($" ptr3+0x{0xC00 + off:X} → 0x{ptrVal:X} +0x{subOff:X}: state={st} \"{nm}\"");
+ }
+ }
+ }
+ }
+
+ // ── Also try tree walk from any found entry ──
+ if (entries.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine("═══ Tree walk from first entry ═══");
+ var startAddr = entries[0].addr;
+ var visited = new HashSet();
+ var queue = new Queue();
+ queue.Enqueue(startAddr);
+ var treeEntries = new List<(nint addr, int state, string? name)>();
+
+ while (queue.Count > 0 && visited.Count < 300)
+ {
+ var node = queue.Dequeue();
+ if (node == 0 || !visited.Add(node)) continue;
+ if (((ulong)node >> 32) is 0 or >= 0x7FFF) continue;
+
+ var nodeData = mem.ReadBytes(node, 0x28);
+ if (nodeData is null) continue;
+
+ var n0 = (nint)BitConverter.ToInt64(nodeData, 0x00);
+ var n1 = (nint)BitConverter.ToInt64(nodeData, 0x08);
+ var idP = (nint)BitConverter.ToInt64(nodeData, 0x10);
+ var st = BitConverter.ToInt32(nodeData, 0x20);
+
+ string? nm = null;
+ if (idP != 0 && ((ulong)idP >> 32) is > 0 and < 0x7FFF)
+ {
+ var np = mem.ReadPointer(idP);
+ if (np != 0) nm = _strings.ReadNullTermWString(np);
+ }
+
+ if (nm is { Length: > 0 } && nm.All(c => char.IsLetterOrDigit(c) || c == '_'))
+ treeEntries.Add((node, st, nm));
+
+ queue.Enqueue(n0);
+ queue.Enqueue(n1);
+ }
+
+ sb.AppendLine($"Tree walk: {treeEntries.Count} quest entries (visited {visited.Count} nodes):");
+ sb.AppendLine($" {"Address",-18} {"State",6} {"Quest ID"}");
+ sb.AppendLine(new string('─', 80));
+ foreach (var (addr, state, name) in treeEntries.OrderBy(e => e.name))
+ sb.AppendLine($" 0x{addr:X} {state,6} {name}");
+ }
+
+ return sb.ToString();
+ }
+
+ private void DescribeQuestEntry(StringBuilder sb, ProcessMemory mem, nint addr, string prefix)
+ {
+ if (((ulong)addr >> 32) is 0 or >= 0x7FFF) { sb.AppendLine($"{prefix}invalid ptr"); return; }
+
+ var data = mem.ReadBytes(addr, 0x28);
+ if (data is null) { sb.AppendLine($"{prefix}unreadable"); return; }
+
+ var l0 = (nint)BitConverter.ToInt64(data, 0x00);
+ var l1 = (nint)BitConverter.ToInt64(data, 0x08);
+ var idP = (nint)BitConverter.ToInt64(data, 0x10);
+ var st = BitConverter.ToInt32(data, 0x20);
+
+ string? name = null;
+ if (idP != 0 && ((ulong)idP >> 32) is > 0 and < 0x7FFF)
+ {
+ var np = mem.ReadPointer(idP);
+ if (np != 0) name = _strings.ReadNullTermWString(np);
+ }
+
+ sb.AppendLine($"{prefix}0x{addr:X}: link0=0x{l0:X} link1=0x{l1:X} state={st} id=\"{name}\"");
+ }
+
+ ///
+ /// Scans InGameState and its child structures for vectors that look like quest state containers.
+ /// Looks for StdVector patterns (begin/end/capacity) pointing to arrays of small structs
+ /// where entries contain small ints that could be questId + state + flags.
+ ///
+ public string ScanQuestStateContainers()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+ var sb = new StringBuilder();
+
+ sb.AppendLine($"InGameState: 0x{inGameState:X}");
+
+ // Read the full InGameState struct
+ var igsData = mem.ReadBytes(inGameState, 0x400);
+ if (igsData is null) return "Error: failed to read InGameState";
+
+ // Also resolve child structures to scan
+ var areaInstance = mem.ReadPointer(inGameState + offsets.IngameDataFromStateOffset);
+ var targets = new List<(string Name, nint Addr, int ScanSize)>
+ {
+ ("InGameState", inGameState, 0x400),
+ };
+
+ if (areaInstance != 0 && ((ulong)areaInstance >> 32) is > 0 and < 0x7FFF)
+ {
+ sb.AppendLine($"AreaInstance: 0x{areaInstance:X}");
+ targets.Add(("AreaInstance", areaInstance, 0xC00));
+
+ // ServerData
+ var serverData = mem.ReadPointer(areaInstance + offsets.ServerDataOffset);
+ if (serverData != 0 && ((ulong)serverData >> 32) is > 0 and < 0x7FFF)
+ {
+ sb.AppendLine($"ServerData: 0x{serverData:X}");
+ targets.Add(("ServerData", serverData, 0x400));
+
+ // PSD
+ var psdVec = mem.ReadPointer(serverData + offsets.PlayerServerDataOffset);
+ if (psdVec != 0)
+ {
+ var psd = mem.ReadPointer(psdVec);
+ if (psd != 0 && ((ulong)psd >> 32) is > 0 and < 0x7FFF)
+ {
+ sb.AppendLine($"PSD: 0x{psd:X}");
+ targets.Add(("PSD", psd, 0x600));
+ }
+ }
+ }
+
+ // Also follow first-level heap pointers from AreaInstance
+ var aiData = mem.ReadBytes(areaInstance, 0xC00);
+ if (aiData is not null)
+ {
+ for (var off = 0; off + 8 <= aiData.Length && off < 0xC00; off += 8)
+ {
+ var ptr = (nint)BitConverter.ToInt64(aiData, off);
+ if (ptr != 0 && ((ulong)ptr >> 32) is > 0 and < 0x7FFF && ptr != areaInstance)
+ {
+ // Check if it's a heap object by reading first 8 bytes
+ var probe = mem.ReadBytes(ptr, 8);
+ if (probe is { Length: 8 })
+ {
+ var vtable = (nint)BitConverter.ToInt64(probe, 0);
+ if (vtable != 0 && ((ulong)vtable >> 32) is > 0 and < 0x7FFF)
+ targets.Add(($"AI+0x{off:X}", ptr, 0x400));
+ }
+ }
+ }
+ }
+ }
+
+ sb.AppendLine();
+ sb.AppendLine($"Scanning {targets.Count} targets for quest-like vectors...");
+ sb.AppendLine(new string('═', 100));
+
+ var candidateCount = 0;
+
+ foreach (var (name, addr, scanSize) in targets)
+ {
+ var data = mem.ReadBytes(addr, scanSize);
+ if (data is null) continue;
+
+ // Scan for StdVector patterns: begin(ptr), end(ptr), capacity(ptr)
+ // where end > begin and (end - begin) is divisible by a small struct size (8-32 bytes)
+ for (var off = 0; off + 24 <= data.Length; off += 8)
+ {
+ var vecBegin = (nint)BitConverter.ToInt64(data, off);
+ var vecEnd = (nint)BitConverter.ToInt64(data, off + 8);
+ var vecCap = (nint)BitConverter.ToInt64(data, off + 16);
+
+ if (vecBegin == 0 || vecEnd <= vecBegin || vecCap < vecEnd) continue;
+ if (((ulong)vecBegin >> 32) is 0 or >= 0x7FFF) continue;
+
+ var totalBytes = (int)(vecEnd - vecBegin);
+ if (totalBytes < 8 || totalBytes > 0x100000) continue;
+
+ // Try different entry sizes
+ foreach (var entrySize in new[] { 8, 12, 16, 20, 24, 28, 32 })
+ {
+ if (totalBytes % entrySize != 0) continue;
+ var entryCount = totalBytes / entrySize;
+ if (entryCount < 3 || entryCount > 2000) continue;
+
+ // Read entries and check if they look quest-like
+ var vecData = mem.ReadBytes(vecBegin, Math.Min(totalBytes, entrySize * 20));
+ if (vecData is null) continue;
+
+ var readCount = Math.Min(entryCount, 20);
+ var hasSmallInts = 0; // entries where first int is a small positive number
+ var hasStateRange = 0; // entries where second int is in 0-10 range
+
+ for (var i = 0; i < readCount; i++)
+ {
+ var eOff = i * entrySize;
+ if (eOff + 8 > vecData.Length) break;
+
+ var v0 = BitConverter.ToInt32(vecData, eOff);
+ var v1 = BitConverter.ToInt32(vecData, eOff + 4);
+
+ if (v0 > 0 && v0 < 5000) hasSmallInts++;
+ if (v1 >= 0 && v1 <= 10) hasStateRange++;
+ }
+
+ // Heuristic: >50% of entries should have small-int IDs and state-range values
+ if (hasSmallInts < readCount / 2 || hasStateRange < readCount / 2) continue;
+
+ candidateCount++;
+ sb.AppendLine();
+ sb.AppendLine($"CANDIDATE #{candidateCount}: {name}+0x{off:X} entrySize={entrySize} count={entryCount}");
+ sb.AppendLine($" vecBegin=0x{vecBegin:X} vecEnd=0x{vecEnd:X} totalBytes={totalBytes}");
+ sb.AppendLine($" Heuristic: {hasSmallInts}/{readCount} small IDs, {hasStateRange}/{readCount} state-range");
+
+ // Dump first entries
+ for (var i = 0; i < Math.Min(readCount, 10); i++)
+ {
+ var eOff = i * entrySize;
+ if (eOff + entrySize > vecData.Length) break;
+
+ var hex = BitConverter.ToString(vecData, eOff, entrySize).Replace("-", " ");
+ var ints = new List();
+ for (var j = 0; j + 4 <= entrySize; j += 4)
+ ints.Add(BitConverter.ToInt32(vecData, eOff + j).ToString());
+
+ sb.AppendLine($" [{i,3}]: {hex} | ints: {string.Join(", ", ints)}");
+ }
+
+ if (entryCount > 10)
+ sb.AppendLine($" ... ({entryCount - 10} more entries)");
+ }
+ }
+ }
+
+ if (candidateCount == 0)
+ sb.AppendLine("\nNo quest-like vector candidates found.");
+ else
+ sb.AppendLine($"\n{candidateCount} candidates found.");
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Scans all heap pointers reachable from InGameState (0x000-0x400) and for each one,
+ /// scans for StdVector patterns with quest-like 12-byte entries.
+ ///
+ public string ScanWorldDataVectors()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var mem = _ctx.Memory;
+ var sb = new StringBuilder();
+ sb.AppendLine($"InGameState: 0x{inGameState:X}");
+
+ // Read InGameState region to find all heap pointers
+ var igsSize = 0x400;
+ var igsData = mem.ReadBytes(inGameState, igsSize);
+ if (igsData is null) return "Error: failed to read InGameState";
+
+ // Collect all valid heap pointers from IGS
+ var targets = new List<(string Name, nint Addr)>();
+ for (var off = 0; off + 8 <= igsData.Length; off += 8)
+ {
+ var ptr = (nint)BitConverter.ToInt64(igsData, off);
+ if (ptr == 0 || ((ulong)ptr >> 32) is 0 or >= 0x7FFF) continue;
+ if (ptr == inGameState) continue;
+ // Quick validation — read vtable
+ var vtable = mem.ReadPointer(ptr);
+ if (vtable != 0 && ((ulong)vtable >> 32) is > 0 and < 0x7FFF)
+ targets.Add(($"IGS+0x{off:X}", ptr));
+ }
+
+ sb.AppendLine($"Found {targets.Count} heap pointers in InGameState");
+ sb.AppendLine(new string('═', 100));
+
+ var candidateCount = 0;
+
+ foreach (var (name, addr) in targets)
+ {
+ var scanSize = 0x600;
+ var data = mem.ReadBytes(addr, scanSize);
+ if (data is null) continue;
+
+ for (var off = 0; off + 24 <= data.Length; off += 8)
+ {
+ var vecBegin = (nint)BitConverter.ToInt64(data, off);
+ var vecEnd = (nint)BitConverter.ToInt64(data, off + 8);
+ var vecCap = (nint)BitConverter.ToInt64(data, off + 16);
+
+ if (vecBegin == 0 || vecEnd <= vecBegin || vecCap < vecEnd) continue;
+ if (((ulong)vecBegin >> 32) is 0 or >= 0x7FFF) continue;
+
+ var totalBytes = (int)(vecEnd - vecBegin);
+ if (totalBytes % 12 != 0) continue; // only 12-byte entries
+ var entryCount = totalBytes / 12;
+ if (entryCount < 2 || entryCount > 500) continue;
+
+ var vecData = mem.ReadBytes(vecBegin, Math.Min(totalBytes, 12 * 20));
+ if (vecData is null) continue;
+
+ // Check if entries look quest-like: first int small positive, second int 0-20
+ var readCount = Math.Min(entryCount, 20);
+ var questLike = 0;
+ for (var i = 0; i < readCount; i++)
+ {
+ var v0 = BitConverter.ToInt32(vecData, i * 12);
+ var v1 = BitConverter.ToInt32(vecData, i * 12 + 4);
+ if (v0 > 0 && v0 < 5000 && v1 >= 0 && v1 <= 20) questLike++;
+ }
+ if (questLike < readCount / 2) continue;
+
+ candidateCount++;
+ sb.AppendLine();
+ sb.AppendLine($"#{candidateCount}: {name} → obj 0x{addr:X} +0x{off:X} count={entryCount} ({questLike}/{readCount} quest-like)");
+
+ for (var i = 0; i < Math.Min(readCount, 10); i++)
+ {
+ var eOff = i * 12;
+ if (eOff + 12 > vecData.Length) break;
+ var id = BitConverter.ToInt32(vecData, eOff);
+ var state = BitConverter.ToInt32(vecData, eOff + 4);
+ var flags = BitConverter.ToInt32(vecData, eOff + 8);
+ sb.AppendLine($" [{i,3}]: questId={id} state={state} flags=0x{flags:X}");
+ }
+ if (entryCount > 10)
+ sb.AppendLine($" ... ({entryCount - 10} more entries)");
+ }
+ }
+
+ sb.AppendLine();
+ sb.AppendLine(candidateCount == 0
+ ? "No quest-like 12-byte vectors found in any IGS child object."
+ : $"{candidateCount} candidates found.");
+ return sb.ToString();
+ }
+
+ ///
+ /// Reads the PSD companion vector (PSD+0x320) and follows the pointers for each quest entry.
+ /// Dumps the first 128 bytes of the pointed-to objects to find where state/order/flags live.
+ ///
+ public string ProbeCompanionQuestObjects()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+ var sb = new StringBuilder();
+
+ // Resolve PSD
+ var areaInstance = mem.ReadPointer(inGameState + offsets.IngameDataFromStateOffset);
+ if (areaInstance == 0) return "Error: AreaInstance null";
+
+ var serverData = mem.ReadPointer(areaInstance + offsets.ServerDataOffset);
+ if (serverData == 0) return "Error: ServerData null";
+
+ var psdVec = mem.ReadPointer(serverData + offsets.PlayerServerDataOffset);
+ if (psdVec == 0) return "Error: PSD vector null";
+
+ var psd = mem.ReadPointer(psdVec);
+ if (psd == 0) return "Error: PSD null";
+
+ sb.AppendLine($"PSD: 0x{psd:X}");
+
+ // Read companion vector at PSD+0x320 (QuestFlags+0x18 = PSD+0x308+0x18)
+ var compAddr = psd + offsets.QuestFlagsOffset + offsets.QuestCompanionOffset;
+ var compBegin = mem.ReadPointer(compAddr);
+ var compEnd = mem.ReadPointer(compAddr + 8);
+ if (compBegin == 0 || compEnd <= compBegin)
+ return sb.Append("Companion vector empty").ToString();
+
+ var totalBytes = (int)(compEnd - compBegin);
+ var entrySize = 24; // confirmed: {int64 questIndex, ptr1, ptr2}
+ var entryCount = totalBytes / entrySize;
+ sb.AppendLine($"Companion vector: {entryCount} entries × {entrySize} bytes");
+ sb.AppendLine(new string('═', 100));
+
+ var compData = mem.ReadBytes(compBegin, totalBytes);
+ if (compData is null) return sb.Append("Failed to read companion data").ToString();
+
+ // First pass: read all quest states (ptr2+0x08 = state, ptr2+0x0C = order)
+ sb.AppendLine();
+ sb.AppendLine("All quests (header at ptr2: +0x08=state, +0x0C=value):");
+ sb.AppendLine("─────────────────────────────────────────────────────");
+
+ var activeQuests = new List<(int index, long questId, nint ptr1, nint ptr2, int state, int order)>();
+
+ for (var i = 0; i < entryCount; i++)
+ {
+ var eOff = i * entrySize;
+ if (eOff + entrySize > compData.Length) break;
+
+ var questIndex = BitConverter.ToInt64(compData, eOff);
+ var ptr1 = (nint)BitConverter.ToInt64(compData, eOff + 8);
+ var ptr2 = (nint)BitConverter.ToInt64(compData, eOff + 16);
+
+ int state = 0, order = 0;
+ if (ptr2 != 0 && ((ulong)ptr2 >> 32) is > 0 and < 0x7FFF)
+ {
+ var hdr = mem.ReadBytes(ptr2 + 8, 8);
+ if (hdr is { Length: 8 })
+ {
+ state = BitConverter.ToInt32(hdr, 0);
+ order = BitConverter.ToInt32(hdr, 4);
+ }
+ }
+
+ var marker = state != 2 ? " ◄" : "";
+ sb.AppendLine($" [{questIndex,3}] state={state,-4} val={order,-6}{marker}");
+
+ if (state != 2 && state != 0)
+ activeQuests.Add((i, questIndex, ptr1, ptr2, state, order));
+ }
+
+ // Second pass: dump details for non-completed quests
+ if (activeQuests.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine($"Non-completed quests ({activeQuests.Count}):");
+ sb.AppendLine(new string('═', 100));
+
+ foreach (var (idx, questId, ptr1, ptr2, state, order) in activeQuests)
+ {
+ sb.AppendLine();
+ sb.AppendLine($"Quest [{questId}]: state={state} val={order} ptr1=0x{ptr1:X} ptr2=0x{ptr2:X}");
+
+ // ptr1 contains vtable(8) + array of pointers to QuestStates.dat rows
+ // The state value indexes into these rows: row at ptr1+0x08 + (state-1)*8 ?
+ // Or state is the count/index of the current row.
+ if (ptr1 != 0 && ((ulong)ptr1 >> 32) is > 0 and < 0x7FFF)
+ {
+ // Read enough for vtable + up to 20 row pointers
+ var ptrArraySize = 8 + state * 8 + 16; // extra room
+ var objData = mem.ReadBytes(ptr1, Math.Min(ptrArraySize, 256));
+ if (objData is not null)
+ {
+ // Skip vtable at +0x00, row pointers start at +0x08
+ var rowCount = (objData.Length - 8) / 8;
+ sb.AppendLine($" Row pointers ({rowCount} slots, state={state}):");
+ for (var j = 8; j + 8 <= objData.Length; j += 8)
+ {
+ var rowPtr = (nint)BitConverter.ToInt64(objData, j);
+ var rowIdx = (j - 8) / 8;
+ var marker = rowIdx == state - 1 ? " ◄ current?" : "";
+
+ // Try to read Order and Text from QuestStates.dat row
+ var rowInfo = "";
+ if (rowPtr != 0 && ((ulong)rowPtr >> 32) is > 0 and < 0x7FFF)
+ {
+ var rowData = mem.ReadBytes(rowPtr, 0x40);
+ if (rowData is { Length: >= 0x40 })
+ {
+ var questRef = (nint)BitConverter.ToInt64(rowData, 0); // Quest TableReference
+ var rowOrder = BitConverter.ToInt32(rowData, 0x10);
+ // FlagsPresent at 0x14: ArrayReference = {ptr(8), ptr(8), count?}
+ var flagsBegin = (nint)BitConverter.ToInt64(rowData, 0x14);
+ var flagsEnd = (nint)BitConverter.ToInt64(rowData, 0x1C);
+ var flagCount = flagsBegin != 0 && flagsEnd > flagsBegin ? (int)(flagsEnd - flagsBegin) / 16 : 0;
+ // Text StringReference at 0x34
+ var textPtr = (nint)BitConverter.ToInt64(rowData, 0x34);
+
+ rowInfo = $" quest={questRef & 0xFFFF} order={rowOrder} flags={flagCount}";
+
+ // Try read text
+ if (textPtr != 0 && ((ulong)textPtr >> 32) is > 0 and < 0x7FFF)
+ {
+ var textBytes = mem.ReadBytes(textPtr, 200);
+ if (textBytes is not null)
+ {
+ var text = System.Text.Encoding.Unicode.GetString(textBytes);
+ var nullIdx = text.IndexOf('\0');
+ if (nullIdx >= 0) text = text[..nullIdx];
+ if (text.Length > 0)
+ rowInfo += $" text=\"{text}\"";
+ }
+ }
+ }
+ }
+
+ sb.AppendLine($" [{rowIdx}] 0x{rowPtr:X}{rowInfo}{marker}");
+ }
+ }
+ }
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Scans the quest panel UI element (GameUi[6][1][0][0][0]) for a quest linked list.
+ /// ExileCore uses a doubly-linked list: {Next(8), Prev(8), QuestPtr(8), Unused(8), QuestStateId(1)}.
+ /// We scan all pointer-sized values in the element's memory for linked list patterns.
+ ///
+ public string ScanQuestLinkedList()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+ var sb = new StringBuilder();
+
+ // Resolve UiRootStruct → GameUi
+ var uiRootStruct = mem.ReadPointer(inGameState + offsets.UiRootStructOffset);
+ if (uiRootStruct == 0) return "Error: UiRootStruct null";
+
+ var gameUi = mem.ReadPointer(uiRootStruct + offsets.GameUiPtrOffset);
+ if (gameUi == 0) return "Error: GameUi null";
+
+ sb.AppendLine($"GameUi: 0x{gameUi:X}");
+
+ // Navigate [6][1][0][0][0] to find quest panel
+ var path = new[] { 6, 1, 0, 0, 0 };
+ var current = gameUi;
+ for (var i = 0; i < path.Length; i++)
+ {
+ var vecBegin = mem.ReadPointer(current + offsets.UiElementChildrenOffset);
+ var vecEnd = mem.ReadPointer(current + offsets.UiElementChildrenOffset + 8);
+ if (vecBegin == 0 || vecEnd <= vecBegin)
+ {
+ sb.AppendLine($"Error: children vector invalid at path[{i}], addr=0x{current:X}");
+ return sb.ToString();
+ }
+ var childCount = (int)(vecEnd - vecBegin) / 8;
+ if (path[i] >= childCount)
+ {
+ sb.AppendLine($"Error: index {path[i]} out of range ({childCount} children) at path[{i}]");
+ return sb.ToString();
+ }
+ current = mem.ReadPointer(vecBegin + path[i] * 8);
+ if (current == 0)
+ {
+ sb.AppendLine($"Error: null child at path[{i}]");
+ return sb.ToString();
+ }
+ }
+
+ sb.AppendLine($"Quest panel element: 0x{current:X}");
+
+ // Read a large chunk of the quest panel element to scan for linked list pointers
+ var scanSize = 0x1000;
+ var elemData = mem.ReadBytes(current, scanSize);
+ if (elemData is null) return sb.Append("Failed to read element data").ToString();
+
+ sb.AppendLine($"Scanning 0x{scanSize:X} bytes for linked list patterns...");
+ sb.AppendLine();
+
+ // Build target list: quest panel + all children + grandchildren + key parents
+ var targets = new List<(string name, nint addr)>();
+
+ // Add InGameState, UiRootStruct, GameUi (the list might be on a parent object)
+ targets.Add(("InGameState", inGameState));
+ targets.Add(("UiRootStruct", uiRootStruct));
+ targets.Add(("GameUi", gameUi));
+
+ // Navigate up the path, adding each intermediate element
+ var pathElement = gameUi;
+ for (var i = 0; i < path.Length; i++)
+ {
+ var vb = mem.ReadPointer(pathElement + offsets.UiElementChildrenOffset);
+ var ve = mem.ReadPointer(pathElement + offsets.UiElementChildrenOffset + 8);
+ if (vb == 0 || ve <= vb) break;
+ var cc = (int)(ve - vb) / 8;
+ if (path[i] >= cc) break;
+ pathElement = mem.ReadPointer(vb + path[i] * 8);
+ if (pathElement == 0) break;
+ targets.Add(($"[{string.Join("][", path.Take(i + 1))}]", pathElement));
+ }
+
+ // Add quest panel itself
+ targets.Add(("QuestPanel", current));
+
+ // Add ALL children of quest panel (quest_display elements)
+ void AddChildren(string prefix, nint parentAddr, int maxDepth)
+ {
+ if (maxDepth <= 0) return;
+ var vb2 = mem.ReadPointer(parentAddr + offsets.UiElementChildrenOffset);
+ var ve2 = mem.ReadPointer(parentAddr + offsets.UiElementChildrenOffset + 8);
+ if (vb2 == 0 || ve2 <= vb2) return;
+ var count = Math.Min((int)(ve2 - vb2) / 8, 20);
+ for (var i = 0; i < count; i++)
+ {
+ var childPtr = mem.ReadPointer(vb2 + i * 8);
+ if (childPtr == 0) continue;
+ var childName = $"{prefix}[{i}]";
+ targets.Add((childName, childPtr));
+ AddChildren(childName, childPtr, maxDepth - 1);
+ }
+ }
+ AddChildren("QP", current, 2); // 2 levels deep
+
+ var found = 0;
+ foreach (var (name, addr) in targets)
+ {
+ var data = mem.ReadBytes(addr, scanSize);
+ if (data is null) continue;
+
+ // Scan every 8-byte aligned offset for linked list roots
+ for (var off = 0; off + 8 <= data.Length; off += 8)
+ {
+ var candidatePtr = (nint)BitConverter.ToInt64(data, off);
+ if (candidatePtr == 0 || ((ulong)candidatePtr >> 32) is 0 or >= 0x7FFF)
+ continue;
+
+ // Try to read as a linked list node: {Next(8), Prev(8), Key(8), Unused(8), Value(1)}
+ var nodeData = mem.ReadBytes(candidatePtr, 33);
+ if (nodeData is null) continue;
+
+ var nodeNext = (nint)BitConverter.ToInt64(nodeData, 0);
+ var nodePrev = (nint)BitConverter.ToInt64(nodeData, 8);
+ var nodeKey = (nint)BitConverter.ToInt64(nodeData, 16);
+ var nodeUnused = (nint)BitConverter.ToInt64(nodeData, 24);
+ var nodeValue = nodeData[32];
+
+ // Validate: Next and Prev should be valid heap pointers
+ if (nodeNext == 0 || ((ulong)nodeNext >> 32) is 0 or >= 0x7FFF) continue;
+ if (nodePrev == 0 || ((ulong)nodePrev >> 32) is 0 or >= 0x7FFF) continue;
+
+ // Read the next node to verify back-link
+ var nextNodeData = mem.ReadBytes(nodeNext, 16);
+ if (nextNodeData is null) continue;
+
+ var nextPrev = (nint)BitConverter.ToInt64(nextNodeData, 8);
+
+ // The next node's Prev should point back to our candidate
+ if (nextPrev != candidatePtr) continue;
+
+ // This looks like a linked list! Traverse it
+ found++;
+ sb.AppendLine($"═══ Candidate #{found}: {name}+0x{off:X} → 0x{candidatePtr:X} ═══");
+ sb.AppendLine($" Node: Next=0x{nodeNext:X} Prev=0x{nodePrev:X} Key=0x{nodeKey:X} Val={nodeValue}");
+
+ // Traverse the list (max 200 entries, 2s timeout)
+ var visited = new HashSet { candidatePtr };
+ var entries = new List<(nint key, int val)>();
+ var walk = candidatePtr;
+ var maxWalk = 200;
+
+ while (maxWalk-- > 0)
+ {
+ var nd = mem.ReadBytes(walk, 33);
+ if (nd is null) break;
+
+ var nNext = (nint)BitConverter.ToInt64(nd, 0);
+ var nKey = (nint)BitConverter.ToInt64(nd, 16);
+ var nVal = nd[32];
+
+ if (nKey != 0 && ((ulong)nKey >> 32) is > 0 and < 0x7FFF)
+ entries.Add((nKey, nVal));
+
+ if (nNext == 0 || !visited.Add(nNext) || nNext == candidatePtr)
+ break;
+ walk = nNext;
+ }
+
+ sb.AppendLine($" Traversed {entries.Count} entries (visited {visited.Count} nodes)");
+
+ // Dump entries with quest name resolution
+ foreach (var (key, val) in entries)
+ {
+ var questName = "?";
+ // Try to read Quest.Id: Quest obj → +0x00 → wchar* string
+ var idPtr = mem.ReadPointer(key);
+ if (idPtr != 0 && ((ulong)idPtr >> 32) is > 0 and < 0x7FFF)
+ questName = _strings.ReadNullTermWString(idPtr) ?? "?";
+
+ sb.AppendLine($" [{val,3}] 0x{key:X} {questName}");
+ }
+
+ sb.AppendLine();
+ }
+ }
+
+ if (found == 0)
+ sb.AppendLine("No linked list patterns found in scanned elements.");
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Reads both quest linked lists using saved offsets (GameUi+0x358 for all quests,
+ /// [6][1]+0x318 for tracked quests). Also reads Quest object fields:
+ /// +0x00 → wchar* Id, +0x08 → int Act, +0x0C → wchar* DisplayName.
+ ///
+ public string ReadQuestLinkedLists()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+ var sb = new StringBuilder();
+
+ // Resolve GameUi
+ var uiRootStruct = mem.ReadPointer(inGameState + offsets.UiRootStructOffset);
+ if (uiRootStruct == 0) return "Error: UiRootStruct null";
+
+ var gameUi = mem.ReadPointer(uiRootStruct + offsets.GameUiPtrOffset);
+ if (gameUi == 0) return "Error: GameUi null";
+
+ sb.AppendLine($"GameUi: 0x{gameUi:X}");
+ sb.AppendLine();
+
+ // ── List 1: All quests at GameUi + QuestLinkedListOffset ──
+ sb.AppendLine("═══ ALL QUESTS (GameUi+0x358) ═══");
+ var allHead = mem.ReadPointer(gameUi + offsets.QuestLinkedListOffset);
+ if (allHead == 0)
+ sb.AppendLine(" Head pointer is null");
+ else
+ TraverseQuestList(mem, offsets, sb, allHead, dumpNodeBytes: false);
+
+ sb.AppendLine();
+
+ // ── List 2: Tracked quests at [6][1] + TrackedQuestLinkedListOffset ──
+ sb.AppendLine("═══ TRACKED QUESTS ([6][1]+0x318) — with raw bytes ═══");
+ var elem61 = NavigateChild(mem, offsets, gameUi, offsets.TrackedQuestPanelChildIndex);
+ if (elem61 != 0)
+ elem61 = NavigateChild(mem, offsets, elem61, offsets.TrackedQuestPanelSubChildIndex);
+
+ if (elem61 == 0)
+ sb.AppendLine(" Could not navigate to [6][1] element");
+ else
+ {
+ sb.AppendLine($" [6][1] element: 0x{elem61:X}");
+ var trackedHead = mem.ReadPointer(elem61 + offsets.TrackedQuestLinkedListOffset);
+ if (trackedHead == 0)
+ sb.AppendLine(" Head pointer is null");
+ else
+ TraverseQuestList(mem, offsets, sb, trackedHead, dumpNodeBytes: true);
+ }
+
+ return sb.ToString();
+ }
+
+ private nint NavigateChild(ProcessMemory mem, GameOffsets offsets, nint parent, int childIndex)
+ {
+ var vecBegin = mem.ReadPointer(parent + offsets.UiElementChildrenOffset);
+ var vecEnd = mem.ReadPointer(parent + offsets.UiElementChildrenOffset + 8);
+ if (vecBegin == 0 || vecEnd <= vecBegin) return 0;
+ var childCount = (int)(vecEnd - vecBegin) / 8;
+ if (childIndex >= childCount) return 0;
+ return mem.ReadPointer(vecBegin + childIndex * 8);
+ }
+
+ private void TraverseQuestList(ProcessMemory mem, GameOffsets offsets, StringBuilder sb, nint headPtr, bool dumpNodeBytes = false)
+ {
+ sb.AppendLine($" Head: 0x{headPtr:X}");
+
+ var visited = new HashSet();
+ var walk = headPtr;
+ var count = 0;
+ var isSentinel = true; // first node is sentinel/header
+ var maxNodes = offsets.QuestLinkedListMaxNodes;
+
+ while (count < maxNodes + 1) // +1 for sentinel
+ {
+ if (walk == 0 || !visited.Add(walk)) break;
+
+ // Read extra bytes to see the full node area (48 bytes to cover possible wider layout)
+ var readSize = Math.Max(offsets.QuestLinkedListNodeSize, 48);
+ var nodeData = mem.ReadBytes(walk, readSize);
+ if (nodeData is null) break;
+
+ var next = (nint)BitConverter.ToInt64(nodeData, 0);
+ var prev = (nint)BitConverter.ToInt64(nodeData, 8);
+ var questPtr = (nint)BitConverter.ToInt64(nodeData, offsets.QuestNodeQuestPtrOffset);
+
+ if (isSentinel)
+ {
+ sb.AppendLine($" (sentinel node skipped, next=0x{next:X})");
+ isSentinel = false;
+ walk = next;
+ continue;
+ }
+
+ // State is int32 at +0x20 in node (not byte): 0=done, -1=locked, other=in-progress
+ var stateInt = BitConverter.ToInt32(nodeData, offsets.QuestNodeStateIdOffset);
+
+ // Read Quest.dat row fields (non-aligned layout)
+ // +0x00: ptr(8) → wchar* Id, +0x08: int32(4) Act, +0x0C: ptr(8) → wchar* Name
+ var questId = "?";
+ var questName = "?";
+ var act = -1;
+
+ if (questPtr != 0 && ((ulong)questPtr >> 32) is > 0 and < 0x7FFF)
+ {
+ // +0x00 → pointer → wchar* Id
+ var idPtr = mem.ReadPointer(questPtr + offsets.QuestObjNamePtrOffset);
+ if (idPtr != 0 && ((ulong)idPtr >> 32) is > 0 and < 0x7FFF)
+ questId = _strings.ReadNullTermWString(idPtr) ?? "?";
+
+ // +0x08 → int32 Act
+ act = mem.Read(questPtr + offsets.QuestObjActOffset);
+
+ // +0x0C → pointer → wchar* DisplayName (NOT 8-byte aligned!)
+ var namePtr = mem.ReadPointer(questPtr + offsets.QuestObjDisplayNameOffset);
+ if (namePtr != 0 && ((ulong)namePtr >> 32) is > 0 and < 0x7FFF)
+ questName = _strings.ReadNullTermWString(namePtr) ?? "?";
+ }
+
+ var stateLabel = stateInt switch
+ {
+ 0 => "DONE",
+ -1 => "LOCKED",
+ _ => $"state={stateInt}"
+ };
+
+ sb.AppendLine($" [{stateLabel,8}] Act{act} \"{questName}\" id={questId}");
+
+ // For tracked list: dump raw node bytes to find where the correct state lives
+ if (dumpNodeBytes)
+ {
+ var bigNode = mem.ReadBytes(walk, 128);
+ if (bigNode is not null)
+ {
+ for (var line = 0; line < 128; line += 16)
+ {
+ sb.Append($" node 0x{line:X2}: ");
+ for (var b = line; b < line + 16 && b < 128; b++)
+ sb.Append($"{bigNode[b]:X2} ");
+ // ASCII on the right
+ sb.Append(" ");
+ for (var b = line; b < line + 16 && b < 128; b++)
+ {
+ var c = (char)bigNode[b];
+ sb.Append(c >= 0x20 && c < 0x7F ? c : '.');
+ }
+ sb.AppendLine();
+ }
+ // Follow pointer at +0x20 (tracked list has a quest-state object here, not an int)
+ var trackedObjPtr = (nint)BitConverter.ToInt64(bigNode, 0x20);
+ if (trackedObjPtr != 0 && ((ulong)trackedObjPtr >> 32) is > 0 and < 0x7FFF)
+ {
+ sb.AppendLine($" +0x20 obj ptr: 0x{trackedObjPtr:X}");
+ var objData = mem.ReadBytes(trackedObjPtr, 128);
+ if (objData is not null)
+ {
+ for (var line = 0; line < 64; line += 16)
+ {
+ sb.Append($" obj 0x{line:X2}: ");
+ for (var b = line; b < line + 16 && b < 64; b++)
+ sb.Append($"{objData[b]:X2} ");
+ sb.AppendLine();
+ }
+ // Show small int32 values (likely state IDs)
+ sb.Append(" int32s: ");
+ for (var off2 = 0; off2 + 4 <= 128; off2 += 4)
+ {
+ var val2 = BitConverter.ToInt32(objData, off2);
+ if (val2 != 0 && val2 > -1000 && val2 < 1000)
+ sb.Append($"+0x{off2:X2}={val2} ");
+ }
+ sb.AppendLine();
+ // Try text pointers in the object (show even 1-char strings)
+ for (var off2 = 0; off2 + 8 <= 128; off2 += 4)
+ {
+ var ptr = (nint)BitConverter.ToInt64(objData, off2);
+ if (ptr == 0 || ((ulong)ptr >> 32) is 0 or >= 0x7FFF) continue;
+ var text = _strings.ReadNullTermWString(ptr);
+ if (!string.IsNullOrEmpty(text))
+ sb.AppendLine($" obj+0x{off2:X2} → \"{text}\"");
+ }
+ // Also try wstring at key offsets
+ foreach (var wsOff in new[] { 0x1C, 0x34 })
+ {
+ var ws = ReadUiWString(mem, trackedObjPtr + wsOff);
+ if (!string.IsNullOrEmpty(ws))
+ sb.AppendLine($" obj+0x{wsOff:X2} wstr → \"{ws}\"");
+ }
+ }
+ }
+
+ // Also follow +0x28 pointer
+ var trackedObj2Ptr = (nint)BitConverter.ToInt64(bigNode, 0x28);
+ if (trackedObj2Ptr != 0 && ((ulong)trackedObj2Ptr >> 32) is > 0 and < 0x7FFF)
+ {
+ sb.AppendLine($" +0x28 obj ptr: 0x{trackedObj2Ptr:X}");
+ var obj2Data = mem.ReadBytes(trackedObj2Ptr, 64);
+ if (obj2Data is not null)
+ {
+ for (var line = 0; line < 64; line += 16)
+ {
+ sb.Append($" obj 0x{line:X2}: ");
+ for (var b = line; b < line + 16 && b < 64; b++)
+ sb.Append($"{obj2Data[b]:X2} ");
+ sb.AppendLine();
+ }
+ }
+ }
+ }
+ }
+
+ // For in-progress quests (all-quests list), check if node has a quest state object ptr
+ // The all-quests node layout: +0x00 next, +0x08 prev, +0x10 questPtr, +0x18 sharedPtr, +0x20 stateId(int32)
+ // Beyond +0x24, there might be more data. Read 128 bytes to probe.
+ if (!dumpNodeBytes && stateInt > 0)
+ {
+ var bigNode = mem.ReadBytes(walk, 128);
+ if (bigNode is not null)
+ {
+ sb.AppendLine($" stateId={stateInt} Scanning all-quests node for state object...");
+ // Check pointers at +0x28 onwards (after stateId at +0x20..+0x23, +0x24 might be padding)
+ for (var off = 0x24; off + 8 <= 128; off += 4)
+ {
+ var ptr = (nint)BitConverter.ToInt64(bigNode, off);
+ if (ptr == 0 || ((ulong)ptr >> 32) is 0 or >= 0x7FFF) continue;
+
+ // Check if this ptr leads to an object with questPtr at +0x00 and stateId at +0x10
+ var candidate = mem.ReadBytes(ptr, 64);
+ if (candidate is null) continue;
+ var candQuestPtr = (nint)BitConverter.ToInt64(candidate, 0);
+ var candStateId = BitConverter.ToInt32(candidate, 0x10);
+ if (candQuestPtr == questPtr && candStateId == stateInt)
+ {
+ sb.AppendLine($" node+0x{off:X2} → quest state obj at 0x{ptr:X} (stateId={candStateId} ✓)");
+ // Read text at +0x34
+ var textPtr = (nint)BitConverter.ToInt64(candidate, 0x34);
+ if (textPtr != 0 && ((ulong)textPtr >> 32) is > 0 and < 0x7FFF)
+ {
+ var text = _strings.ReadNullTermWString(textPtr);
+ if (!string.IsNullOrEmpty(text))
+ sb.AppendLine($" text: \"{text}\"");
+ }
+ break;
+ }
+ }
+ // Also dump node raw bytes +0x20..+0x60 for reference
+ sb.Append(" node raw[0x20..0x60]: ");
+ for (var b = 0x20; b < 0x60; b++)
+ {
+ if (b > 0x20 && (b - 0x20) % 16 == 0) sb.Append("\n ");
+ sb.Append($"{bigNode[b]:X2} ");
+ }
+ sb.AppendLine();
+ }
+ }
+
+ count++;
+ walk = next;
+ }
+
+ sb.AppendLine($" Total: {count} entries");
+ }
}
diff --git a/src/Roboto.Memory/GameMemoryReader.cs b/src/Roboto.Memory/GameMemoryReader.cs
index bccab1f..075f109 100644
--- a/src/Roboto.Memory/GameMemoryReader.cs
+++ b/src/Roboto.Memory/GameMemoryReader.cs
@@ -44,6 +44,7 @@ public class GameMemoryReader : IDisposable
public MemoryContext? Context => _ctx;
public ComponentReader? Components => _components;
public GameStateReader? StateReader => _stateReader;
+ public UIElements? UIElements => _gameStates?.InGame.UIElements;
public GameMemoryReader()
{
@@ -166,7 +167,15 @@ public class GameMemoryReader : IDisposable
var gs = _gameStates!;
// Set loading state on terrain before cascade
- gs.InGame.AreaInstance.SetLoadingState(gs.AreaLoading.IsLoading);
+ // Use controller-based check (controller+IsLoadingOffset == InGameState → not loading)
+ // instead of AreaLoading slot which has a stale hardcoded offset (0x660)
+ var isLoadingForTerrain = false;
+ if (_lastController != 0 && _lastInGameState != 0 && _ctx.Offsets.IsLoadingOffset > 0)
+ {
+ var loadPtr = _ctx.Memory.ReadPointer(_lastController + _ctx.Offsets.IsLoadingOffset);
+ isLoadingForTerrain = loadPtr != 0 && loadPtr != _lastInGameState;
+ }
+ gs.InGame.AreaInstance.SetLoadingState(isLoadingForTerrain);
if (!gs.Update())
return snap;
@@ -179,7 +188,7 @@ public class GameMemoryReader : IDisposable
snap.CurrentGameState = gs.CurrentState;
snap.ControllerPreSlots = gs.ControllerPreSlots;
snap.InGameStatePtr = gs.InGame.Address;
- snap.IsLoading = gs.AreaLoading.IsLoading;
+ snap.IsLoading = isLoadingForTerrain; // use controller-based check, not broken AreaLoading.IsLoading
snap.IsEscapeOpen = gs.InGame.IsEscapeOpen;
snap.AreaInstancePtr = ai.Address;
snap.ServerDataPtr = ai.ServerDataPtr;
@@ -249,6 +258,7 @@ public class GameMemoryReader : IDisposable
// Skills & quests — read from hierarchy
snap.PlayerSkills = ai.PlayerSkills.Skills;
snap.QuestFlags = ai.QuestFlags.Quests;
+ snap.QuestStates = ai.QuestStates;
// Read state flag bytes
if (snap.InGameStatePtr != 0)
@@ -261,6 +271,15 @@ public class GameMemoryReader : IDisposable
snap.TerrainHeight = ai.Terrain.TerrainHeight;
snap.Terrain = ai.Terrain.Grid;
snap.TerrainWalkablePercent = ai.Terrain.WalkablePercent;
+
+ // UI tree — root pointer only; tree is read lazily on-demand
+ snap.GameUiPtr = gs.InGame.UIElements.GameUiPtr;
+
+ // Quest linked lists (all quests + tracked merged)
+ snap.QuestLinkedList = gs.InGame.UIElements.ReadQuestLinkedLists();
+
+ // Quest groups from UI element tree
+ snap.UiQuestGroups = gs.InGame.UIElements.ReadQuestGroups();
}
}
catch (Exception ex)
diff --git a/src/Roboto.Memory/GameOffsets.cs b/src/Roboto.Memory/GameOffsets.cs
index bfe6dc5..4e02dc2 100644
--- a/src/Roboto.Memory/GameOffsets.cs
+++ b/src/Roboto.Memory/GameOffsets.cs
@@ -183,15 +183,41 @@ public sealed class GameOffsets
public uint QuestTrackedMarker { get; set; } = 0x43020000;
/// Offset within the quest state object to the encounter state byte (1=locked, 2=started). 0x08.
public int QuestObjEncounterStateOffset { get; set; } = 0x08;
+ /// Offset within quest state object to QuestStateId int32. 0 = disabled (use ScanQuestStateOffsets to discover).
+ public int QuestObjStateIdOffset { get; set; } = 0;
+ /// Offset within quest state object to state text. Interpretation depends on QuestObjStateTextType.
+ public int QuestObjStateTextOffset { get; set; } = 0;
+ /// Offset within quest state object to progress text. Interpretation depends on QuestObjStateTextType.
+ public int QuestObjProgressTextOffset { get; set; } = 0;
+ /// Offset within quest state object to Quest pointer (follow → +0x00 for quest name wchar*). 0 = disabled.
+ public int QuestObjQuestPtrOffset { get; set; } = 0;
+ /// How to read state/progress text: "wchar_ptr" = direct pointer to wchar*, "std_wstring" = inline MSVC std::wstring (32 bytes).
+ public string QuestObjStateTextType { get; set; } = "wchar_ptr";
+
+ // ── Quest state container (InGameState → WorldData-like object → vector of 12-byte entries) ──
+ /// AreaInstance → quest state sub-object pointer. Discovered via ScanQuestStateContainers: 0x900.
+ public int QuestStateObjectOffset { get; set; } = 0x900;
+ /// Quest state container → StdVector of 12-byte {questId, state, flags} entries.
+ public int QuestStateVectorOffset { get; set; } = 0x240;
+ /// Size of each quest state entry in bytes.
+ public int QuestStateEntrySize { get; set; } = 12;
+ /// Maximum number of quest state entries to read (sanity limit).
+ public int QuestStateMaxEntries { get; set; } = 256;
// ── QuestStates.dat row layout (119 bytes, non-aligned fields) ──
- /// Size of each .dat row in bytes. 0x77 = 119. 0 = name resolution disabled.
- public int QuestDatRowSize { get; set; } = 0x77;
- /// Dat row → Quest display name wchar* pointer.
+ /// Size of each .dat row in bytes. 0x68 = 104 (confirmed via CE imul stride). 0 = name resolution disabled.
+ public int QuestDatRowSize { get; set; } = 0x68;
+ /// Dat row → Quest TableReference (16 bytes: pointer to Quest.dat row at +0x00). Follow Quest.dat row → +0x00 for name wchar*.
public int QuestDatNameOffset { get; set; } = 0x00;
- /// Dat row → Internal quest ID wchar* pointer (e.g. "TreeOfSouls2").
+ /// Dat row → Order int32 (at offset 16 / 0x10).
+ public int QuestDatOrderOffset { get; set; } = 0x10;
+ /// Dat row → Text StringReference (quest state text). Offset 52 / 0x34.
+ public int QuestDatTextOffset { get; set; } = 0x34;
+ /// Dat row → Message StringReference. Offset 61 / 0x3D.
+ public int QuestDatMessageOffset { get; set; } = 0x3D;
+ /// Dat row → Internal quest ID wchar* pointer (legacy, may need update).
public int QuestDatInternalIdOffset { get; set; } = 0x6B;
- /// Dat row → Act/phase number int32.
+ /// Dat row → Act/phase number int32 (legacy, may need update).
public int QuestDatActOffset { get; set; } = 0x73;
// ── Entity / Component ──
@@ -283,6 +309,39 @@ public sealed class GameOffsets
/// How many bytes to scan from InGameState for UIElement pointers (0x1000 = 4KB).
public int UiElementScanRange { get; set; } = 0x1000;
+ // ── Quest Linked Lists (ExileCore-style, on GameUi UIElement tree) ──
+ // Node layout: Next(8) + Prev(8) + QuestPtr(8) + Unused(8) + QuestStateId(1) = 33 bytes
+ // QuestPtr → +0x00 → wchar* internal quest ID (e.g. "TreeOfSouls2")
+ // QuestStateId: 0=completed, 255=not started/locked, other=in progress
+
+ /// Offset from GameUi UIElement to the full quest linked list head pointer. All quests (117 entries). 0x358.
+ public int QuestLinkedListOffset { get; set; } = 0x358;
+ /// Size of each linked list node in bytes. At least 40: Next(8)+Prev(8)+QuestPtr(8)+SharedPtr(8)+StateId(4)+extra(4).
+ public int QuestLinkedListNodeSize { get; set; } = 40;
+ /// Offset within linked list node to the Quest object pointer. 0x10.
+ public int QuestNodeQuestPtrOffset { get; set; } = 0x10;
+ /// Offset within linked list node to the QuestStateId byte. 0x20.
+ public int QuestNodeStateIdOffset { get; set; } = 0x20;
+ /// Offset from Quest.dat row to ptr→wchar* internal ID. 0x00.
+ public int QuestObjNamePtrOffset { get; set; } = 0x00;
+ /// Offset from Quest.dat row to int32 Act number. 0x08.
+ public int QuestObjActOffset { get; set; } = 0x08;
+ /// Offset from Quest.dat row to ptr→wchar* display name. 0x0C (NOT 8-byte aligned). ExileCore confirmed.
+ public int QuestObjDisplayNameOffset { get; set; } = 0x0C;
+ /// Offset from Quest.dat row to ptr→wchar* icon path. 0x14.
+ public int QuestObjIconOffset { get; set; } = 0x14;
+ /// Maximum nodes to traverse (sanity limit).
+ public int QuestLinkedListMaxNodes { get; set; } = 256;
+ /// Offset within the tracked quest's runtime state object to the objective text (std::wstring). 0x34.
+ public int QuestStateObjTextOffset { get; set; } = 0x34;
+
+ /// GameUi child index for the quest panel parent element (child[6]).
+ public int TrackedQuestPanelChildIndex { get; set; } = 6;
+ /// Sub-child index within quest panel parent (child[6][1]).
+ public int TrackedQuestPanelSubChildIndex { get; set; } = 1;
+ /// Offset from the [6][1] element to the tracked/active quest linked list. Same node layout. 0x318.
+ public int TrackedQuestLinkedListOffset { get; set; } = 0x318;
+
// ── Terrain (inline in AreaInstance) ──
/// Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).
public int TerrainListOffset { get; set; } = 0xCC0;
diff --git a/src/Roboto.Memory/Objects/AreaInstance.cs b/src/Roboto.Memory/Objects/AreaInstance.cs
index b7b3197..f992ad2 100644
--- a/src/Roboto.Memory/Objects/AreaInstance.cs
+++ b/src/Roboto.Memory/Objects/AreaInstance.cs
@@ -15,12 +15,12 @@ public sealed class AreaInstance : RemoteObject
public nint ServerDataPtr { get; private set; }
public nint LocalPlayerPtr { get; private set; }
public int EntityCount { get; private set; }
-
public EntityList EntityList { get; }
public PlayerSkills PlayerSkills { get; }
public QuestFlags QuestFlags { get; }
public Terrain Terrain { get; }
public AreaTemplate AreaTemplate { get; }
+ public List? QuestStates { get; private set; }
public AreaInstance(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
: base(ctx)
@@ -101,6 +101,9 @@ public sealed class AreaInstance : RemoteObject
else
QuestFlags.Reset();
+ // Quest state container (AI+0x900 → obj → +0x240 vector)
+ QuestStates = ReadQuestStates(mem, offsets);
+
// AreaTemplate — pointer at AreaInstance + AreaTemplateOffset
var areaTemplatePtr = mem.ReadPointer(Address + offsets.AreaTemplateOffset);
if (areaTemplatePtr != 0)
@@ -131,6 +134,45 @@ public sealed class AreaInstance : RemoteObject
Terrain.InvalidateCache();
}
+ private List? ReadQuestStates(ProcessMemory mem, GameOffsets offsets)
+ {
+ if (offsets.QuestStateObjectOffset <= 0 || offsets.QuestStateVectorOffset <= 0)
+ return null;
+
+ var objPtr = mem.ReadPointer(Address + offsets.QuestStateObjectOffset);
+ if (objPtr == 0 || ((ulong)objPtr >> 32) is 0 or >= 0x7FFF)
+ return null;
+
+ var vecAddr = objPtr + offsets.QuestStateVectorOffset;
+ var vecBegin = mem.ReadPointer(vecAddr);
+ var vecEnd = mem.ReadPointer(vecAddr + 8);
+ if (vecBegin == 0 || vecEnd <= vecBegin) return null;
+
+ var totalBytes = (int)(vecEnd - vecBegin);
+ var entrySize = offsets.QuestStateEntrySize;
+ if (totalBytes % entrySize != 0) return null;
+
+ var entryCount = totalBytes / entrySize;
+ if (entryCount <= 0 || entryCount > offsets.QuestStateMaxEntries) return null;
+
+ var data = mem.ReadBytes(vecBegin, totalBytes);
+ if (data is null) return null;
+
+ var result = new List(entryCount);
+ for (var i = 0; i < entryCount; i++)
+ {
+ var off = i * entrySize;
+ result.Add(new QuestStateEntry
+ {
+ QuestId = BitConverter.ToInt32(data, off),
+ State = BitConverter.ToInt32(data, off + 4),
+ Flags = BitConverter.ToInt32(data, off + 8),
+ });
+ }
+
+ return result;
+ }
+
protected override void Clear()
{
AreaLevel = 0;
@@ -138,6 +180,7 @@ public sealed class AreaInstance : RemoteObject
ServerDataPtr = 0;
LocalPlayerPtr = 0;
EntityCount = 0;
+ QuestStates = null;
EntityList.Reset();
PlayerSkills.Reset();
QuestFlags.Reset();
diff --git a/src/Roboto.Memory/Objects/InGameState.cs b/src/Roboto.Memory/Objects/InGameState.cs
index 0298cd5..81ff307 100644
--- a/src/Roboto.Memory/Objects/InGameState.cs
+++ b/src/Roboto.Memory/Objects/InGameState.cs
@@ -14,17 +14,20 @@ public sealed class InGameState : RemoteObject
public bool IsEscapeOpen { get; private set; }
public AreaInstance AreaInstance { get; }
public WorldData WorldData { get; }
+ public UIElements UIElements { get; }
public InGameState(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
: base(ctx)
{
AreaInstance = new AreaInstance(ctx, components, strings, questNames);
WorldData = new WorldData(ctx);
+ UIElements = new UIElements(ctx, strings);
}
protected override bool ReadData()
{
var mem = Ctx.Memory;
+ var offsets = Ctx.Offsets;
// Read the full InGameState struct (0x310 = 784 bytes, 1 RPM)
_data = mem.Read(Address);
@@ -39,6 +42,9 @@ public sealed class InGameState : RemoteObject
WorldData.FallbackCameraPtr = _data.CameraPtr;
WorldData.Update(_data.WorldDataPtr);
+ // Cascade to UIElements — pass InGameState address for UiRootStruct chain
+ UIElements.Update(Address);
+
return true;
}
@@ -48,5 +54,6 @@ public sealed class InGameState : RemoteObject
IsEscapeOpen = false;
AreaInstance.Reset();
WorldData.Reset();
+ UIElements.Reset();
}
}
diff --git a/src/Roboto.Memory/Objects/QuestFlags.cs b/src/Roboto.Memory/Objects/QuestFlags.cs
index 2ac928e..6dc3847 100644
--- a/src/Roboto.Memory/Objects/QuestFlags.cs
+++ b/src/Roboto.Memory/Objects/QuestFlags.cs
@@ -94,6 +94,7 @@ public sealed class QuestFlags : RemoteObject
}
var datTableBase = FindDatTableBase(offsets);
+ var useStdWString = offsets.QuestObjStateTextType == "std_wstring";
var result = new List(entryCount);
@@ -105,6 +106,8 @@ public sealed class QuestFlags : RemoteObject
byte stateId = 0;
bool isTracked = false;
nint questObjPtr = 0;
+ string? stateText = null;
+ string? progressText = null;
if (compData is not null && i < compEntryCount)
{
@@ -121,26 +124,51 @@ public sealed class QuestFlags : RemoteObject
{
questObjPtr = (nint)BitConverter.ToInt64(compData, compOff + offsets.QuestCompanionObjPtrOffset);
- if (questObjPtr != 0 && ((ulong)questObjPtr >> 32) is > 0 and < 0x7FFF
- && offsets.QuestObjEncounterStateOffset > 0)
+ if (questObjPtr != 0 && ((ulong)questObjPtr >> 32) is > 0 and < 0x7FFF)
{
- var stateByte = mem.ReadBytes(questObjPtr + offsets.QuestObjEncounterStateOffset, 1);
- if (stateByte is { Length: 1 })
- stateId = stateByte[0];
+ if (offsets.QuestObjEncounterStateOffset > 0)
+ {
+ var stateByte = mem.ReadBytes(questObjPtr + offsets.QuestObjEncounterStateOffset, 1);
+ if (stateByte is { Length: 1 })
+ stateId = stateByte[0];
+ }
+
+ // Read state text from quest state object
+ if (offsets.QuestObjStateTextOffset > 0)
+ stateText = ReadQuestObjString(questObjPtr + offsets.QuestObjStateTextOffset, useStdWString);
+
+ // Read progress text from quest state object
+ if (offsets.QuestObjProgressTextOffset > 0)
+ progressText = ReadQuestObjString(questObjPtr + offsets.QuestObjProgressTextOffset, useStdWString);
+
+ // Read quest name via QuestPtr → +0x00 wchar*
+ if (offsets.QuestObjQuestPtrOffset > 0 && questName is null)
+ {
+ var questPtr = mem.ReadPointer(questObjPtr + offsets.QuestObjQuestPtrOffset);
+ if (questPtr != 0 && ((ulong)questPtr >> 32) is > 0 and < 0x7FFF)
+ {
+ var namePtr = mem.ReadPointer(questPtr);
+ if (namePtr != 0)
+ questName = _strings.ReadNullTermWString(namePtr);
+ }
+ }
}
}
}
- if (datTableBase != 0 && offsets.QuestDatRowSize > 0)
+ if (questName is null)
{
- var rowAddr = datTableBase + idx * offsets.QuestDatRowSize;
- questName = ResolveDatString(rowAddr + offsets.QuestDatNameOffset);
- internalId = ResolveDatString(rowAddr + offsets.QuestDatInternalIdOffset);
- }
- else if (_nameLookup is not null && _nameLookup.TryGet(idx, out var entry))
- {
- questName = entry?.Name;
- internalId = entry?.InternalId;
+ if (datTableBase != 0 && offsets.QuestDatRowSize > 0)
+ {
+ var rowAddr = datTableBase + idx * offsets.QuestDatRowSize;
+ questName = ResolveDatString(rowAddr + offsets.QuestDatNameOffset);
+ internalId = ResolveDatString(rowAddr + offsets.QuestDatInternalIdOffset);
+ }
+ else if (_nameLookup is not null && _nameLookup.TryGet(idx, out var entry))
+ {
+ questName = entry?.Name;
+ internalId = entry?.InternalId;
+ }
}
result.Add(new QuestSnapshot
@@ -151,12 +179,26 @@ public sealed class QuestFlags : RemoteObject
InternalId = internalId,
StateId = stateId,
IsTracked = isTracked,
+ StateText = stateText,
+ ProgressText = progressText,
});
}
return result;
}
+ /// Reads a string from a quest state object field, either as wchar* pointer or MSVC std::wstring.
+ private string? ReadQuestObjString(nint fieldAddr, bool stdWString)
+ {
+ var mem = Ctx.Memory;
+ if (stdWString)
+ return _strings.ReadMsvcWString(fieldAddr);
+
+ var strPtr = mem.ReadPointer(fieldAddr);
+ if (strPtr == 0 || ((ulong)strPtr >> 32) is 0 or >= 0x7FFF) return null;
+ return _strings.ReadNullTermWString(strPtr);
+ }
+
private nint FindDatTableBase(GameOffsets offsets)
{
if (offsets.QuestDatRowSize <= 0) return 0;
diff --git a/src/Roboto.Memory/Objects/UIElements.cs b/src/Roboto.Memory/Objects/UIElements.cs
new file mode 100644
index 0000000..ce86000
--- /dev/null
+++ b/src/Roboto.Memory/Objects/UIElements.cs
@@ -0,0 +1,528 @@
+using System.Text;
+
+namespace Roboto.Memory.Objects;
+
+///
+/// Reads the UIElement tree from InGameState → UiRootStruct → GameUi.
+/// Fully lazy: ReadData() only resolves root pointers (2 RPM).
+/// Tree nodes are read on-demand via ReadNode/ReadChildren from the UI layer.
+///
+public sealed class UIElements : RemoteObject
+{
+ // Bulk-read covers offsets 0x00 through 0x468 (Text std::wstring at 0x448 + 32 bytes).
+ private const int BulkReadSize = 0x468;
+
+ /// Maximum children to read per node (safety limit).
+ public int MaxChildrenPerNode { get; set; } = 200;
+
+ public nint UiRootPtr { get; private set; }
+ public nint GameUiPtr { get; private set; }
+
+ private readonly MsvcStringReader _strings;
+
+ public UIElements(MemoryContext ctx, MsvcStringReader strings) : base(ctx)
+ {
+ _strings = strings;
+ }
+
+ protected override bool ReadData()
+ {
+ var mem = Ctx.Memory;
+ var offsets = Ctx.Offsets;
+
+ // Address = InGameState pointer
+ // Follow: InGameState+UiRootStructOffset → UiRootStruct ptr
+ var uiRootStructPtr = mem.ReadPointer(Address + offsets.UiRootStructOffset);
+ if (uiRootStructPtr == 0 || (ulong)uiRootStructPtr >> 32 is 0 or >= 0x7FFF)
+ {
+ UiRootPtr = 0;
+ GameUiPtr = 0;
+ return true;
+ }
+
+ // UiRootStruct → UiRoot and GameUi element pointers
+ UiRootPtr = mem.ReadPointer(uiRootStructPtr + offsets.UiRootPtrOffset);
+ GameUiPtr = mem.ReadPointer(uiRootStructPtr + offsets.GameUiPtrOffset);
+
+ return true;
+ }
+
+ ///
+ /// Reads a single UIElement node (1 bulk RPM). Returns null if address is invalid.
+ /// Does NOT read children — call ReadChildren separately.
+ ///
+ public UIElementNode? ReadNode(nint addr)
+ {
+ if (addr == 0 || (ulong)addr >> 32 is 0 or >= 0x7FFF)
+ return null;
+
+ var mem = Ctx.Memory;
+ var offsets = Ctx.Offsets;
+
+ var buf = mem.ReadBytes(addr, BulkReadSize);
+ if (buf is null || buf.Length < BulkReadSize)
+ return null;
+
+ // Self-check validation
+ var selfPtr = (nint)BitConverter.ToInt64(buf, offsets.UiElementSelfOffset);
+ if (selfPtr != addr)
+ return null;
+
+ var stringId = ParseWStringFromBuffer(buf, offsets.UiElementStringIdOffset);
+ var text = ParseWStringFromBuffer(buf, offsets.UiElementTextOffset);
+
+ var flags = BitConverter.ToUInt32(buf, offsets.UiElementFlagsOffset);
+ var isVisible = (flags & (1u << offsets.UiElementVisibleBit)) != 0;
+
+ var width = BitConverter.ToSingle(buf, offsets.UiElementSizeOffset);
+ var height = BitConverter.ToSingle(buf, offsets.UiElementSizeOffset + 4);
+
+ // Children count (don't read children themselves)
+ var childCount = 0;
+ var vecBegin = (nint)BitConverter.ToInt64(buf, offsets.UiElementChildrenOffset);
+ var vecEnd = (nint)BitConverter.ToInt64(buf, offsets.UiElementChildrenOffset + 8);
+ if (vecBegin != 0 && vecEnd > vecBegin)
+ {
+ childCount = (int)(vecEnd - vecBegin) / 8;
+ if (childCount > 10000) childCount = 0;
+ }
+
+ return new UIElementNode
+ {
+ Address = addr,
+ StringId = stringId,
+ Text = text,
+ IsVisible = isVisible,
+ Width = width,
+ Height = height,
+ ChildCount = childCount,
+ Children = null,
+ };
+ }
+
+ ///
+ /// On-demand: reads immediate children of a node address.
+ /// Each child is a shallow UIElementNode (no grandchildren).
+ ///
+ public List? ReadChildren(nint nodeAddr)
+ {
+ if (nodeAddr == 0) return null;
+
+ var mem = Ctx.Memory;
+ var offsets = Ctx.Offsets;
+
+ var vecBegin = mem.ReadPointer(nodeAddr + offsets.UiElementChildrenOffset);
+ var vecEnd = mem.ReadPointer(nodeAddr + offsets.UiElementChildrenOffset + 8);
+ if (vecBegin == 0 || vecEnd <= vecBegin) return null;
+
+ var childCount = (int)(vecEnd - vecBegin) / 8;
+ if (childCount <= 0 || childCount > 10000) return null;
+
+ var count = Math.Min(childCount, MaxChildrenPerNode);
+ var ptrData = mem.ReadBytes(vecBegin, count * 8);
+ if (ptrData is null) return null;
+
+ var result = new List(count);
+ for (var i = 0; i < count; i++)
+ {
+ var childPtr = (nint)BitConverter.ToInt64(ptrData, i * 8);
+ var child = ReadNode(childPtr);
+ if (child is not null)
+ result.Add(child);
+ }
+
+ return result;
+ }
+
+ ///
+ /// On-demand text read for a specific node address (1-2 RPM).
+ ///
+ public string? ReadNodeText(nint nodeAddr)
+ {
+ if (nodeAddr == 0) return null;
+ return ReadUiWString(nodeAddr + Ctx.Offsets.UiElementTextOffset);
+ }
+
+ ///
+ /// Reads the Nth child of a node (0-indexed). Returns null if out of range or invalid.
+ ///
+ public UIElementNode? ReadChildAtIndex(nint nodeAddr, int index)
+ {
+ if (nodeAddr == 0 || index < 0) return null;
+
+ var mem = Ctx.Memory;
+ var offsets = Ctx.Offsets;
+
+ var vecBegin = mem.ReadPointer(nodeAddr + offsets.UiElementChildrenOffset);
+ var vecEnd = mem.ReadPointer(nodeAddr + offsets.UiElementChildrenOffset + 8);
+ if (vecBegin == 0 || vecEnd <= vecBegin) return null;
+
+ var childCount = (int)(vecEnd - vecBegin) / 8;
+ if (index >= childCount) return null;
+
+ var childPtr = mem.ReadPointer(vecBegin + index * 8);
+ return ReadNode(childPtr);
+ }
+
+ ///
+ /// Navigates a path of child indices from a starting node address.
+ /// e.g. NavigatePath(gameUiPtr, [6, 1, 0, 0, 0, 2]) → root[6][1][0][0][0][2]
+ ///
+ public UIElementNode? NavigatePath(nint startAddr, ReadOnlySpan path)
+ {
+ var current = startAddr;
+ UIElementNode? node = null;
+
+ foreach (var idx in path)
+ {
+ node = ReadChildAtIndex(current, idx);
+ if (node is null) return null;
+ current = node.Address;
+ }
+
+ return node;
+ }
+
+ ///
+ /// Reads quest groups from the UI tree.
+ /// Path: GameUi[6][1][0][0][0] → quest_display → [0] → title_layout/quest_info_layout
+ ///
+ public List? ReadQuestGroups()
+ {
+ if (GameUiPtr == 0) return null;
+
+ // Navigate to the parent that holds quest_display nodes
+ var questParent = NavigatePath(GameUiPtr, [6, 1, 0, 0, 0]);
+ if (questParent is null) return null;
+
+ var questDisplays = ReadChildren(questParent.Address);
+ if (questDisplays is null) return null;
+
+ var groups = new List();
+ foreach (var qd in questDisplays)
+ {
+ if (!string.Equals(qd.StringId, "quest_display", StringComparison.Ordinal))
+ continue;
+
+ // quest_display → [0] (unnamed child with title_layout + quest_info_layout)
+ var qdChildren = ReadChildren(qd.Address);
+ if (qdChildren is null || qdChildren.Count == 0) continue;
+
+ var innerChildren = ReadChildren(qdChildren[0].Address);
+ if (innerChildren is null) continue;
+
+ var group = new UiQuestGroup();
+
+ foreach (var child in innerChildren)
+ {
+ if (string.Equals(child.StringId, "title_layout", StringComparison.Ordinal))
+ {
+ // title_layout → title_label (has quest name text)
+ var titleChildren = ReadChildren(child.Address);
+ if (titleChildren is not null)
+ {
+ foreach (var tc in titleChildren)
+ {
+ if (string.Equals(tc.StringId, "title_label", StringComparison.Ordinal))
+ group.Title = tc.Text;
+ }
+ }
+ }
+ else if (string.Equals(child.StringId, "quest_info_layout", StringComparison.Ordinal))
+ {
+ ReadQuestSteps(child.Address, group.Steps);
+ }
+ }
+
+ if (group.Title is not null || group.Steps.Count > 0)
+ groups.Add(group);
+ }
+
+ return groups.Count > 0 ? groups : null;
+ }
+
+ private void ReadQuestSteps(nint layoutAddr, List steps)
+ {
+ var layoutChildren = ReadChildren(layoutAddr);
+ if (layoutChildren is null) return;
+
+ foreach (var entryNode in layoutChildren)
+ {
+ if (!string.Equals(entryNode.StringId, "quest_info_entry", StringComparison.Ordinal))
+ continue;
+
+ var entryChildren = ReadChildren(entryNode.Address);
+ if (entryChildren is null) continue;
+
+ var step = new UiQuestStep();
+ foreach (var part in entryChildren)
+ {
+ if (string.Equals(part.StringId, "quest_info", StringComparison.Ordinal))
+ step.Text = part.Text;
+ }
+
+ if (step.Text is not null)
+ steps.Add(step);
+ }
+ }
+
+ ///
+ /// BFS search: reads nodes on-demand until a matching StringId is found.
+ /// Walks the live game memory — use sparingly.
+ ///
+ public UIElementNode? FindByStringId(string id)
+ {
+ if (GameUiPtr == 0) return null;
+
+ var root = ReadNode(GameUiPtr);
+ if (root is null) return null;
+ if (string.Equals(root.StringId, id, StringComparison.Ordinal))
+ return root;
+
+ var queue = new Queue();
+ var visited = new HashSet { GameUiPtr };
+ EnqueueChildren(queue, visited, GameUiPtr);
+
+ while (queue.Count > 0)
+ {
+ var addr = queue.Dequeue();
+ var node = ReadNode(addr);
+ if (node is null) continue;
+
+ if (string.Equals(node.StringId, id, StringComparison.Ordinal))
+ return node;
+
+ EnqueueChildren(queue, visited, addr);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Reads both quest linked lists (all-quests + tracked) and merges them.
+ /// Returns null if GameUi is not available.
+ ///
+ public List? ReadQuestLinkedLists()
+ {
+ if (GameUiPtr == 0) return null;
+
+ var mem = Ctx.Memory;
+ var offsets = Ctx.Offsets;
+
+ // ── Tracked quests: [6][1]+0x318 — collect into dict keyed by QuestDatPtr ──
+ var trackedMap = new Dictionary();
+ var elem6 = ReadChildAtIndex(GameUiPtr, offsets.TrackedQuestPanelChildIndex);
+ if (elem6 is not null)
+ {
+ var elem61 = ReadChildAtIndex(elem6.Address, offsets.TrackedQuestPanelSubChildIndex);
+ if (elem61 is not null)
+ {
+ var trackedHead = mem.ReadPointer(elem61.Address + offsets.TrackedQuestLinkedListOffset);
+ if (trackedHead != 0)
+ TraverseTrackedQuests(trackedHead, trackedMap);
+ }
+ }
+
+ // ── All quests: GameUi+0x358 ──
+ var allHead = mem.ReadPointer(GameUiPtr + offsets.QuestLinkedListOffset);
+ if (allHead == 0) return null;
+
+ return TraverseAllQuests(allHead, trackedMap);
+ }
+
+ ///
+ /// Walks the all-quests linked list. Reads Quest.dat row fields + stateId per node.
+ /// Merges tracked info from the trackedMap.
+ ///
+ private List? TraverseAllQuests(nint headPtr, Dictionary trackedMap)
+ {
+ var mem = Ctx.Memory;
+ var offsets = Ctx.Offsets;
+ var maxNodes = offsets.QuestLinkedListMaxNodes;
+ var readSize = Math.Max(offsets.QuestLinkedListNodeSize, 48);
+
+ var visited = new HashSet();
+ var walk = headPtr;
+ var isSentinel = true;
+ var result = new List();
+
+ while (result.Count < maxNodes)
+ {
+ if (walk == 0 || !visited.Add(walk)) break;
+
+ var nodeData = mem.ReadBytes(walk, readSize);
+ if (nodeData is null) break;
+
+ var next = (nint)BitConverter.ToInt64(nodeData, 0);
+
+ if (isSentinel)
+ {
+ isSentinel = false;
+ walk = next;
+ continue;
+ }
+
+ var questPtr = (nint)BitConverter.ToInt64(nodeData, offsets.QuestNodeQuestPtrOffset);
+ var stateId = BitConverter.ToInt32(nodeData, offsets.QuestNodeStateIdOffset);
+
+ string? internalId = null;
+ string? displayName = null;
+ var act = -1;
+
+ if (questPtr != 0 && ((ulong)questPtr >> 32) is > 0 and < 0x7FFF)
+ {
+ var idPtr = mem.ReadPointer(questPtr + offsets.QuestObjNamePtrOffset);
+ if (idPtr != 0 && ((ulong)idPtr >> 32) is > 0 and < 0x7FFF)
+ internalId = _strings.ReadNullTermWString(idPtr);
+
+ act = mem.Read(questPtr + offsets.QuestObjActOffset);
+
+ var namePtr = mem.ReadPointer(questPtr + offsets.QuestObjDisplayNameOffset);
+ if (namePtr != 0 && ((ulong)namePtr >> 32) is > 0 and < 0x7FFF)
+ displayName = _strings.ReadNullTermWString(namePtr);
+ }
+
+ var isTracked = trackedMap.TryGetValue(questPtr, out var objectiveText);
+
+ result.Add(new QuestLinkedEntry
+ {
+ InternalId = internalId,
+ DisplayName = displayName,
+ Act = act,
+ StateId = stateId,
+ IsTracked = isTracked,
+ ObjectiveText = objectiveText,
+ QuestDatPtr = questPtr,
+ });
+
+ walk = next;
+ }
+
+ return result.Count > 0 ? result : null;
+ }
+
+ ///
+ /// Walks the tracked-quests linked list. Builds a dict of QuestDatPtr → ObjectiveText.
+ /// Node+0x20 is a pointer to a runtime state object; text is at stateObj+QuestStateObjTextOffset (std::wstring).
+ ///
+ private void TraverseTrackedQuests(nint headPtr, Dictionary trackedMap)
+ {
+ var mem = Ctx.Memory;
+ var offsets = Ctx.Offsets;
+ var maxNodes = offsets.QuestLinkedListMaxNodes;
+ var readSize = Math.Max(offsets.QuestLinkedListNodeSize, 48);
+
+ var visited = new HashSet();
+ var walk = headPtr;
+ var isSentinel = true;
+ var count = 0;
+
+ while (count < maxNodes)
+ {
+ if (walk == 0 || !visited.Add(walk)) break;
+
+ var nodeData = mem.ReadBytes(walk, readSize);
+ if (nodeData is null) break;
+
+ var next = (nint)BitConverter.ToInt64(nodeData, 0);
+
+ if (isSentinel)
+ {
+ isSentinel = false;
+ walk = next;
+ continue;
+ }
+
+ var questPtr = (nint)BitConverter.ToInt64(nodeData, offsets.QuestNodeQuestPtrOffset);
+ string? objectiveText = null;
+
+ // +0x20 in tracked list is a pointer to the quest state runtime object
+ var stateObjPtr = (nint)BitConverter.ToInt64(nodeData, 0x20);
+ if (stateObjPtr != 0 && ((ulong)stateObjPtr >> 32) is > 0 and < 0x7FFF)
+ {
+ // Read std::wstring at stateObj + QuestStateObjTextOffset
+ objectiveText = ParseWStringFromMemory(stateObjPtr + offsets.QuestStateObjTextOffset);
+ }
+
+ if (questPtr != 0)
+ trackedMap[questPtr] = objectiveText;
+
+ count++;
+ walk = next;
+ }
+ }
+
+ ///
+ /// Reads an inline MSVC std::wstring from a process memory address.
+ /// Same layout as UIElement strings: Buffer(8) + Reserved(8) + Length(4) + pad(4) + Capacity(4) + pad(4).
+ ///
+ private string? ParseWStringFromMemory(nint addr)
+ {
+ var strData = Ctx.Memory.ReadBytes(addr, 32);
+ if (strData is null || strData.Length < 28) return null;
+ return ParseWStringFromBuffer(strData, 0);
+ }
+
+ private void EnqueueChildren(Queue queue, HashSet visited, nint parentAddr)
+ {
+ var mem = Ctx.Memory;
+ var offsets = Ctx.Offsets;
+
+ var vecBegin = mem.ReadPointer(parentAddr + offsets.UiElementChildrenOffset);
+ var vecEnd = mem.ReadPointer(parentAddr + offsets.UiElementChildrenOffset + 8);
+ if (vecBegin == 0 || vecEnd <= vecBegin) return;
+
+ var count = Math.Min((int)(vecEnd - vecBegin) / 8, MaxChildrenPerNode);
+ if (count <= 0) return;
+
+ var data = mem.ReadBytes(vecBegin, count * 8);
+ if (data is null) return;
+
+ for (var i = 0; i < count; i++)
+ {
+ var ptr = (nint)BitConverter.ToInt64(data, i * 8);
+ if (ptr != 0 && visited.Add(ptr))
+ queue.Enqueue(ptr);
+ }
+ }
+
+ private string? ParseWStringFromBuffer(byte[] buf, int offset)
+ {
+ if (offset + 32 > buf.Length) return null;
+
+ var length = BitConverter.ToInt32(buf, offset + 0x10);
+ var capacity = BitConverter.ToInt32(buf, offset + 0x18);
+ if (length <= 0 || length > 4096 || capacity < length) return null;
+
+ try
+ {
+ if (capacity <= 8)
+ {
+ var byteLen = Math.Min(length * 2, 16);
+ return Encoding.Unicode.GetString(buf, offset, byteLen).TrimEnd('\0');
+ }
+ else
+ {
+ var ptr = (nint)BitConverter.ToInt64(buf, offset);
+ if (ptr == 0 || (ulong)ptr >> 32 is 0 or >= 0x7FFF) return null;
+ var charData = Ctx.Memory.ReadBytes(ptr, Math.Min(length * 2, 512));
+ if (charData is null) return null;
+ return Encoding.Unicode.GetString(charData).TrimEnd('\0');
+ }
+ }
+ catch { return null; }
+ }
+
+ private string? ReadUiWString(nint addr)
+ {
+ var strData = Ctx.Memory.ReadBytes(addr, 32);
+ if (strData is null || strData.Length < 28) return null;
+ return ParseWStringFromBuffer(strData, 0);
+ }
+
+ protected override void Clear()
+ {
+ UiRootPtr = 0;
+ GameUiPtr = 0;
+ }
+}
diff --git a/src/Roboto.Memory/Snapshots/GameStateSnapshot.cs b/src/Roboto.Memory/Snapshots/GameStateSnapshot.cs
index 1083f35..b2288bb 100644
--- a/src/Roboto.Memory/Snapshots/GameStateSnapshot.cs
+++ b/src/Roboto.Memory/Snapshots/GameStateSnapshot.cs
@@ -78,6 +78,18 @@ public class GameStateSnapshot
// Quest flags (from ServerData → PlayerServerData)
public List? QuestFlags;
+ // Quest states (from AreaInstance → sub-object → vector)
+ public List? QuestStates;
+
+ // UI tree — root pointer only; tree is read on-demand
+ public nint GameUiPtr;
+
+ // Quest linked lists (all-quests + tracked merged)
+ public List? QuestLinkedList;
+
+ // Quest groups from UI element tree
+ public List? UiQuestGroups;
+
// Camera
public Matrix4x4? CameraMatrix;
diff --git a/src/Roboto.Memory/Snapshots/QuestLinkedEntry.cs b/src/Roboto.Memory/Snapshots/QuestLinkedEntry.cs
new file mode 100644
index 0000000..263084f
--- /dev/null
+++ b/src/Roboto.Memory/Snapshots/QuestLinkedEntry.cs
@@ -0,0 +1,24 @@
+namespace Roboto.Memory;
+
+///
+/// A quest entry from the GameUi linked lists.
+/// All-quests list (GameUi+0x358) provides Id/Name/Act/StateId.
+/// Tracked-quests list ([6][1]+0x318) adds ObjectiveText.
+///
+public sealed class QuestLinkedEntry
+{
+ /// Internal quest ID from Quest.dat row, e.g. "TreeOfSouls".
+ public string? InternalId { get; init; }
+ /// Display name from Quest.dat row, e.g. "Secrets in the Dark".
+ public string? DisplayName { get; init; }
+ /// Act number from Quest.dat row.
+ public int Act { get; init; }
+ /// State: 0=done, -1(0xFFFFFFFF)=locked, positive=in-progress step.
+ public int StateId { get; init; }
+ /// True if this quest appears in the tracked-quests list.
+ public bool IsTracked { get; init; }
+ /// Objective text from the tracked quest's runtime state object (std::wstring at +0x34).
+ public string? ObjectiveText { get; init; }
+ /// Raw Quest.dat row pointer — used as key for merging tracked info.
+ public nint QuestDatPtr { get; init; }
+}
diff --git a/src/Roboto.Memory/Snapshots/QuestStateEntry.cs b/src/Roboto.Memory/Snapshots/QuestStateEntry.cs
new file mode 100644
index 0000000..8675b0b
--- /dev/null
+++ b/src/Roboto.Memory/Snapshots/QuestStateEntry.cs
@@ -0,0 +1,13 @@
+namespace Roboto.Memory;
+
+///
+/// A quest state entry from the AreaInstance quest state container.
+/// 12-byte struct: {int QuestId, int State, int Flags}.
+/// Discovered via ScanQuestStateContainers at AI+0x900 → obj → +0x240 vector.
+///
+public sealed class QuestStateEntry
+{
+ public int QuestId { get; init; }
+ public int State { get; init; }
+ public int Flags { get; init; }
+}
diff --git a/src/Roboto.Memory/Snapshots/UIElementNode.cs b/src/Roboto.Memory/Snapshots/UIElementNode.cs
new file mode 100644
index 0000000..f7325a4
--- /dev/null
+++ b/src/Roboto.Memory/Snapshots/UIElementNode.cs
@@ -0,0 +1,17 @@
+namespace Roboto.Memory;
+
+///
+/// Lightweight snapshot of a single UIElement from the game's UI tree.
+/// Built by UIElements RemoteObject during cold tick reads.
+///
+public sealed class UIElementNode
+{
+ public nint Address { get; init; }
+ public string? StringId { get; init; }
+ public string? Text { get; init; }
+ public bool IsVisible { get; init; }
+ public float Width { get; init; }
+ public float Height { get; init; }
+ public int ChildCount { get; init; }
+ public List? Children { get; init; }
+}
diff --git a/src/Roboto.Memory/Snapshots/UiQuestEntry.cs b/src/Roboto.Memory/Snapshots/UiQuestEntry.cs
new file mode 100644
index 0000000..5516512
--- /dev/null
+++ b/src/Roboto.Memory/Snapshots/UiQuestEntry.cs
@@ -0,0 +1,22 @@
+namespace Roboto.Memory;
+
+///
+/// A quest group from the UI element tree (one per quest_display).
+/// Contains the quest title and current objective steps.
+///
+public sealed class UiQuestGroup
+{
+ /// Quest title from title_layout → title_label (e.g. "Treacherous Ground").
+ public string? Title { get; set; }
+ /// Current quest objective steps.
+ public List Steps { get; set; } = [];
+}
+
+///
+/// A single quest objective from quest_info_entry → quest_info.
+///
+public sealed class UiQuestStep
+{
+ /// Objective text (e.g. "Search Clearfell for the entrance to the Mud Burrow").
+ public string? Text { get; set; }
+}