huge refactor

This commit is contained in:
Boki 2026-03-02 23:45:12 -05:00
parent e5ebe05571
commit a8341e8232
29 changed files with 3184 additions and 340 deletions

96
data/poe2/areas.json Normal file
View file

@ -0,0 +1,96 @@
[
{
"act": 1,
"areas": [
{ "id": "G1_1", "name": "The Riverbank", "level": 1, "order": 1, "wp": false, "connects": ["G1_town"] },
{ "id": "G1_town", "name": "Clearfell Encampment", "level": 15, "order": 2, "town": true, "wp": true, "connects": ["G1_1", "G1_2"] },
{ "id": "G1_2", "name": "Clearfell", "level": 2, "order": 3, "wp": true, "connects": ["G1_town", "G1_3", "G1_4"] },
{ "id": "G1_3", "name": "Mud Burrow", "level": 3, "order": 4, "wp": false, "connects": ["G1_2"] },
{ "id": "G1_4", "name": "The Grelwood", "level": 4, "order": 5, "wp": true, "connects": ["G1_2", "G1_5", "G1_6"] },
{ "id": "G1_5", "name": "The Red Vale", "level": 5, "order": 6, "wp": true, "connects": ["G1_4"] },
{ "id": "G1_6", "name": "The Grim Tangle", "level": 6, "order": 7, "wp": true, "connects": ["G1_4", "G1_7"] },
{ "id": "G1_7", "name": "Cemetery of the Eternals", "level": 7, "order": 8, "wp": true, "connects": ["G1_6", "G1_8", "G1_9", "G1_11"] },
{ "id": "G1_8", "name": "Mausoleum of the Praetor", "level": 8, "order": 9, "wp": true, "connects": ["G1_7"] },
{ "id": "G1_9", "name": "Tomb of the Consort", "level": 8, "order": 10, "wp": true, "connects": ["G1_7"] },
{ "id": "G1_10", "name": "Root Hollow", "level": 15, "order": 11, "wp": false, "connects": [] },
{ "id": "G1_11", "name": "Hunting Grounds", "level": 10, "order": 12, "wp": true, "connects": ["G1_7", "G1_12", "G1_13_1"] },
{ "id": "G1_12", "name": "Freythorn", "level": 11, "order": 13, "wp": true, "connects": ["G1_11"] },
{ "id": "G1_13_1", "name": "Ogham Farmlands", "level": 12, "order": 14, "wp": true, "connects": ["G1_11", "G1_13_2"] },
{ "id": "G1_13_2", "name": "Ogham Village", "level": 13, "order": 15, "wp": true, "connects": ["G1_13_1", "G1_14"] },
{ "id": "G1_14", "name": "The Manor Ramparts", "level": 14, "order": 16, "wp": true, "connects": ["G1_13_2", "G1_15"] },
{ "id": "G1_15", "name": "Ogham Manor", "level": 15, "order": 17, "wp": true, "connects": ["G1_14"] }
]
},
{
"act": 2,
"areas": [
{ "id": "G2_1", "name": "Vastiri Outskirts", "level": 16, "order": 1, "wp": true, "connects": ["G2_10_2"] },
{ "id": "G2_town", "name": "The Ardura Caravan", "level": 32, "order": 2, "town": true, "wp": true, "connects": ["G2_10_1", "G2_13"] },
{ "id": "G2_2", "name": "Traitor's Passage", "level": 19, "order": 3, "wp": true, "connects": ["G2_3", "G2_12_1"] },
{ "id": "G2_3", "name": "The Halani Gates", "level": 20, "order": 4, "wp": true, "connects": ["G2_2"] },
{ "id": "G2_4_1", "name": "Keth", "level": 21, "order": 5, "wp": true, "connects": ["G2_8", "G2_4_2"] },
{ "id": "G2_4_2", "name": "The Lost City", "level": 22, "order": 6, "wp": true, "connects": ["G2_4_1", "G2_5_2", "G2_4_3"] },
{ "id": "G2_4_3", "name": "Buried Shrines", "level": 23, "order": 7, "wp": true, "connects": ["G2_4_2"] },
{ "id": "G2_5_1", "name": "Mastodon Badlands", "level": 21, "order": 8, "wp": true, "connects": ["G2_12_1", "G2_5_2"] },
{ "id": "G2_5_2", "name": "The Bone Pits", "level": 22, "order": 9, "wp": true, "connects": ["G2_5_1", "G2_4_2"] },
{ "id": "Abyss_Intro", "name": "Lightless Passage", "level": 22, "order": 10, "wp": false, "connects": [] },
{ "id": "Abyss_Hub", "name": "The Well of Souls", "level": 22, "order": 11, "wp": false, "connects": [] },
{ "id": "G2_6", "name": "Valley of the Titans", "level": 21, "order": 12, "wp": true, "connects": ["G2_12_1", "G2_7"] },
{ "id": "G2_7", "name": "The Titan Grotto", "level": 22, "order": 13, "wp": true, "connects": ["G2_6"] },
{ "id": "G2_8", "name": "Deshar", "level": 28, "order": 14, "wp": true, "connects": ["G2_4_1", "G2_9_1"] },
{ "id": "G2_9_1", "name": "Path of Mourning", "level": 29, "order": 15, "wp": true, "connects": ["G2_8", "G2_9_2"] },
{ "id": "G2_9_2", "name": "The Spires of Deshar", "level": 30, "order": 16, "wp": true, "connects": ["G2_9_1"] },
{ "id": "G2_10_1", "name": "Mawdun Quarry", "level": 17, "order": 17, "wp": true, "connects": ["G2_10_2", "G2_town"] },
{ "id": "G2_10_2", "name": "Mawdun Mine", "level": 18, "order": 18, "wp": true, "connects": ["G2_1", "G2_10_1"] },
{ "id": "G2_12_1", "name": "The Dreadnought", "level": 31, "order": 19, "wp": true, "connects": ["G2_2", "G2_12_2", "G2_5_1", "G2_6"] },
{ "id": "G2_12_2", "name": "Dreadnought Vanguard", "level": 32, "order": 20, "wp": true, "connects": ["G2_12_1"] },
{ "id": "G2_13", "name": "Trial of the Sekhemas", "level": 22, "order": 21, "wp": true, "connects": ["G2_town"] }
]
},
{
"act": 3,
"areas": [
{ "id": "G3_1", "name": "Sandswept Marsh", "level": 33, "order": 1, "wp": true, "connects": ["G3_4", "G3_3"] },
{ "id": "G3_town", "name": "Ziggurat Encampment", "level": 44, "order": 2, "town": true, "wp": true, "connects": ["G3_3", "G3_2_1", "G3_8"] },
{ "id": "G3_2_1", "name": "Infested Barrens", "level": 35, "order": 3, "wp": true, "connects": ["G3_town", "G3_7", "G3_5"] },
{ "id": "G3_2_2", "name": "The Matlan Waterways", "level": 39, "order": 4, "wp": false, "connects": ["G3_3", "G3_5"] },
{ "id": "G3_3", "name": "Jungle Ruins", "level": 34, "order": 5, "wp": true, "connects": ["G3_1", "G3_4", "G3_town", "G3_2_2"] },
{ "id": "G3_4", "name": "The Venom Crypts", "level": 35, "order": 6, "wp": false, "connects": ["G3_1", "G3_3"] },
{ "id": "G3_5", "name": "Chimeral Wetlands", "level": 36, "order": 7, "wp": true, "connects": ["G3_2_2", "G3_2_1", "G3_6_1", "G3_10"] },
{ "id": "G3_6_1", "name": "Jiquani's Machinarium", "level": 37, "order": 8, "wp": true, "connects": ["G3_5", "G3_6_2"] },
{ "id": "G3_6_2", "name": "Jiquani's Sanctum", "level": 38, "order": 9, "wp": true, "connects": ["G3_6_1"] },
{ "id": "G3_7", "name": "The Azak Bog", "level": 36, "order": 10, "wp": true, "connects": ["G3_2_1"] },
{ "id": "G3_8", "name": "The Drowned City", "level": 40, "order": 11, "wp": true, "connects": ["G3_town", "G3_11", "G3_9"] },
{ "id": "G3_9", "name": "The Molten Vault", "level": 41, "order": 12, "wp": true, "connects": ["G3_8"] },
{ "id": "G3_10", "name": "The Trial of Chaos", "level": 38, "order": 13, "wp": true, "connects": ["G3_5", "G3_14"] },
{ "id": "G3_11", "name": "Apex of Filth", "level": 41, "order": 14, "wp": true, "connects": ["G3_8", "G3_12"] },
{ "id": "G3_12", "name": "Temple of Kopec", "level": 42, "order": 15, "wp": false, "connects": ["G3_11", "G3_14"] },
{ "id": "G3_14", "name": "Utzaal", "level": 43, "order": 16, "wp": true, "connects": ["G3_12", "G3_16", "G3_10"] },
{ "id": "G3_16", "name": "Aggorat", "level": 44, "order": 17, "wp": true, "connects": ["G3_14", "G3_17"] },
{ "id": "G3_17", "name": "The Black Chambers", "level": 45, "order": 18, "wp": true, "connects": ["G3_16"] }
]
},
{
"act": 4,
"areas": [
{ "id": "G4_town", "name": "Kingsmarch", "level": 53, "order": 1, "town": true, "wp": true, "connects": ["G4_1_1", "G4_5_1"] },
{ "id": "G4_1_1", "name": "Isle of Kin", "level": 46, "order": 2, "wp": true, "connects": ["G4_town", "G4_1_2"] },
{ "id": "G4_1_2", "name": "Volcanic Warrens", "level": 47, "order": 3, "wp": true, "connects": ["G4_1_1", "G4_7"] },
{ "id": "G4_2_1", "name": "Kedge Bay", "level": 46, "order": 4, "wp": true, "connects": ["G4_7", "G4_2_2"] },
{ "id": "G4_2_2", "name": "Journey's End", "level": 47, "order": 5, "wp": true, "connects": ["G4_2_1", "G4_3_1"] },
{ "id": "G4_3_1", "name": "Whakapanu Island", "level": 46, "order": 6, "wp": true, "connects": ["G4_2_2", "G4_3_2"] },
{ "id": "G4_3_2", "name": "Singing Caverns", "level": 47, "order": 7, "wp": true, "connects": ["G4_3_1", "G4_4_1"] },
{ "id": "G4_4_1", "name": "Eye of Hinekora", "level": 46, "order": 8, "wp": true, "connects": ["G4_3_2", "G4_4_2"] },
{ "id": "G4_4_2", "name": "Halls of the Dead", "level": 47, "order": 9, "wp": true, "connects": ["G4_4_1", "G4_4_3", "G4_8a"] },
{ "id": "G4_4_3", "name": "Trial of the Ancestors", "level": 51, "order": 10, "wp": true, "connects": ["G4_4_2", "G4_11_1a"] },
{ "id": "G4_5_1", "name": "Abandoned Prison", "level": 46, "order": 11, "wp": true, "connects": ["G4_town", "G4_5_2"] },
{ "id": "G4_5_2", "name": "Solitary Confinement", "level": 47, "order": 12, "wp": true, "connects": ["G4_5_1"] },
{ "id": "G4_7", "name": "Shrike Island", "level": 46, "order": 13, "wp": true, "connects": ["G4_1_2", "G4_2_1"] },
{ "id": "G4_8a", "name": "Arastas", "level": 52, "order": 14, "wp": true, "connects": ["G4_4_2", "G4_10"] },
{ "id": "G4_10", "name": "The Excavation", "level": 52, "order": 15, "wp": true, "connects": ["G4_8a", "G4_11_1a"] },
{ "id": "G4_11_1a", "name": "Ngakanu", "level": 53, "order": 16, "wp": true, "connects": ["G4_4_3", "G4_10", "G4_11_2"] },
{ "id": "G4_11_2", "name": "Heart of the Tribe", "level": 53, "order": 17, "wp": true, "connects": ["G4_11_1a"] },
{ "id": "G4_13", "name": "Plunder's Point", "level": 53, "order": 18, "wp": true, "connects": [] }
]
}
]

View file

@ -13,18 +13,24 @@
"Metadata/Chests/EzomyteChest_06", "Metadata/Chests/EzomyteChest_06",
"Metadata/Chests/LeagueIncursion/EncounterChest", "Metadata/Chests/LeagueIncursion/EncounterChest",
"Metadata/Chests/MossyChest11", "Metadata/Chests/MossyChest11",
"Metadata/Chests/MossyChest13",
"Metadata/Chests/MossyChest20", "Metadata/Chests/MossyChest20",
"Metadata/Chests/MossyChest21", "Metadata/Chests/MossyChest21",
"Metadata/Chests/MossyChest26", "Metadata/Chests/MossyChest26",
"Metadata/Critters/Chicken/Chicken_kingsmarch", "Metadata/Critters/Chicken/Chicken_kingsmarch",
"Metadata/Critters/Crow/Crow",
"Metadata/Critters/Ferret/Ferret",
"Metadata/Critters/Hedgehog/HedgehogSlow", "Metadata/Critters/Hedgehog/HedgehogSlow",
"Metadata/Critters/Weta/Basic", "Metadata/Critters/Weta/Basic",
"Metadata/Effects/Effect", "Metadata/Effects/Effect",
"Metadata/Effects/Microtransactions/Town_Portals/PersonSplitPortal/_PersonSplitPortalPrespawnDummy",
"Metadata/Effects/Microtransactions/Town_Portals/PersonSplitPortal/_PersonSplitPortalPrespawnDummyMarble",
"Metadata/Effects/Microtransactions/foot_prints/delirium/footprints_delirium", "Metadata/Effects/Microtransactions/foot_prints/delirium/footprints_delirium",
"Metadata/Effects/Microtransactions/foot_prints/harvest02/footprints_harvest", "Metadata/Effects/Microtransactions/foot_prints/harvest02/footprints_harvest",
"Metadata/Effects/PermanentEffect", "Metadata/Effects/PermanentEffect",
"Metadata/Effects/ServerEffect", "Metadata/Effects/ServerEffect",
"Metadata/Effects/Spells/monsters_effects/Act1_FOUR/CarrionCrone/IceSpike", "Metadata/Effects/Spells/monsters_effects/Act1_FOUR/CarrionCrone/IceSpike",
"Metadata/Effects/Spells/sandstorm_swipe/sandstorm_swipe_storm",
"Metadata/MiscellaneousObjects/AreaTransitionBlockage", "Metadata/MiscellaneousObjects/AreaTransitionBlockage",
"Metadata/MiscellaneousObjects/AreaTransitionDoodad", "Metadata/MiscellaneousObjects/AreaTransitionDoodad",
"Metadata/MiscellaneousObjects/AreaTransition_Animate", "Metadata/MiscellaneousObjects/AreaTransition_Animate",
@ -46,19 +52,28 @@
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_6", "Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_6",
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalEncounter", "Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalEncounter",
"Metadata/MiscellaneousObjects/MultiplexPortal", "Metadata/MiscellaneousObjects/MultiplexPortal",
"Metadata/MiscellaneousObjects/ReviveIcon",
"Metadata/MiscellaneousObjects/ServerDoodadHidden", "Metadata/MiscellaneousObjects/ServerDoodadHidden",
"Metadata/MiscellaneousObjects/Stash", "Metadata/MiscellaneousObjects/Stash",
"Metadata/MiscellaneousObjects/Waypoint", "Metadata/MiscellaneousObjects/Waypoint",
"Metadata/MiscellaneousObjects/WorldItem", "Metadata/MiscellaneousObjects/WorldItem",
"Metadata/Monsters/BansheeRemake/WitchHut/Objects/AmbushLocation",
"Metadata/Monsters/BansheeRemake/WitchHutBanshee",
"Metadata/Monsters/FungusZombie/FungusZombieLarge",
"Metadata/Monsters/FungusZombie/FungusZombieMedium",
"Metadata/Monsters/Hags/Objects/BossRoomMinimapIcon", "Metadata/Monsters/Hags/Objects/BossRoomMinimapIcon",
"Metadata/Monsters/Hags/UrchinHag1", "Metadata/Monsters/Hags/UrchinHag1",
"Metadata/Monsters/Hags/UrchinHagBoss", "Metadata/Monsters/Hags/UrchinHagBoss",
"Metadata/Monsters/InvisibleFire/MDCarrionCroneWave", "Metadata/Monsters/InvisibleFire/MDCarrionCroneWave",
"Metadata/Monsters/MonsterMods/OnDeathColdExplosionParent",
"Metadata/Monsters/Urchins/MeleeUrchin1", "Metadata/Monsters/Urchins/MeleeUrchin1",
"Metadata/Monsters/Urchins/SlingUrchin1", "Metadata/Monsters/Urchins/SlingUrchin1",
"Metadata/Monsters/Werewolves/WerewolfPack1",
"Metadata/Monsters/Werewolves/WerewolfProwler1",
"Metadata/Monsters/Wolves/RottenWolf1_", "Metadata/Monsters/Wolves/RottenWolf1_",
"Metadata/Monsters/Wolves/RottenWolfDead", "Metadata/Monsters/Wolves/RottenWolfDead",
"Metadata/Monsters/Wolves/RottenWolfHagSummonedDead", "Metadata/Monsters/Wolves/RottenWolfHagSummonedDead",
"Metadata/Monsters/Zombies/CourtGuardZombieAxe",
"Metadata/Monsters/Zombies/CourtGuardZombieUnarmed", "Metadata/Monsters/Zombies/CourtGuardZombieUnarmed",
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxe", "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxe",
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxePhysics__", "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxePhysics__",
@ -89,10 +104,16 @@
"Metadata/Pet/BabyChimera/BabyChimera", "Metadata/Pet/BabyChimera/BabyChimera",
"Metadata/Pet/BetaKiwis/BaronKiwi", "Metadata/Pet/BetaKiwis/BaronKiwi",
"Metadata/Pet/BetaKiwis/FaridunKiwi", "Metadata/Pet/BetaKiwis/FaridunKiwi",
"Metadata/Pet/BetaKiwis/KaruiKiwi",
"Metadata/Pet/BetaKiwis/VaalKiwi",
"Metadata/Pet/BookAndQuillPet/BookAndQuillPet",
"Metadata/Pet/BookAndQuillPet/BookAndQuillPet_Abyss", "Metadata/Pet/BookAndQuillPet/BookAndQuillPet_Abyss",
"Metadata/Pet/Cat/Sphynx/GiantSphynx/GiantSphynxBlack", "Metadata/Pet/Cat/Sphynx/GiantSphynx/GiantSphynxBlack",
"Metadata/Pet/EtchedBeetlePet/EtchedBeetlePetAsala",
"Metadata/Pet/FledglingBellcrow/FledglingBellcrow", "Metadata/Pet/FledglingBellcrow/FledglingBellcrow",
"Metadata/Pet/HeritagePeacock/HeritagePeacock",
"Metadata/Pet/LandSharkPet/LandSharkPet", "Metadata/Pet/LandSharkPet/LandSharkPet",
"Metadata/Pet/LightBringerCat/LightbringerCat",
"Metadata/Pet/OctopusParasite/OctopusParasiteCelestial", "Metadata/Pet/OctopusParasite/OctopusParasiteCelestial",
"Metadata/Pet/OrigamiPet/OrigamiPetBase", "Metadata/Pet/OrigamiPet/OrigamiPetBase",
"Metadata/Pet/Phoenix/PhoenixPetBlue", "Metadata/Pet/Phoenix/PhoenixPetBlue",
@ -100,10 +121,12 @@
"Metadata/Pet/Phoenix/PhoenixPetRed", "Metadata/Pet/Phoenix/PhoenixPetRed",
"Metadata/Pet/QuadrillaPet/QuadrillaArmoured", "Metadata/Pet/QuadrillaPet/QuadrillaArmoured",
"Metadata/Pet/ScavengerBat/ScavengerBat", "Metadata/Pet/ScavengerBat/ScavengerBat",
"Metadata/Pet/WayfinderWolf/WayfinderWolf",
"Metadata/Projectiles/CarrionCroneIceSpear", "Metadata/Projectiles/CarrionCroneIceSpear",
"Metadata/Projectiles/HagBossIceShard", "Metadata/Projectiles/HagBossIceShard",
"Metadata/Projectiles/IceSpear", "Metadata/Projectiles/IceSpear",
"Metadata/Projectiles/SlingUrchinProjectile", "Metadata/Projectiles/SlingUrchinProjectile",
"Metadata/Projectiles/Spark",
"Metadata/Projectiles/Twister", "Metadata/Projectiles/Twister",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1", "Metadata/Terrain/Doodads/Gallows/ClearfellBull1",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1_CountKilled", "Metadata/Terrain/Doodads/Gallows/ClearfellBull1_CountKilled",
@ -112,6 +135,9 @@
"Metadata/Terrain/Gallows/Act1/1_2/Objects/CampsiteChest", "Metadata/Terrain/Gallows/Act1/1_2/Objects/CampsiteChest",
"Metadata/Terrain/Gallows/Act1/1_2/Objects/CampsiteController", "Metadata/Terrain/Gallows/Act1/1_2/Objects/CampsiteController",
"Metadata/Terrain/Gallows/Act1/1_2/Objects/RuleSet", "Metadata/Terrain/Gallows/Act1/1_2/Objects/RuleSet",
"Metadata/Terrain/Gallows/Act1/1_4/Objects/HagCauldron",
"Metadata/Terrain/Gallows/Act1/1_4/Objects/SecretRoomMinimapIcon",
"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/Act1_finished_LightController",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBenchEzomyte", "Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBenchEzomyte",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBench_DisableRendering", "Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBench_DisableRendering",
@ -122,6 +148,7 @@
"Metadata/Terrain/Tools/AudioTools/G1_2/ForestEntrance", "Metadata/Terrain/Tools/AudioTools/G1_2/ForestEntrance",
"Metadata/Terrain/Tools/AudioTools/G1_2/HagArena", "Metadata/Terrain/Tools/AudioTools/G1_2/HagArena",
"Metadata/Terrain/Tools/AudioTools/G1_2/RiverRapidsMedium", "Metadata/Terrain/Tools/AudioTools/G1_2/RiverRapidsMedium",
"Metadata/Terrain/Tools/AudioTools/G1_4/WitchHutIndoorAudio",
"Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio", "Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio",
"Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio" "Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio"
] ]

View file

@ -7,6 +7,7 @@ using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Roboto.Memory; using Roboto.Memory;
using Roboto.Memory.States;
namespace Automata.Ui.ViewModels; namespace Automata.Ui.ViewModels;
@ -89,8 +90,10 @@ public partial class MemoryViewModel : ObservableObject
private MemoryNodeViewModel? _playerLife; private MemoryNodeViewModel? _playerLife;
private MemoryNodeViewModel? _playerMana; private MemoryNodeViewModel? _playerMana;
private MemoryNodeViewModel? _playerEs; private MemoryNodeViewModel? _playerEs;
private MemoryNodeViewModel? _currentStateNode;
private MemoryNodeViewModel? _isLoadingNode; private MemoryNodeViewModel? _isLoadingNode;
private MemoryNodeViewModel? _escapeStateNode; private MemoryNodeViewModel? _escapeStateNode;
private MemoryNodeViewModel? _activeStatesNode;
private MemoryNodeViewModel? _statesNode; private MemoryNodeViewModel? _statesNode;
private MemoryNodeViewModel? _terrainCells; private MemoryNodeViewModel? _terrainCells;
private MemoryNodeViewModel? _terrainGrid; private MemoryNodeViewModel? _terrainGrid;
@ -98,6 +101,7 @@ public partial class MemoryViewModel : ObservableObject
private MemoryNodeViewModel? _entitySummary; private MemoryNodeViewModel? _entitySummary;
private MemoryNodeViewModel? _entityTypesNode; private MemoryNodeViewModel? _entityTypesNode;
private MemoryNodeViewModel? _entityListNode; private MemoryNodeViewModel? _entityListNode;
private MemoryNodeViewModel? _skillsNode;
partial void OnIsEnabledChanged(bool value) partial void OnIsEnabledChanged(bool value)
{ {
@ -163,16 +167,20 @@ public partial class MemoryViewModel : ObservableObject
_gsController = new MemoryNodeViewModel("Controller:"); _gsController = new MemoryNodeViewModel("Controller:");
_gsStates = new MemoryNodeViewModel("States:"); _gsStates = new MemoryNodeViewModel("States:");
_inGameState = new MemoryNodeViewModel("InGameState:"); _inGameState = new MemoryNodeViewModel("InGameState:");
_currentStateNode = new MemoryNodeViewModel("Current State:");
_isLoadingNode = new MemoryNodeViewModel("Loading:"); _isLoadingNode = new MemoryNodeViewModel("Loading:");
_escapeStateNode = new MemoryNodeViewModel("Escape:"); _escapeStateNode = new MemoryNodeViewModel("Escape:");
_activeStatesNode = new MemoryNodeViewModel("Controller") { IsExpanded = true };
_statesNode = new MemoryNodeViewModel("State Slots") { IsExpanded = true }; _statesNode = new MemoryNodeViewModel("State Slots") { IsExpanded = true };
gameState.Children.Add(_gsPattern); gameState.Children.Add(_gsPattern);
gameState.Children.Add(_gsBase); gameState.Children.Add(_gsBase);
gameState.Children.Add(_gsController); gameState.Children.Add(_gsController);
gameState.Children.Add(_gsStates); gameState.Children.Add(_gsStates);
gameState.Children.Add(_inGameState); gameState.Children.Add(_inGameState);
gameState.Children.Add(_currentStateNode);
gameState.Children.Add(_isLoadingNode); gameState.Children.Add(_isLoadingNode);
gameState.Children.Add(_escapeStateNode); gameState.Children.Add(_escapeStateNode);
gameState.Children.Add(_activeStatesNode);
gameState.Children.Add(_statesNode); gameState.Children.Add(_statesNode);
// InGameState children // InGameState children
@ -199,10 +207,12 @@ public partial class MemoryViewModel : ObservableObject
_playerLife = new MemoryNodeViewModel("Life:") { Value = "?", ValueColor = "#484f58" }; _playerLife = new MemoryNodeViewModel("Life:") { Value = "?", ValueColor = "#484f58" };
_playerMana = new MemoryNodeViewModel("Mana:") { Value = "?", ValueColor = "#484f58" }; _playerMana = new MemoryNodeViewModel("Mana:") { Value = "?", ValueColor = "#484f58" };
_playerEs = new MemoryNodeViewModel("ES:") { Value = "?", ValueColor = "#484f58" }; _playerEs = new MemoryNodeViewModel("ES:") { Value = "?", ValueColor = "#484f58" };
_skillsNode = new MemoryNodeViewModel("Skills") { IsExpanded = false };
player.Children.Add(_playerPos); player.Children.Add(_playerPos);
player.Children.Add(_playerLife); player.Children.Add(_playerLife);
player.Children.Add(_playerMana); player.Children.Add(_playerMana);
player.Children.Add(_playerEs); player.Children.Add(_playerEs);
player.Children.Add(_skillsNode);
// Entities // Entities
var entitiesGroup = new MemoryNodeViewModel("Entities"); var entitiesGroup = new MemoryNodeViewModel("Entities");
@ -276,9 +286,69 @@ public partial class MemoryViewModel : ObservableObject
_inGameState!.Set( _inGameState!.Set(
snap.InGameStatePtr != 0 ? $"0x{snap.InGameStatePtr:X}" : "not found", snap.InGameStatePtr != 0 ? $"0x{snap.InGameStatePtr:X}" : "not found",
snap.InGameStatePtr != 0); snap.InGameStatePtr != 0);
// Current game state
var currentState = snap.CurrentGameState;
var stateColor = currentState switch
{
GameStateType.InGameState => "#3fb950", // green
GameStateType.AreaLoadingState or
GameStateType.LoadingState => "#d29922", // yellow
GameStateType.EscapeState => "#f85149", // red
GameStateType.LoginState or
GameStateType.SelectCharacterState or
GameStateType.PreGameState => "#58a6ff", // blue
GameStateType.GameNotLoaded => "#484f58", // dim
_ => "#8b949e", // gray
};
_currentStateNode!.Value = currentState.ToString();
_currentStateNode.ValueColor = stateColor;
_isLoadingNode!.Set(snap.IsLoading ? "Loading..." : "Ready", !snap.IsLoading); _isLoadingNode!.Set(snap.IsLoading ? "Loading..." : "Ready", !snap.IsLoading);
_escapeStateNode!.Set(snap.IsEscapeOpen ? "Open" : "Closed", !snap.IsEscapeOpen); _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 // State Slots — show pointer + int32 at +0x08 for each state slot
if (_statesNode is not null && snap.StateSlots.Length > 0) if (_statesNode is not null && snap.StateSlots.Length > 0)
{ {
@ -303,10 +373,18 @@ public partial class MemoryViewModel : ObservableObject
} }
else else
{ {
// Read int32 at state+0x08 (the value CE found)
var int32Val = snap.StateSlotValues?.Length > i ? snap.StateSlotValues[i] : 0; var int32Val = snap.StateSlotValues?.Length > i ? snap.StateSlotValues[i] : 0;
val = $"0x{ptr:X} [+0x08]={int32Val}"; var isActive = snap.ActiveStates.Contains(ptr);
color = ptr == snap.InGameStatePtr ? "#3fb950" : "#8b949e"; 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) if (i < _statesNode.Children.Count)
@ -325,11 +403,14 @@ public partial class MemoryViewModel : ObservableObject
} }
} }
// Status text // Status text — show resolved current state
if (snap.Attached) if (snap.Attached)
StatusText = snap.InGameStatePtr != 0 {
? $"Attached (PID {snap.ProcessId}) — InGame" var stateLabel = snap.CurrentGameState != GameStateType.GameNotLoaded
: $"Attached (PID {snap.ProcessId})"; ? snap.CurrentGameState.ToString()
: snap.InGameStatePtr != 0 ? "InGame" : "unknown";
StatusText = $"Attached (PID {snap.ProcessId}) — {stateLabel}";
}
else if (snap.Error is not null) else if (snap.Error is not null)
StatusText = $"Error: {snap.Error}"; StatusText = $"Error: {snap.Error}";
@ -373,6 +454,57 @@ public partial class MemoryViewModel : ObservableObject
_playerEs!.Set("? (set LifeComponentIndex)", false); _playerEs!.Set("? (set LifeComponentIndex)", false);
} }
// Player skills
if (_skillsNode is not null)
{
if (snap.PlayerSkills is { Count: > 0 })
{
_skillsNode.Value = $"{snap.PlayerSkills.Count} skills";
_skillsNode.ValueColor = "#3fb950";
while (_skillsNode.Children.Count > snap.PlayerSkills.Count)
_skillsNode.Children.RemoveAt(_skillsNode.Children.Count - 1);
for (var i = 0; i < snap.PlayerSkills.Count; i++)
{
var skill = snap.PlayerSkills[i];
var name = skill.Name ?? $"Skill#{i}";
var label = $"[{i}] {name}:";
var parts = new List<string>();
parts.Add(skill.CanBeUsed ? "Ready" : "Cooldown");
if (skill.UseStage != 0)
parts.Add($"stage:{skill.UseStage}");
if (skill.CooldownTimeMs > 0)
parts.Add($"cd:{skill.CooldownTimeMs}ms");
if (skill.MaxUses > 1)
parts.Add($"charges:{skill.MaxUses - skill.ActiveCooldowns}/{skill.MaxUses}");
parts.Add($"cast:{skill.CastType}");
var value = string.Join(" ", parts);
var color = skill.CanBeUsed ? "#3fb950" : "#d29922";
if (i < _skillsNode.Children.Count)
{
_skillsNode.Children[i].Name = label;
_skillsNode.Children[i].Value = value;
_skillsNode.Children[i].ValueColor = color;
}
else
{
var node = new MemoryNodeViewModel(label) { Value = value, ValueColor = color };
_skillsNode.Children.Add(node);
}
}
}
else
{
_skillsNode.Value = "—";
_skillsNode.ValueColor = "#484f58";
_skillsNode.Children.Clear();
}
}
// Entities // Entities
if (snap.Entities is { Count: > 0 }) if (snap.Entities is { Count: > 0 })
{ {
@ -707,6 +839,8 @@ public partial class MemoryViewModel : ObservableObject
var name = lastSlash >= 0 ? e.Path[(lastSlash + 1)..] : e.Path; var name = lastSlash >= 0 ? e.Path[(lastSlash + 1)..] : e.Path;
var at = name.IndexOf('@'); var at = name.IndexOf('@');
if (at > 0) name = name[..at]; if (at > 0) name = name[..at];
if (e.TransitionName is not null)
return $"[{e.Id}] {name} → {e.TransitionName}";
return $"[{e.Id}] {name}"; return $"[{e.Id}] {name}";
} }
return $"[{e.Id}] ?"; return $"[{e.Id}] ?";
@ -716,6 +850,9 @@ public partial class MemoryViewModel : ObservableObject
{ {
var parts = new List<string>(); var parts = new List<string>();
if (e.Rarity != 0)
parts.Add(e.Rarity.ToString());
if (e.HasVitals) if (e.HasVitals)
parts.Add(e.IsAlive ? "Alive" : "Dead"); parts.Add(e.IsAlive ? "Alive" : "Dead");
@ -725,6 +862,9 @@ public partial class MemoryViewModel : ObservableObject
if (e.HasVitals) if (e.HasVitals)
parts.Add($"HP:{e.LifeCurrent}/{e.LifeTotal}"); parts.Add($"HP:{e.LifeCurrent}/{e.LifeTotal}");
if (e.ActionId != 0)
parts.Add($"act:0x{e.ActionId:X}");
if (e.Components is { Count: > 0 }) if (e.Components is { Count: > 0 })
parts.Add($"{e.Components.Count} comps"); parts.Add($"{e.Components.Count} comps");
@ -741,6 +881,9 @@ public partial class MemoryViewModel : ObservableObject
if (e.Path is not null) if (e.Path is not null)
needed.Add(("Path:", e.Path, true)); needed.Add(("Path:", e.Path, true));
if (e.TransitionName is not null)
needed.Add(("Destination:", e.TransitionName, true));
if (e.HasPosition) if (e.HasPosition)
needed.Add(("Pos:", $"({e.X:F1}, {e.Y:F1}, {e.Z:F1})", true)); needed.Add(("Pos:", $"({e.X:F1}, {e.Y:F1}, {e.Z:F1})", true));
@ -753,6 +896,15 @@ public partial class MemoryViewModel : ObservableObject
needed.Add(("ES:", $"{e.EsCurrent} / {e.EsTotal}", true)); needed.Add(("ES:", $"{e.EsCurrent} / {e.EsTotal}", true));
} }
if (e.Rarity != 0)
needed.Add(("Rarity:", e.Rarity.ToString(), true));
if (e.ModNames is { Count: > 0 })
{
foreach (var mod in e.ModNames)
needed.Add(("Mod:", mod, true));
}
if (e.Components is { Count: > 0 }) if (e.Components is { Count: > 0 })
{ {
var compList = string.Join(", ", e.Components.OrderBy(c => c)); var compList = string.Join(", ", e.Components.OrderBy(c => c));
@ -959,6 +1111,18 @@ public partial class MemoryViewModel : ObservableObject
ScanResult = _reader.Diagnostics!.ScanActiveStatesVector(); ScanResult = _reader.Diagnostics!.ScanActiveStatesVector();
} }
[RelayCommand]
private void ScanStateDiffExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.Diagnostics!.ScanStateDiff();
}
[RelayCommand] [RelayCommand]
private void ScanTerrainExecute() private void ScanTerrainExecute()
{ {
@ -1009,4 +1173,28 @@ public partial class MemoryViewModel : ObservableObject
ScanResult = _reader.Diagnostics!.CameraDiff(); ScanResult = _reader.Diagnostics!.CameraDiff();
} }
[RelayCommand]
private void ScanActorSkillsExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.Diagnostics!.ScanActorSkills();
}
[RelayCommand]
private void ScanActorDiffExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.Diagnostics!.ScanActorDiff();
}
} }

View file

@ -222,7 +222,9 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
Entities.Clear(); Entities.Clear();
foreach (var e in state.Entities) foreach (var e in state.Entities)
{ {
var shortLabel = GetShortLabel(e.Path); var shortLabel = e.Category == EntityCategory.AreaTransition && e.TransitionName is not null
? $"AreaTransition — {e.TransitionName}"
: GetShortLabel(e.Path);
var item = new EntityListItem(e.Id, shortLabel, e.Category.ToString(), e.DistanceToPlayer, e.Position.X, e.Position.Y); var item = new EntityListItem(e.Id, shortLabel, e.Category.ToString(), e.DistanceToPlayer, e.Position.X, e.Position.Y);
if (checkedIds.Contains(e.Id)) if (checkedIds.Contains(e.Id))
item.IsChecked = true; item.IsChecked = true;

View file

@ -766,10 +766,16 @@
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" /> Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Scan ActiveVec" Command="{Binding ScanActiveVecExecuteCommand}" <Button Content="Scan ActiveVec" Command="{Binding ScanActiveVecExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" /> Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="State Diff" Command="{Binding ScanStateDiffExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Scan Camera" Command="{Binding ScanCameraExecuteCommand}" <Button Content="Scan Camera" Command="{Binding ScanCameraExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" /> Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Camera Diff" Command="{Binding CameraDiffExecuteCommand}" <Button Content="Camera Diff" Command="{Binding CameraDiffExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" /> Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Scan Skills" Command="{Binding ScanActorSkillsExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Actor Diff" Command="{Binding ScanActorDiffExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
</WrapPanel> </WrapPanel>
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas" <TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
FontSize="10" Foreground="#e6edf3" Background="#0d1117" FontSize="10" Foreground="#e6edf3" Background="#0d1117"

View file

@ -10,11 +10,25 @@ public enum EntityCategory
Npc, Npc,
WorldItem, WorldItem,
Chest, Chest,
Shrine,
Portal, Portal,
AreaTransition, AreaTransition,
Effect, Effect,
Terrain, Terrain,
MiscObject, MiscObject,
Waypoint,
Door,
Doodad,
TownPortal,
Critter,
}
public enum MonsterRarity
{
White,
Magic,
Rare,
Unique,
} }
public enum MonsterThreatLevel public enum MonsterThreatLevel
@ -39,4 +53,15 @@ public record EntitySnapshot
public int LifeTotal { get; init; } public int LifeTotal { get; init; }
public bool IsTargetable { get; init; } public bool IsTargetable { get; init; }
public HashSet<string>? Components { get; init; } public HashSet<string>? Components { get; init; }
// Classification
public MonsterRarity Rarity { get; init; }
public List<string>? ModNames { get; init; }
public string? TransitionName { get; init; }
public string? Metadata { get; init; }
// Action state (from Actor component)
public short ActionId { get; init; }
public bool IsAttacking { get; init; }
public bool IsMoving { get; init; }
} }

View file

@ -1,9 +0,0 @@
namespace Roboto.Core;
public interface IMemoryProvider
{
bool IsAttached { get; }
bool Attach();
void Detach();
GameState ReadGameState(GameState? previous);
}

View file

@ -4,9 +4,12 @@ public record SkillState
{ {
public int SlotIndex { get; init; } public int SlotIndex { get; init; }
public ushort ScanCode { get; init; } public ushort ScanCode { get; init; }
public short SkillId { get; init; }
public string? Name { get; init; } public string? Name { get; init; }
public string? InternalName { get; init; }
public int ChargesCurrent { get; init; } public int ChargesCurrent { get; init; }
public int ChargesMax { get; init; } public int ChargesMax { get; init; }
public float CooldownRemaining { get; init; } public float CooldownRemaining { get; init; }
public bool CanUse => CooldownRemaining <= 0 && ChargesCurrent > 0; public bool CanBeUsed { get; init; }
public bool CanUse => CanBeUsed && CooldownRemaining <= 0;
} }

View file

@ -0,0 +1,42 @@
using System.Text.Json;
namespace Roboto.Data;
/// <summary>
/// Resolves area IDs (e.g. "G1_4") to display names (e.g. "The Grelwood")
/// using data/poe2/areas.json.
/// </summary>
public static class AreaNameLookup
{
private static readonly Dictionary<string, string> AreaNames = LoadAreaNames();
public static string? Resolve(string? areaId)
{
if (areaId is null) return null;
return AreaNames.TryGetValue(areaId, out var name) ? name : null;
}
private static Dictionary<string, string> LoadAreaNames()
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
var path = Path.Combine("data", "poe2", "areas.json");
if (!File.Exists(path)) return map;
var json = File.ReadAllText(path);
using var doc = JsonDocument.Parse(json);
foreach (var act in doc.RootElement.EnumerateArray())
{
foreach (var area in act.GetProperty("areas").EnumerateArray())
{
var id = area.GetProperty("id").GetString();
var name = area.GetProperty("name").GetString();
if (id is not null && name is not null)
map[id] = name;
}
}
}
catch { /* non-critical */ }
return map;
}
}

View file

@ -0,0 +1,90 @@
using Roboto.Core;
namespace Roboto.Data;
/// <summary>
/// Classifies entities from path + component data into EntityCategory.
/// Single source of truth for entity classification in the Data layer.
/// </summary>
public static class EntityClassifier
{
public static EntityCategory Classify(string? path, HashSet<string>? components)
{
var baseCategory = ClassifyFromPath(path);
if (components is { Count: > 0 })
return ReclassifyFromComponents(baseCategory, components);
return baseCategory;
}
private static EntityCategory ClassifyFromPath(string? path)
{
if (path is null) return EntityCategory.Unknown;
var firstSlash = path.IndexOf('/');
if (firstSlash < 0) return EntityCategory.Unknown;
var secondSlash = path.IndexOf('/', firstSlash + 1);
var segment = secondSlash > 0
? path[(firstSlash + 1)..secondSlash]
: path[(firstSlash + 1)..];
switch (segment)
{
case "Characters":
return EntityCategory.Player;
case "Monsters":
if (path.Contains("/Critters/", StringComparison.OrdinalIgnoreCase))
return EntityCategory.Critter;
if (path.Contains("/NPC/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/TownNPC/", StringComparison.OrdinalIgnoreCase))
return EntityCategory.Npc;
return EntityCategory.Monster;
case "NPC":
return EntityCategory.Npc;
case "Effects":
return EntityCategory.Effect;
case "MiscellaneousObjects":
if (path.Contains("Doodad", StringComparison.OrdinalIgnoreCase))
return EntityCategory.Doodad;
if (path.Contains("/Chest", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/Stash", StringComparison.OrdinalIgnoreCase))
return EntityCategory.Chest;
if (path.Contains("/Shrine", StringComparison.OrdinalIgnoreCase))
return EntityCategory.Shrine;
if (path.Contains("/Portal", StringComparison.OrdinalIgnoreCase))
return EntityCategory.Portal;
return EntityCategory.MiscObject;
case "Terrain":
if (path.Contains("/Doodad", StringComparison.OrdinalIgnoreCase))
return EntityCategory.Doodad;
return EntityCategory.Terrain;
case "Items":
return EntityCategory.WorldItem;
default:
return EntityCategory.Unknown;
}
}
private static EntityCategory ReclassifyFromComponents(EntityCategory baseCategory, HashSet<string> components)
{
// Priority order matching ExileCore's ParseType logic
if (components.Contains("Monster")) return EntityCategory.Monster;
if (components.Contains("Chest")) return EntityCategory.Chest;
if (components.Contains("Shrine")) return EntityCategory.Shrine;
if (components.Contains("Waypoint")) return EntityCategory.Waypoint;
if (components.Contains("AreaTransition")) return EntityCategory.AreaTransition;
if (components.Contains("Portal")) return EntityCategory.Portal;
if (components.Contains("TownPortal")) return EntityCategory.TownPortal;
if (components.Contains("NPC")) return EntityCategory.Npc;
if (components.Contains("Player")) return EntityCategory.Player;
// Don't override path-based classification for Effects/Terrain/etc.
return baseCategory;
}
}

View file

@ -0,0 +1,54 @@
using System.Numerics;
using Roboto.Core;
using MemEntity = Roboto.Memory.Entity;
namespace Roboto.Data;
/// <summary>
/// Maps raw Memory.Entity → Core.EntitySnapshot. Single source of truth for entity mapping.
/// </summary>
public static class EntityMapper
{
public static EntitySnapshot MapEntity(MemEntity e, Vector2 playerPos)
{
var pos = e.HasPosition ? new Vector2(e.X, e.Y) : Vector2.Zero;
var dist = e.HasPosition ? Vector2.Distance(pos, playerPos) : float.MaxValue;
var category = EntityClassifier.Classify(e.Path, e.Components);
var rarity = (MonsterRarity)e.Rarity;
return new EntitySnapshot
{
Id = e.Id,
Path = e.Path,
Metadata = e.Metadata,
Category = category,
ThreatLevel = MapThreatLevel(category, rarity),
Rarity = rarity,
Position = pos,
DistanceToPlayer = dist,
IsAlive = e.IsAlive || !e.HasVitals,
LifeCurrent = e.LifeCurrent,
LifeTotal = e.LifeTotal,
IsTargetable = e.IsTargetable,
Components = e.Components,
ModNames = e.ModNames,
TransitionName = AreaNameLookup.Resolve(e.TransitionName) ?? e.TransitionName,
ActionId = e.ActionId,
IsAttacking = e.IsAttacking,
IsMoving = e.IsMoving,
};
}
public static MonsterThreatLevel MapThreatLevel(EntityCategory category, MonsterRarity rarity)
{
if (category != EntityCategory.Monster) return MonsterThreatLevel.None;
return rarity switch
{
MonsterRarity.White => MonsterThreatLevel.Normal,
MonsterRarity.Magic => MonsterThreatLevel.Magic,
MonsterRarity.Rare => MonsterThreatLevel.Rare,
MonsterRarity.Unique => MonsterThreatLevel.Unique,
_ => MonsterThreatLevel.Normal,
};
}
}

View file

@ -3,8 +3,6 @@ using System.Numerics;
using Roboto.Memory; using Roboto.Memory;
using Roboto.Core; using Roboto.Core;
using Serilog; using Serilog;
using MemEntity = Roboto.Memory.Entity;
using MemEntityType = Roboto.Memory.EntityType;
namespace Roboto.Data; namespace Roboto.Data;
@ -288,7 +286,7 @@ public sealed class MemoryPoller : IDisposable
{ {
if (e.Address == snap.LocalPlayerPtr) continue; if (e.Address == snap.LocalPlayerPtr) continue;
var es = MapEntity(e, playerPos); var es = EntityMapper.MapEntity(e, playerPos);
allEntities.Add(es); allEntities.Add(es);
if (es.Category == EntityCategory.Monster && es.IsAlive) if (es.Category == EntityCategory.Monster && es.IsAlive)
@ -315,54 +313,6 @@ public sealed class MemoryPoller : IDisposable
return state; return state;
} }
private static EntitySnapshot MapEntity(MemEntity e, Vector2 playerPos)
{
var pos = e.HasPosition ? new Vector2(e.X, e.Y) : Vector2.Zero;
var dist = e.HasPosition ? Vector2.Distance(pos, playerPos) : float.MaxValue;
return new EntitySnapshot
{
Id = e.Id,
Path = e.Path,
Category = MapCategory(e.Type),
ThreatLevel = MapThreatLevel(e),
Position = pos,
DistanceToPlayer = dist,
IsAlive = e.IsAlive || !e.HasVitals,
LifeCurrent = e.LifeCurrent,
LifeTotal = e.LifeTotal,
IsTargetable = e.IsTargetable,
Components = e.Components,
};
}
private static EntityCategory MapCategory(MemEntityType type) => type switch
{
MemEntityType.Player => EntityCategory.Player,
MemEntityType.Monster => EntityCategory.Monster,
MemEntityType.Npc => EntityCategory.Npc,
MemEntityType.WorldItem => EntityCategory.WorldItem,
MemEntityType.Chest => EntityCategory.Chest,
MemEntityType.Portal or MemEntityType.TownPortal => EntityCategory.Portal,
MemEntityType.AreaTransition => EntityCategory.AreaTransition,
MemEntityType.Effect => EntityCategory.Effect,
MemEntityType.Terrain => EntityCategory.Terrain,
_ => EntityCategory.MiscObject,
};
private static MonsterThreatLevel MapThreatLevel(MemEntity e)
{
if (e.Type != MemEntityType.Monster) return MonsterThreatLevel.None;
return e.Rarity switch
{
MonsterRarity.White => MonsterThreatLevel.Normal,
MonsterRarity.Magic => MonsterThreatLevel.Magic,
MonsterRarity.Rare => MonsterThreatLevel.Rare,
MonsterRarity.Unique => MonsterThreatLevel.Unique,
_ => MonsterThreatLevel.Normal,
};
}
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;

View file

@ -3,66 +3,85 @@ using Roboto.GameOffsets.Natives;
namespace Roboto.GameOffsets.Components; namespace Roboto.GameOffsets.Components;
/// <summary>Actor component — skills, animations, deployments.</summary> /// <summary>
[StructLayout(LayoutKind.Explicit, Size = 0x2E8)] /// Actor component offsets — confirmed from ExileCore2.
public struct Actor /// The struct is too large (0xC28) for a single Read, so fields are read at offsets directly.
/// </summary>
public static class ActorOffsets
{ {
[FieldOffset(0x00)] public ComponentHeader Header; public const int AnimationId = 0x370;
public const int ActiveSkillsVector = 0xB00;
/// <summary>Pointer to animation controller.</summary> public const int CooldownsVector = 0xB18;
[FieldOffset(0x1D8)] public nint AnimationControllerPtr; public const int DeployedEntitiesVector = 0xC10;
/// <summary>Active skills StdVector (of ActiveSkillStructure).</summary>
[FieldOffset(0x2C0)] public StdVector ActiveSkills;
/// <summary>Deployed entities StdVector (of DeployedEntityStructure).</summary>
[FieldOffset(0x2D8)] public StdVector DeployedEntities;
} }
/// <summary>An entry in the active skills vector.</summary> /// <summary>
[StructLayout(LayoutKind.Explicit, Size = 0x28)] /// An entry in the ActiveSkills vector: shared_ptr pair (0x10 bytes).
public struct ActiveSkillStructure /// Follow ActiveSkillPtr (first pointer) for skill details.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ActiveSkillEntry
{ {
[FieldOffset(0x00)] public nint SkillDetailsPtr; public nint ActiveSkillPtr;
[FieldOffset(0x08)] public short SkillId; public nint ControlBlockPtr; // shared_ptr control block, not used
[FieldOffset(0x0C)] public byte CanBeUsed;
[FieldOffset(0x0D)] public byte CanBeUsedWithWeapon;
[FieldOffset(0x10)] public nint CooldownPtr;
} }
/// <summary>Detailed info about a skill.</summary> /// <summary>
[StructLayout(LayoutKind.Explicit, Size = 0x20)] /// Details of an active skill, reached by following ActiveSkillEntry.ActiveSkillPtr.
/// From ExileCore2 GameOffsets.Objects.Components.ActiveSkillDetails.
/// </summary>
[StructLayout(LayoutKind.Explicit, Pack = 1)]
public struct ActiveSkillDetails public struct ActiveSkillDetails
{ {
[FieldOffset(0x00)] public nint NamePtr; [FieldOffset(0x08)] public int UseStage;
[FieldOffset(0x08)] public nint InternalNamePtr; [FieldOffset(0x0C)] public int CastType;
[FieldOffset(0x10)] public int GrantedEffectsPerLevelIdx; [FieldOffset(0x10)] public uint UnknownIdAndEquipmentInfo;
[FieldOffset(0x14)] public int IconIndex; [FieldOffset(0x18)] public nint GrantedEffectsPerLevelDatRow;
[FieldOffset(0x20)] public nint ActiveSkillsDatPtr;
[FieldOffset(0x30)] public nint GrantedEffectStatSetsPerLevelDatRow;
[FieldOffset(0x98)] public int TotalUses;
[FieldOffset(0xA8)] public int TotalCooldownTimeInMs;
} }
/// <summary>Cooldown state for a skill.</summary> /// <summary>
[StructLayout(LayoutKind.Explicit, Size = 0x20)] /// Cooldown state for a skill. Entries in Actor+0xB18 vector.
/// From ExileCore2 GameOffsets.Objects.Components.ActiveSkillCooldown.
/// </summary>
[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x48)]
public struct ActiveSkillCooldown public struct ActiveSkillCooldown
{ {
[FieldOffset(0x00)] public nint CooldownGroupPtr; [FieldOffset(0x08)] public int ActiveSkillsDatId;
[FieldOffset(0x08)] public int MaxUses; [FieldOffset(0x10)] public StdVector CooldownsList; // 0x10-byte entries
[FieldOffset(0x0C)] public int CurrentUses; [FieldOffset(0x30)] public int MaxUses;
[FieldOffset(0x10)] public int CooldownTimer; [FieldOffset(0x34)] public int TotalCooldownTimeInMs;
[FieldOffset(0x3C)] public uint UnknownIdAndEquipmentInfo;
/// <summary>Number of active cooldown timer entries.</summary>
public readonly int TotalActiveCooldowns => (int)CooldownsList.TotalElements(0x10);
/// <summary>True if all uses are on cooldown.</summary>
public readonly bool CannotBeUsed => TotalActiveCooldowns >= MaxUses;
} }
/// <summary>Vaal soul tracking.</summary> /// <summary>Vaal soul tracking.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)] [StructLayout(LayoutKind.Explicit, Pack = 1)]
public struct VaalSoulStructure public struct VaalSoulStructure
{ {
public nint GrantedEffectsPtr; [FieldOffset(0x00)] public nint ActiveSkillsDatPtr;
public int CurrentSouls; [FieldOffset(0x08)] public nint UselessPtr;
public int SoulCost; [FieldOffset(0x10)] public int RequiredSouls;
[FieldOffset(0x14)] public int CurrentSouls;
public readonly bool CannotBeUsed => CurrentSouls < RequiredSouls;
} }
/// <summary>A deployed entity (totem, mine, etc.).</summary> /// <summary>A deployed entity (totem, mine, etc.).</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)] [StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct DeployedEntityStructure public struct DeployedEntityStructure
{ {
public uint EntityId; public int EntityId;
public int SkillIndex; public int ActiveSkillsDatId;
public int DeployedObjectType;
public int PAD_0x014;
public int Counter;
} }

View file

@ -18,16 +18,13 @@ public enum EntityType
Waypoint, Waypoint,
AreaTransition, AreaTransition,
Door, Door,
Doodad,
} }
public enum MonsterRarity /// <summary>
{ /// Raw entity data read from process memory. No business logic or classification —
White, /// type classification lives in EntityReader (Memory-internal) and EntityClassifier (Data layer).
Magic, /// </summary>
Rare,
Unique,
}
public class Entity public class Entity
{ {
public nint Address { get; } public nint Address { get; }
@ -58,14 +55,25 @@ public class Entity
public bool IsTargetable { get; internal set; } public bool IsTargetable { get; internal set; }
public bool IsOpened { get; internal set; } public bool IsOpened { get; internal set; }
public bool IsAvailable { get; internal set; } public bool IsAvailable { get; internal set; }
public MonsterRarity Rarity { get; internal set; } public int Rarity { get; internal set; }
// Mods (from Mods component)
public List<string>? ModNames { get; internal set; }
// AreaTransition destination (raw area ID, e.g. "G1_4")
public string? TransitionName { get; internal set; }
// Action state (from Actor component)
public short ActionId { get; internal set; }
public bool IsAttacking { get; internal set; }
public bool IsMoving { get; internal set; }
// Classification (set by EntityReader)
public EntityType Type { get; internal set; }
// Derived properties // Derived properties
public bool IsAlive => HasVitals && LifeCurrent > 0; public bool IsAlive => HasVitals && LifeCurrent > 0;
public bool IsDead => HasVitals && LifeCurrent <= 0; public bool IsDead => HasVitals && LifeCurrent <= 0;
public bool IsHostile => Type == EntityType.Monster;
public bool IsNpc => Type == EntityType.Npc;
public bool IsPlayer => Type == EntityType.Player;
public bool HasComponent(string name) => Components?.Contains(name) == true; public bool HasComponent(string name) => Components?.Contains(name) == true;
/// <summary> /// <summary>
@ -86,7 +94,7 @@ public class Entity
/// <summary> /// <summary>
/// Short category string derived from path (e.g. "Monsters", "Effects", "NPC"). /// Short category string derived from path (e.g. "Monsters", "Effects", "NPC").
/// </summary> /// </summary>
public string Category public string PathCategory
{ {
get get
{ {
@ -96,36 +104,12 @@ public class Entity
} }
} }
public EntityType Type { get; internal set; }
internal Entity(nint address, uint id, string? path) internal Entity(nint address, uint id, string? path)
{ {
Address = address; Address = address;
Id = id; Id = id;
Path = path; Path = path;
Metadata = ExtractMetadata(path); Metadata = ExtractMetadata(path);
Type = ClassifyType(path);
}
/// <summary>
/// Reclassify entity type using component names (called after components are read).
/// Component-based classification is more reliable than path-based.
/// </summary>
internal void ReclassifyFromComponents()
{
if (Components is null || Components.Count == 0) return;
// Priority order matching ExileCore's ParseType logic
if (Components.Contains("Monster")) { Type = EntityType.Monster; return; }
if (Components.Contains("Chest")) { Type = EntityType.Chest; return; }
if (Components.Contains("Shrine")) { Type = EntityType.Shrine; return; }
if (Components.Contains("Waypoint")) { Type = EntityType.Waypoint; return; }
if (Components.Contains("AreaTransition")) { Type = EntityType.AreaTransition; return; }
if (Components.Contains("Portal")) { Type = EntityType.Portal; return; }
if (Components.Contains("TownPortal")) { Type = EntityType.TownPortal; return; }
if (Components.Contains("NPC")) { Type = EntityType.Npc; return; }
if (Components.Contains("Player")) { Type = EntityType.Player; return; }
// Don't override path-based classification for Effects/Terrain/etc.
} }
/// <summary> /// <summary>
@ -139,60 +123,6 @@ public class Entity
return atIndex > 0 ? path[..atIndex] : path; return atIndex > 0 ? path[..atIndex] : path;
} }
private static EntityType ClassifyType(string? path)
{
if (path is null) return EntityType.Unknown;
// Check second path segment: "Metadata/<Category>/..."
var firstSlash = path.IndexOf('/');
if (firstSlash < 0) return EntityType.Unknown;
var secondSlash = path.IndexOf('/', firstSlash + 1);
var category = secondSlash > 0
? path[(firstSlash + 1)..secondSlash]
: path[(firstSlash + 1)..];
switch (category)
{
case "Characters":
return EntityType.Player;
case "Monsters":
// Sub-classify: some "monsters" are actually NPCs or critters
if (path.Contains("/Critters/", StringComparison.OrdinalIgnoreCase))
return EntityType.Critter;
if (path.Contains("/NPC/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/TownNPC/", StringComparison.OrdinalIgnoreCase))
return EntityType.Npc;
return EntityType.Monster;
case "NPC":
return EntityType.Npc;
case "Effects":
return EntityType.Effect;
case "MiscellaneousObjects":
if (path.Contains("/Chest", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/Stash", StringComparison.OrdinalIgnoreCase))
return EntityType.Chest;
if (path.Contains("/Shrine", StringComparison.OrdinalIgnoreCase))
return EntityType.Shrine;
if (path.Contains("/Portal", StringComparison.OrdinalIgnoreCase))
return EntityType.Portal;
return EntityType.MiscellaneousObject;
case "Terrain":
return EntityType.Terrain;
case "Items":
return EntityType.WorldItem;
default:
return EntityType.Unknown;
}
}
public override string ToString() public override string ToString()
{ {
var pos = HasPosition ? $"({X:F0},{Y:F0})" : "no pos"; var pos = HasPosition ? $"({X:F0},{Y:F0})" : "no pos";

View file

@ -49,7 +49,11 @@ public sealed class EntityReader
var entityId = treeNode.Data.Key.EntityId; var entityId = treeNode.Data.Key.EntityId;
var path = TryReadEntityPath(entityPtr); var path = TryReadEntityPath(entityPtr);
// Never process doodads — they are decorative and waste RPM calls
if (IsDoodadPath(path)) return;
var entity = new Entity(entityPtr, entityId, path); var entity = new Entity(entityPtr, entityId, path);
entity.Type = ClassifyType(path);
if (registry["entities"].Register(entity.Metadata)) if (registry["entities"].Register(entity.Metadata))
dirty = true; dirty = true;
@ -62,26 +66,24 @@ public sealed class EntityReader
entity.Z = z; entity.Z = z;
} }
// Read component names for non-trivial entities // Read component names for non-trivial entities (skip effects, terrain, critters)
if (hasComponentLookup && if (hasComponentLookup && !IsLowPriorityPath(entity.Type))
entity.Type != EntityType.Effect &&
entity.Type != EntityType.Terrain &&
entity.Type != EntityType.Critter)
{ {
var lookup = _components.ReadComponentLookup(entityPtr); var lookup = _components.ReadComponentLookup(entityPtr);
if (lookup is not null) if (lookup is not null)
{ {
entity.Components = new HashSet<string>(lookup.Keys); entity.Components = new HashSet<string>(lookup.Keys);
entity.ReclassifyFromComponents(); ReclassifyFromComponents(entity);
if (registry["components"].Register(lookup.Keys)) if (registry["components"].Register(lookup.Keys))
dirty = true; dirty = true;
// Read HP for monsters to determine alive/dead var (compFirst, compCount) = _components.FindComponentList(entityPtr);
if (entity.Type == EntityType.Monster && lookup.TryGetValue("Life", out var lifeIdx))
// Read HP/Actor/Mods for monsters
if (entity.Components.Contains("Monster"))
{ {
var (compFirst, compCount) = _components.FindComponentList(entityPtr); if (lookup.TryGetValue("Life", out var lifeIdx) && lifeIdx >= 0 && lifeIdx < compCount)
if (lifeIdx >= 0 && lifeIdx < compCount)
{ {
var lifeComp = mem.ReadPointer(compFirst + lifeIdx * 8); var lifeComp = mem.ReadPointer(compFirst + lifeIdx * 8);
if (lifeComp != 0) if (lifeComp != 0)
@ -96,6 +98,34 @@ public sealed class EntityReader
} }
} }
} }
// Read Actor AnimationId — offset 0x370 confirmed from ExileCore2
if (lookup.TryGetValue("Actor", out var actorIdx) && actorIdx >= 0 && actorIdx < compCount)
{
var actorComp = mem.ReadPointer(compFirst + actorIdx * 8);
if (actorComp != 0)
{
var animId = mem.Read<int>(actorComp + ActorOffsets.AnimationId);
entity.ActionId = (short)(animId & 0xFFFF);
}
}
// Read Mods — rarity + explicit mod names
if (lookup.TryGetValue("Mods", out var modsIdx) && modsIdx >= 0 && modsIdx < compCount)
{
var modsComp = mem.ReadPointer(compFirst + modsIdx * 8);
if (modsComp != 0)
ReadEntityMods(entity, modsComp);
}
}
// Read AreaTransition destination name
if (entity.Components.Contains("AreaTransition") &&
lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compCount)
{
var atComp = mem.ReadPointer(compFirst + atIdx * 8);
if (atComp != 0)
entity.TransitionName = ReadAreaTransitionName(atComp);
} }
} }
} }
@ -158,6 +188,93 @@ public sealed class EntityReader
} }
} }
/// <summary>
/// Reads area transition destination by scanning the AreaTransition component
/// for pointer chains leading to a readable string. Returns the raw area ID
/// (e.g. "G1_4"); display name resolution is done in the Data layer.
/// </summary>
private string? ReadAreaTransitionName(nint atComp)
{
var mem = _ctx.Memory;
var data = mem.ReadBytes(atComp, 0x80);
if (data is null) return null;
for (var off = 0x10; off + 8 <= data.Length; off += 8)
{
var ptr = (nint)BitConverter.ToInt64(data, off);
if (ptr == 0) continue;
if (((ulong)ptr >> 32) is 0 or >= 0x7FFF) continue;
var raw = _strings.ReadNullTermWString(ptr);
if (raw is not null && raw.Length >= 3)
return raw;
var inner = mem.ReadPointer(ptr);
if (inner != 0 && ((ulong)inner >> 32) is > 0 and < 0x7FFF)
{
raw = _strings.ReadNullTermWString(inner);
if (raw is not null && raw.Length >= 3)
return raw;
}
}
return null;
}
/// <summary>
/// Reads Mods component: rarity from ObjectMagicProperties, mod names from ExplicitMods vector.
/// ModPtr in each mod entry → .dat row → first field is a wchar* mod identifier.
/// </summary>
private void ReadEntityMods(Entity entity, nint modsComp)
{
var mem = _ctx.Memory;
var mods = mem.Read<Mods>(modsComp);
// Read rarity from ObjectMagicProperties
if (mods.ObjectMagicPropertiesPtr != 0 &&
((ulong)mods.ObjectMagicPropertiesPtr >> 32) is > 0 and < 0x7FFF)
{
var props = mem.Read<ObjectMagicProperties>(mods.ObjectMagicPropertiesPtr);
if (props.Rarity is >= 0 and <= 3)
entity.Rarity = props.Rarity;
}
// Read explicit mod names from AllModsPtr
if (mods.AllModsPtr == 0 || ((ulong)mods.AllModsPtr >> 32) is 0 or >= 0x7FFF)
return;
var allMods = mem.Read<AllModsType>(mods.AllModsPtr);
var explicitCount = (int)allMods.ExplicitMods.TotalElements(16); // ModArrayStruct = 16 bytes
if (explicitCount <= 0 || explicitCount > 20) return;
var modNames = new List<string>();
for (var i = 0; i < explicitCount; i++)
{
var modEntry = mem.Read<ModArrayStruct>(allMods.ExplicitMods.First + i * 16);
if (modEntry.ModPtr == 0) continue;
if (((ulong)modEntry.ModPtr >> 32) is 0 or >= 0x7FFF) continue;
// Mods.dat row: first field is typically a wchar* or std::wstring key
// Try reading as wchar* first (null-terminated UTF-16)
var name = _strings.ReadNullTermWString(modEntry.ModPtr);
if (name is not null)
{
modNames.Add(name);
continue;
}
// Try as std::wstring
name = _strings.ReadMsvcWString(modEntry.ModPtr);
if (name is not null)
modNames.Add(name);
}
if (modNames.Count > 0)
entity.ModNames = modNames;
}
/// <summary> /// <summary>
/// Reads entity path string via EntityDetailsPtr → std::wstring. /// Reads entity path string via EntityDetailsPtr → std::wstring.
/// </summary> /// </summary>
@ -210,4 +327,93 @@ public sealed class EntityReader
return false; return false;
} }
// ── Classification helpers (Memory-internal) ─────────────────────────
private static bool IsDoodadPath(string? path)
{
if (path is null) return false;
return path.Contains("Doodad", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Returns true for entity types that don't need component reading (effects, terrain, critters).
/// </summary>
private static bool IsLowPriorityPath(EntityType type)
=> type is EntityType.Effect or EntityType.Terrain or EntityType.Critter;
/// <summary>
/// Path-based entity type classification. Mirrors the logic previously in Entity.ClassifyType.
/// </summary>
private static EntityType ClassifyType(string? path)
{
if (path is null) return EntityType.Unknown;
var firstSlash = path.IndexOf('/');
if (firstSlash < 0) return EntityType.Unknown;
var secondSlash = path.IndexOf('/', firstSlash + 1);
var category = secondSlash > 0
? path[(firstSlash + 1)..secondSlash]
: path[(firstSlash + 1)..];
switch (category)
{
case "Characters":
return EntityType.Player;
case "Monsters":
if (path.Contains("/Critters/", StringComparison.OrdinalIgnoreCase))
return EntityType.Critter;
if (path.Contains("/NPC/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/TownNPC/", StringComparison.OrdinalIgnoreCase))
return EntityType.Npc;
return EntityType.Monster;
case "NPC":
return EntityType.Npc;
case "Effects":
return EntityType.Effect;
case "MiscellaneousObjects":
if (path.Contains("/Chest", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/Stash", StringComparison.OrdinalIgnoreCase))
return EntityType.Chest;
if (path.Contains("/Shrine", StringComparison.OrdinalIgnoreCase))
return EntityType.Shrine;
if (path.Contains("/Portal", StringComparison.OrdinalIgnoreCase))
return EntityType.Portal;
return EntityType.MiscellaneousObject;
case "Terrain":
return EntityType.Terrain;
case "Items":
return EntityType.WorldItem;
default:
return EntityType.Unknown;
}
}
/// <summary>
/// Reclassify entity type using component names (called after components are read).
/// Component-based classification is more reliable than path-based.
/// </summary>
private static void ReclassifyFromComponents(Entity entity)
{
var components = entity.Components;
if (components is null || components.Count == 0) return;
if (components.Contains("Monster")) { entity.Type = EntityType.Monster; return; }
if (components.Contains("Chest")) { entity.Type = EntityType.Chest; return; }
if (components.Contains("Shrine")) { entity.Type = EntityType.Shrine; return; }
if (components.Contains("Waypoint")) { entity.Type = EntityType.Waypoint; return; }
if (components.Contains("AreaTransition")) { entity.Type = EntityType.AreaTransition; return; }
if (components.Contains("Portal")) { entity.Type = EntityType.Portal; return; }
if (components.Contains("TownPortal")) { entity.Type = EntityType.TownPortal; return; }
if (components.Contains("NPC")) { entity.Type = EntityType.Npc; return; }
if (components.Contains("Player")) { entity.Type = EntityType.Player; return; }
}
} }

View file

@ -1,4 +1,5 @@
using System.Numerics; using System.Numerics;
using Roboto.Memory.States;
using Serilog; using Serilog;
namespace Roboto.Memory; namespace Roboto.Memory;
@ -28,6 +29,7 @@ public class GameMemoryReader : IDisposable
// Sub-readers (created on Attach) // Sub-readers (created on Attach)
private MemoryContext? _ctx; private MemoryContext? _ctx;
private GameStates? _gameStates;
private GameStateReader? _stateReader; private GameStateReader? _stateReader;
private nint _cachedCameraMatrixAddr; private nint _cachedCameraMatrixAddr;
private nint _lastInGameState; private nint _lastInGameState;
@ -37,6 +39,7 @@ public class GameMemoryReader : IDisposable
private TerrainReader? _terrain; private TerrainReader? _terrain;
private MsvcStringReader? _strings; private MsvcStringReader? _strings;
private RttiResolver? _rtti; private RttiResolver? _rtti;
private SkillReader? _skills;
public ObjectRegistry Registry => _registry; public ObjectRegistry Registry => _registry;
public MemoryDiagnostics? Diagnostics { get; private set; } public MemoryDiagnostics? Diagnostics { get; private set; }
@ -88,12 +91,14 @@ public class GameMemoryReader : IDisposable
} }
// Create sub-readers // Create sub-readers
_gameStates = new GameStates(_ctx);
_strings = new MsvcStringReader(_ctx); _strings = new MsvcStringReader(_ctx);
_rtti = new RttiResolver(_ctx); _rtti = new RttiResolver(_ctx);
_stateReader = new GameStateReader(_ctx); _stateReader = new GameStateReader(_ctx);
_components = new ComponentReader(_ctx, _strings); _components = new ComponentReader(_ctx, _strings);
_entities = new EntityReader(_ctx, _components, _strings); _entities = new EntityReader(_ctx, _components, _strings);
_terrain = new TerrainReader(_ctx); _terrain = new TerrainReader(_ctx);
_skills = new SkillReader(_ctx, _components, _strings);
Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti); Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
return true; return true;
@ -103,12 +108,14 @@ public class GameMemoryReader : IDisposable
{ {
_ctx?.Memory.Dispose(); _ctx?.Memory.Dispose();
_ctx = null; _ctx = null;
_gameStates = null;
_stateReader = null; _stateReader = null;
_components = null; _components = null;
_entities = null; _entities = null;
_terrain = null; _terrain = null;
_strings = null; _strings = null;
_rtti = null; _rtti = null;
_skills = null;
Diagnostics = null; Diagnostics = null;
} }
@ -145,76 +152,76 @@ public class GameMemoryReader : IDisposable
try try
{ {
// Resolve InGameState from controller // Hierarchical state read — resolves controller, state slots, cascades to children
var inGameState = _stateReader!.ResolveInGameState(snap); var gs = _gameStates!;
if (inGameState == 0) if (!gs.Update())
return snap; return snap;
snap.InGameStatePtr = inGameState;
_lastInGameState = inGameState;
_lastController = snap.ControllerPtr;
// Read all state slot pointers // Populate snapshot from state hierarchy
_stateReader.ReadStateSlots(snap); snap.ControllerPtr = gs.ControllerPtr;
snap.StatesCount = gs.StatesCount;
snap.CurrentGameState = gs.CurrentState;
snap.ControllerPreSlots = gs.ControllerPreSlots;
snap.InGameStatePtr = gs.InGame.Address;
snap.IsLoading = gs.AreaLoading.IsLoading;
snap.IsEscapeOpen = gs.InGame.IsEscapeOpen;
snap.AreaInstancePtr = gs.InGame.AreaInstance.Address;
snap.ServerDataPtr = gs.InGame.AreaInstance.ServerDataPtr;
snap.LocalPlayerPtr = gs.InGame.AreaInstance.LocalPlayerPtr;
snap.EntityCount = gs.InGame.AreaInstance.EntityCount;
// InGameState → AreaInstance // Area level — prefer hierarchical read, keep static offset as fallback
var ingameData = mem.ReadPointer(inGameState + offsets.IngameDataFromStateOffset); var areaLevel = gs.InGame.AreaInstance.AreaLevel;
snap.AreaInstancePtr = ingameData; if (areaLevel > 0)
snap.AreaLevel = areaLevel;
snap.AreaHash = gs.InGame.AreaInstance.AreaHash;
// Camera matrix from WorldDataState
if (gs.InGame.WorldData.CameraMatrix.HasValue)
{
snap.CameraMatrix = gs.InGame.WorldData.CameraMatrix;
_cachedCameraMatrixAddr = gs.InGame.WorldData.CameraMatrixAddress;
}
else
{
// Fallback: direct camera read (inline or pointer-based)
ReadCameraMatrix(snap, gs.InGame.Address);
}
_lastInGameState = gs.InGame.Address;
_lastController = gs.ControllerPtr;
// Diagnostic state slots — GameStateReader still used for MemoryDiagnostics compat
_stateReader!.ReadStateSlots(snap);
// Loading/escape overrides from GameStateReader (active states vector method)
_stateReader.ReadIsLoading(snap);
_stateReader.ReadEscapeState(snap);
// Reconcile CurrentGameState with reliable loading/escape detection
if (snap.IsLoading)
snap.CurrentGameState = States.GameStateType.AreaLoadingState;
else if (snap.IsEscapeOpen)
snap.CurrentGameState = States.GameStateType.EscapeState;
var ingameData = gs.InGame.AreaInstance.Address;
if (ingameData != 0) if (ingameData != 0)
{ {
// Area level // Entity list
if (offsets.AreaLevelIsByte) if (snap.EntityCount > 0)
{
var level = mem.Read<byte>(ingameData + offsets.AreaLevelOffset);
if (level > 0 && level < 200)
snap.AreaLevel = level;
}
else
{
var level = mem.Read<int>(ingameData + offsets.AreaLevelOffset);
if (level > 0 && level < 200)
snap.AreaLevel = level;
}
// Area hash
snap.AreaHash = mem.Read<uint>(ingameData + offsets.AreaHashOffset);
// ServerData pointer
var serverData = mem.ReadPointer(ingameData + offsets.ServerDataOffset);
snap.ServerDataPtr = serverData;
// LocalPlayer — try direct offset first, fallback to ServerData chain
if (offsets.LocalPlayerDirectOffset > 0)
snap.LocalPlayerPtr = mem.ReadPointer(ingameData + offsets.LocalPlayerDirectOffset);
if (snap.LocalPlayerPtr == 0 && serverData != 0)
snap.LocalPlayerPtr = mem.ReadPointer(serverData + offsets.LocalPlayerOffset);
// Entity count and list
var entityCount = (int)mem.Read<long>(ingameData + offsets.EntityListOffset + offsets.EntityCountInternalOffset);
if (entityCount > 0 && entityCount < 50000)
{
snap.EntityCount = entityCount;
_entities!.ReadEntities(snap, ingameData); _entities!.ReadEntities(snap, ingameData);
}
// Player vitals & position — ECS // Player vitals & position — ECS
if (snap.LocalPlayerPtr != 0) if (snap.LocalPlayerPtr != 0)
{ {
// Invalidate caches if LocalPlayer entity changed (zone change)
if (snap.LocalPlayerPtr != _components!.LastLocalPlayer) if (snap.LocalPlayerPtr != _components!.LastLocalPlayer)
_terrain!.InvalidateCache(); _terrain!.InvalidateCache();
_components.InvalidateCaches(snap.LocalPlayerPtr); _components.InvalidateCaches(snap.LocalPlayerPtr);
_components.ReadPlayerVitals(snap); _components.ReadPlayerVitals(snap);
_components.ReadPlayerPosition(snap); _components.ReadPlayerPosition(snap);
snap.PlayerSkills = _skills!.ReadPlayerSkills(snap.LocalPlayerPtr);
} }
// Camera matrix
ReadCameraMatrix(snap, inGameState);
// Loading and escape state
_stateReader.ReadIsLoading(snap);
_stateReader.ReadEscapeState(snap);
// Read state flag bytes // Read state flag bytes
if (snap.InGameStatePtr != 0) if (snap.InGameStatePtr != 0)
snap.StateFlagBytes = mem.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30); snap.StateFlagBytes = mem.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30);
@ -296,8 +303,13 @@ public class GameMemoryReader : IDisposable
/// </summary> /// </summary>
public HotAddresses ResolveHotAddresses() public HotAddresses ResolveHotAddresses()
{ {
// Prefer camera address from hierarchical state, fallback to cached
var cameraAddr = _gameStates?.InGame.WorldData.CameraMatrixAddress ?? 0;
if (cameraAddr == 0)
cameraAddr = _cachedCameraMatrixAddr;
return new HotAddresses( return new HotAddresses(
_cachedCameraMatrixAddr, cameraAddr,
_components?.CachedRenderComponentAddr ?? 0, _components?.CachedRenderComponentAddr ?? 0,
_components?.CachedLifeComponentAddr ?? 0, _components?.CachedLifeComponentAddr ?? 0,
_lastInGameState, _lastInGameState,

View file

@ -113,32 +113,46 @@ public sealed class GameStateReader
} }
snap.StateSlotValues = values; snap.StateSlotValues = values;
// Read active states vector // Read active states vector — scan controller for {begin, end} pairs
// containing known state slot pointers (auto-discovers layout)
if (offsets.ActiveStatesOffset > 0) if (offsets.ActiveStatesOffset > 0)
{ {
// Collect known state slot pointers for matching
var knownSlots = new HashSet<nint>();
foreach (var s in slots)
if (s != 0) knownSlots.Add(s);
// Try the configured offset with end at both +8 and +16
var beginPtr = mem.ReadPointer(controller + offsets.ActiveStatesOffset); var beginPtr = mem.ReadPointer(controller + offsets.ActiveStatesOffset);
var endPtr = mem.ReadPointer(controller + offsets.ActiveStatesOffset + 16);
snap.ActiveStatesBegin = beginPtr; snap.ActiveStatesBegin = beginPtr;
nint endPtr = 0;
foreach (var endDelta in new[] { 8, 16 })
{
var candidate = mem.ReadPointer(controller + offsets.ActiveStatesOffset + endDelta);
if (candidate > beginPtr && candidate - beginPtr < 0x1000)
{
endPtr = candidate;
break;
}
}
snap.ActiveStatesEnd = endPtr; snap.ActiveStatesEnd = endPtr;
if (beginPtr != 0 && endPtr > beginPtr) if (beginPtr != 0 && endPtr > beginPtr)
{ {
var size = (int)(endPtr - beginPtr); var size = (int)(endPtr - beginPtr);
if (size is > 0 and < 0x1000) var data = mem.ReadBytes(beginPtr, size);
if (data is not null)
{ {
var data = mem.ReadBytes(beginPtr, size); var rawList = new List<nint>();
if (data is not null) for (var i = 0; i + 8 <= data.Length; i += 8)
{ {
var rawList = new List<nint>(); var ptr = (nint)BitConverter.ToInt64(data, i);
for (var i = 0; i + 8 <= data.Length; i += offsets.StateStride) rawList.Add(ptr);
{ if (ptr != 0)
var ptr = (nint)BitConverter.ToInt64(data, i); snap.ActiveStates.Add(ptr);
rawList.Add(ptr);
if (ptr != 0)
snap.ActiveStates.Add(ptr);
}
snap.ActiveStatesRaw = rawList.ToArray();
} }
snap.ActiveStatesRaw = rawList.ToArray();
} }
} }
} }

View file

@ -1,4 +1,5 @@
using System.Numerics; using System.Numerics;
using Roboto.Memory.States;
namespace Roboto.Memory; namespace Roboto.Memory;
@ -15,6 +16,7 @@ public class GameStateSnapshot
public nint GameStateBase; public nint GameStateBase;
public bool OffsetsConfigured; public bool OffsetsConfigured;
public int StatesCount; public int StatesCount;
public GameStateType CurrentGameState = GameStateType.GameNotLoaded;
// Pointers // Pointers
public nint ControllerPtr; public nint ControllerPtr;
@ -56,6 +58,10 @@ public class GameStateSnapshot
public nint ActiveStatesBegin, ActiveStatesEnd; // debug: raw vector pointers public nint ActiveStatesBegin, ActiveStatesEnd; // debug: raw vector pointers
public nint[] ActiveStatesRaw = Array.Empty<nint>(); // debug: all pointers in the vector public nint[] ActiveStatesRaw = Array.Empty<nint>(); // debug: all pointers in the vector
public (int Offset, nint Value)[] WatchOffsets = []; // candidate controller offsets public (int Offset, nint Value)[] WatchOffsets = []; // candidate controller offsets
public (int Offset, nint Value, string? Match, bool Changed, string? DerefInfo)[] ControllerPreSlots = []; // qwords before state array
// Player skills (from Actor component)
public List<SkillSnapshot>? PlayerSkills;
// Camera // Camera
public Matrix4x4? CameraMatrix; public Matrix4x4? CameraMatrix;

File diff suppressed because it is too large Load diff

View file

@ -96,6 +96,38 @@ public sealed class MsvcStringReader
return null; return null;
} }
/// <summary>
/// Reads a null-terminated wchar_t* (UTF-16) string, e.g. skill names.
/// Validates that all characters are printable (0x20-0x7E ASCII range).
/// </summary>
public string? ReadNullTermWString(nint ptr)
{
if (ptr == 0) return null;
var data = _ctx.Memory.ReadBytes(ptr, 256);
if (data is null) return null;
int byteLen = -1;
for (int i = 0; i + 1 < data.Length; i += 2)
{
if (data[i] == 0 && data[i + 1] == 0)
{
byteLen = i;
break;
}
}
if (byteLen <= 0) return null;
var str = Encoding.Unicode.GetString(data, 0, byteLen);
// Validate: all chars must be printable ASCII (skill/item names are ASCII in POE2)
if (str.Length == 0) return null;
foreach (var c in str)
{
if (c < 0x20 || c > 0x7E) return null;
}
return str;
}
/// <summary> /// <summary>
/// Reads a null-terminated UTF-8 string (up to 256 bytes). /// Reads a null-terminated UTF-8 string (up to 256 bytes).
/// </summary> /// </summary>

View file

@ -6,10 +6,10 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="System.Drawing.Common" Version="8.0.12" /> <PackageReference Include="System.Drawing.Common" Version="8.0.12" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Automata.Core\Automata.Core.csproj" />
<ProjectReference Include="..\Roboto.GameOffsets\Roboto.GameOffsets.csproj" /> <ProjectReference Include="..\Roboto.GameOffsets\Roboto.GameOffsets.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,226 @@
using Roboto.GameOffsets.Components;
namespace Roboto.Memory;
/// <summary>
/// Lightweight skill data from the Actor component's ActiveSkills vector.
/// Stored in GameStateSnapshot; mapped to Roboto.Core.SkillState in the Data layer.
/// </summary>
public sealed class SkillSnapshot
{
public string? Name { get; init; }
public bool CanBeUsed { get; init; }
public int UseStage { get; init; }
public int CastType { get; init; }
public int TotalUses { get; init; }
public int CooldownTimeMs { get; init; }
/// <summary>From Cooldowns vector — number of active cooldown entries.</summary>
public int ActiveCooldowns { get; init; }
/// <summary>From Cooldowns vector — max uses (charges) for the skill.</summary>
public int MaxUses { get; init; }
}
/// <summary>
/// Reads active skills from the local player's Actor component.
/// Uses ExileCore2 offsets: Actor+0xB00 = ActiveSkills vector (shared_ptr pairs),
/// follow ptr1 (ActiveSkillPtr) → ActiveSkillDetails for GEPL, cooldown, uses.
/// Actor+0xB18 = Cooldowns vector for dynamic cooldown state.
/// </summary>
public sealed class SkillReader
{
private readonly MemoryContext _ctx;
private readonly ComponentReader _components;
private readonly MsvcStringReader _strings;
// Name cache — skill names are static per area, only refresh on actor change
private readonly Dictionary<nint, string?> _nameCache = new();
private nint _lastActorComp;
public SkillReader(MemoryContext ctx, ComponentReader components, MsvcStringReader strings)
{
_ctx = ctx;
_components = components;
_strings = strings;
}
public List<SkillSnapshot>? ReadPlayerSkills(nint localPlayerPtr)
{
if (localPlayerPtr == 0) return null;
var mem = _ctx.Memory;
var actorComp = _components.GetComponentAddress(localPlayerPtr, "Actor");
if (actorComp == 0) return null;
// Invalidate name cache if actor component address changed (area transition)
if (actorComp != _lastActorComp)
{
_nameCache.Clear();
_lastActorComp = actorComp;
}
// Read ActiveSkills vector at Actor+0xB00
var vecFirst = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector);
var vecLast = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector + 8);
if (vecFirst == 0 || vecLast <= vecFirst) return null;
var totalBytes = (int)(vecLast - vecFirst);
const int entrySize = 0x10; // ActiveSkillEntry: ActiveSkillPtr + ControlBlockPtr
var entryCount = totalBytes / entrySize;
if (entryCount <= 0 || entryCount > 128) return null;
// Bulk read all entries
var vecData = mem.ReadBytes(vecFirst, totalBytes);
if (vecData is null) return null;
// Read cooldowns for dynamic CanBeUsed state
var cooldowns = ReadCooldowns(actorComp);
var result = new List<SkillSnapshot>();
var seen = new HashSet<uint>(); // deduplicate by UnknownIdAndEquipmentInfo
for (var i = 0; i < entryCount; i++)
{
// Follow ptr1 (ActiveSkillPtr) — ExileCore convention
var activeSkillPtr = (nint)BitConverter.ToInt64(vecData, i * entrySize);
if (activeSkillPtr == 0) continue;
var high = (ulong)activeSkillPtr >> 32;
if (high == 0 || high >= 0x7FFF) continue;
// Read ActiveSkillDetails struct
var details = mem.Read<ActiveSkillDetails>(activeSkillPtr);
// Resolve skill name via GEPL FK chain (cached)
var name = ResolveSkillName(activeSkillPtr, details);
// Skip entries with no resolved name (support gems, passives, internal skills)
if (name is null) continue;
// Deduplicate by UnknownIdAndEquipmentInfo
if (!seen.Add(details.UnknownIdAndEquipmentInfo)) continue;
// Match cooldown entry by UnknownIdAndEquipmentInfo
var canBeUsed = true;
var activeCooldowns = 0;
var cdMaxUses = 0;
if (cooldowns is not null)
{
foreach (var (cd, _) in cooldowns)
{
if (cd.UnknownIdAndEquipmentInfo == details.UnknownIdAndEquipmentInfo)
{
canBeUsed = !cd.CannotBeUsed;
activeCooldowns = cd.TotalActiveCooldowns;
cdMaxUses = cd.MaxUses;
break;
}
}
}
result.Add(new SkillSnapshot
{
Name = name,
CanBeUsed = canBeUsed,
UseStage = details.UseStage,
CastType = details.CastType,
TotalUses = details.TotalUses,
CooldownTimeMs = details.TotalCooldownTimeInMs,
ActiveCooldowns = activeCooldowns,
MaxUses = cdMaxUses,
});
}
return result;
}
/// <summary>
/// Reads the Cooldowns vector at Actor+0xB18.
/// Each entry is an ActiveSkillCooldown struct (0x48 bytes).
/// Returns tuples of (struct, vectorFirstPtr) so callers can read timer entries.
/// </summary>
private List<(ActiveSkillCooldown Cd, nint FirstPtr)>? ReadCooldowns(nint actorComp)
{
var mem = _ctx.Memory;
var cdFirst = mem.ReadPointer(actorComp + ActorOffsets.CooldownsVector);
var cdLast = mem.ReadPointer(actorComp + ActorOffsets.CooldownsVector + 8);
if (cdFirst == 0 || cdLast <= cdFirst) return null;
var totalBytes = (int)(cdLast - cdFirst);
const int cdEntrySize = 0x48;
var count = totalBytes / cdEntrySize;
if (count <= 0 || count > 64) return null;
var result = new List<(ActiveSkillCooldown, nint)>(count);
for (var i = 0; i < count; i++)
{
var cd = mem.Read<ActiveSkillCooldown>(cdFirst + i * cdEntrySize);
result.Add((cd, cd.CooldownsList.First));
}
return result;
}
/// <summary>
/// Resolves skill name via multiple paths:
/// 1. ActiveSkillDetails.ActiveSkillsDatPtr (+0x20) → wchar* (most direct)
/// 2. GEPL FK chain: GrantedEffectsPerLevelDatRow → GEPL+0x00 FK → GE row → GE+0x00 → wchar*
/// 3. GE+0xA8 → ptr → +0x00 → wchar*
/// Results are cached since names don't change per-area.
/// </summary>
private string? ResolveSkillName(nint activeSkillPtr, ActiveSkillDetails details)
{
if (_nameCache.TryGetValue(activeSkillPtr, out var cached))
return cached;
var mem = _ctx.Memory;
string? name = null;
// Path 1: ActiveSkillsDatPtr (+0x20) → read wchar* directly from the .dat row
var asDatDirect = details.ActiveSkillsDatPtr;
if (asDatDirect != 0 && ((ulong)asDatDirect >> 32) is > 0 and < 0x7FFF)
name = _strings.ReadNullTermWString(asDatDirect);
// Path 2: GEPL FK chain
if (name is null)
{
var geplPtr = details.GrantedEffectsPerLevelDatRow;
if (geplPtr != 0)
{
var geFk = mem.ReadPointer(geplPtr);
if (geFk != 0 && ((ulong)geFk >> 32) is > 0 and < 0x7FFF)
{
var geData = mem.ReadBytes(geFk, 0xB0);
if (geData is not null)
{
// GE+0x00 → ActiveSkills.dat row → wchar*
var asDatPtr = (nint)BitConverter.ToInt64(geData, 0x00);
if (asDatPtr != 0 && ((ulong)asDatPtr >> 32) is > 0 and < 0x7FFF)
name = _strings.ReadNullTermWString(asDatPtr);
// GE+0xA8 → ptr → +0x00 → wchar*
if (name is null && 0xA8 + 8 <= geData.Length)
{
var nameObjPtr = (nint)BitConverter.ToInt64(geData, 0xA8);
if (nameObjPtr != 0 && ((ulong)nameObjPtr >> 32) is > 0 and < 0x7FFF)
{
var namePtr = mem.ReadPointer(nameObjPtr);
if (namePtr != 0)
name = _strings.ReadNullTermWString(namePtr);
}
}
}
}
}
}
_nameCache[activeSkillPtr] = name;
return name;
}
/// <summary>Clears cached names (call on area change).</summary>
public void InvalidateCache()
{
_nameCache.Clear();
_lastActorComp = 0;
}
}

View file

@ -0,0 +1,63 @@
namespace Roboto.Memory.States;
/// <summary>
/// Reads fields from the AreaInstance (IngameData) address.
/// Individual field reads — the full struct is 3280B, too large to bulk-read.
/// Uses GameOffsets for configurable offsets.
/// </summary>
public sealed class AreaInstanceState : RemoteObject
{
public int AreaLevel { get; private set; }
public uint AreaHash { get; private set; }
public nint ServerDataPtr { get; private set; }
public nint LocalPlayerPtr { get; private set; }
public int EntityCount { get; private set; }
public AreaInstanceState(MemoryContext ctx) : base(ctx) { }
protected override bool ReadData()
{
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
// Area level
if (offsets.AreaLevelIsByte)
{
var level = mem.Read<byte>(Address + offsets.AreaLevelOffset);
AreaLevel = level is > 0 and < 200 ? level : 0;
}
else
{
var level = mem.Read<int>(Address + offsets.AreaLevelOffset);
AreaLevel = level is > 0 and < 200 ? level : 0;
}
// Area hash
AreaHash = mem.Read<uint>(Address + offsets.AreaHashOffset);
// ServerData pointer
ServerDataPtr = mem.ReadPointer(Address + offsets.ServerDataOffset);
// LocalPlayer — try direct offset first, fallback to ServerData chain
LocalPlayerPtr = 0;
if (offsets.LocalPlayerDirectOffset > 0)
LocalPlayerPtr = mem.ReadPointer(Address + offsets.LocalPlayerDirectOffset);
if (LocalPlayerPtr == 0 && ServerDataPtr != 0)
LocalPlayerPtr = mem.ReadPointer(ServerDataPtr + offsets.LocalPlayerOffset);
// Entity count from std::map _Mysize
var count = (int)mem.Read<long>(Address + offsets.EntityListOffset + offsets.EntityCountInternalOffset);
EntityCount = count is > 0 and < 50000 ? count : 0;
return true;
}
protected override void Clear()
{
AreaLevel = 0;
AreaHash = 0;
ServerDataPtr = 0;
LocalPlayerPtr = 0;
EntityCount = 0;
}
}

View file

@ -0,0 +1,36 @@
using Roboto.GameOffsets.States;
namespace Roboto.Memory.States;
/// <summary>
/// Reads AreaLoading state (slot 0). Individual field reads — the full struct is 3672B, wasteful to bulk-read.
/// </summary>
public sealed class AreaLoadingState : RemoteObject
{
// AreaLoading struct field offsets
private const int IsLoadingOffset = 0x660;
private const int TotalLoadingScreenTimeMsOffset = 0xDB8;
public bool IsLoading { get; private set; }
public long TotalLoadingScreenTimeMs { get; private set; }
public AreaLoadingState(MemoryContext ctx) : base(ctx) { }
protected override bool ReadData()
{
var mem = Ctx.Memory;
var loadingFlag = mem.Read<int>(Address + IsLoadingOffset);
IsLoading = loadingFlag != 0;
TotalLoadingScreenTimeMs = mem.Read<long>(Address + TotalLoadingScreenTimeMsOffset);
return true;
}
protected override void Clear()
{
IsLoading = false;
TotalLoadingScreenTimeMs = 0;
}
}

View file

@ -0,0 +1,22 @@
namespace Roboto.Memory.States;
/// <summary>
/// Game state types by slot index. Order must match the state array in the controller.
/// Matches ExileCore/GameHelper2 slot ordering.
/// </summary>
public enum GameStateType
{
AreaLoadingState, // 0
WaitingState, // 1
CreditsState, // 2
EscapeState, // 3
InGameState, // 4
ChangePasswordState, // 5
LoginState, // 6
PreGameState, // 7
CreateCharacterState, // 8
SelectCharacterState, // 9
DeleteCharacterState, // 10
LoadingState, // 11
GameNotLoaded, // sentinel — no valid state resolved
}

View file

@ -0,0 +1,437 @@
namespace Roboto.Memory.States;
/// <summary>
/// Root state orchestrator. Reads controller from GameStateBase, resolves state slot pointers,
/// builds address→GameStateType dictionary, resolves current state, and cascades to children.
/// NOT a RemoteObject — owns the top-level resolution logic.
/// </summary>
public sealed class GameStates
{
private readonly MemoryContext _ctx;
private readonly Dictionary<nint, GameStateType> _allStates = new();
private nint[] _slotPointers = [];
private nint[] _prevPreSlotValues = [];
public nint ControllerPtr { get; private set; }
public int StatesCount { get; private set; }
public GameStateType CurrentState { get; private set; } = GameStateType.GameNotLoaded;
public IReadOnlyDictionary<nint, GameStateType> AllStates => _allStates;
public AreaLoadingState AreaLoading { get; }
public InGameStateReader InGame { get; }
/// <summary>Raw qwords from controller 0x00-0x48 (before state slots), for UI diagnostics.</summary>
public (int Offset, nint Value, string? Match, bool Changed, string? DerefInfo)[] ControllerPreSlots { get; private set; } = [];
public GameStates(MemoryContext ctx)
{
_ctx = ctx;
AreaLoading = new AreaLoadingState(ctx);
InGame = new InGameStateReader(ctx);
}
/// <summary>
/// Reads controller, resolves state slots, builds state dictionary, cascades to children,
/// then resolves current state from child flags. Returns true if InGameState was resolved.
/// </summary>
public bool Update()
{
ControllerPtr = 0;
StatesCount = 0;
_allStates.Clear();
CurrentState = GameStateType.GameNotLoaded;
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
if (_ctx.GameStateBase == 0)
return false;
var controller = mem.ReadPointer(_ctx.GameStateBase);
if (controller == 0)
return false;
ControllerPtr = controller;
nint igsPtr = 0;
// Mode 1: Direct offset — InGameState pointer at a known offset from controller
if (offsets.InGameStateDirectOffset > 0)
{
igsPtr = mem.ReadPointer(controller + offsets.InGameStateDirectOffset);
if (igsPtr != 0)
ReadSlotPointers(controller);
}
// Mode 2: Inline states — states are inline in the controller struct
if (igsPtr == 0 && offsets.StatesInline)
{
ReadSlotPointers(controller);
var inlineOffset = offsets.StatesBeginOffset
+ offsets.InGameStateIndex * offsets.StateStride
+ offsets.StatePointerOffset;
igsPtr = mem.ReadPointer(controller + inlineOffset);
}
// Mode 3: Vector of pointers — StatesBeginOffset points to begin/end pair
if (igsPtr == 0 && !offsets.StatesInline)
{
var statesBegin = mem.ReadPointer(controller + offsets.StatesBeginOffset);
if (statesBegin == 0)
return false;
var statesEnd = mem.ReadPointer(controller + offsets.StatesBeginOffset + 8);
if (statesEnd > statesBegin && statesEnd - statesBegin < 0x1000 && offsets.StateStride > 0)
StatesCount = (int)((statesEnd - statesBegin) / offsets.StateStride);
else
{
for (var i = 0; i < 20; i++)
{
if (mem.ReadPointer(statesBegin + i * offsets.StateStride + offsets.StatePointerOffset) == 0) break;
StatesCount++;
}
}
if (_slotPointers.Length < StatesCount)
_slotPointers = new nint[StatesCount];
for (var i = 0; i < StatesCount; i++)
_slotPointers[i] = mem.ReadPointer(statesBegin + i * offsets.StateStride + offsets.StatePointerOffset);
BuildStateDictionary();
if (offsets.InGameStateIndex >= 0 && offsets.InGameStateIndex < StatesCount)
igsPtr = _slotPointers[offsets.InGameStateIndex];
}
// Dump controller pre-slots region for diagnostics
DumpControllerPreSlots(controller);
// Cascade to children FIRST — we need their flags for current state resolution
var areaLoadingPtr = StatesCount > 0 ? _slotPointers[0] : (nint)0;
AreaLoading.Update(areaLoadingPtr);
if (igsPtr == 0)
{
InGame.Reset();
ResolveCurrentState(controller, igsPtr);
return false;
}
InGame.Update(igsPtr);
// Resolve current state AFTER children have read their flags
ResolveCurrentState(controller, igsPtr);
return true;
}
/// <summary>
/// Read slot pointers from inline state array in the controller.
/// </summary>
private void ReadSlotPointers(nint controller)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
StatesCount = 0;
for (var i = 0; i < 20; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
var ptr = mem.ReadPointer(controller + slotOffset);
if (ptr == 0) break;
StatesCount++;
}
if (_slotPointers.Length < StatesCount)
_slotPointers = new nint[StatesCount];
for (var i = 0; i < StatesCount; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
_slotPointers[i] = mem.ReadPointer(controller + slotOffset);
}
BuildStateDictionary();
}
/// <summary>
/// Dump the controller region before the state slots for UI diagnostics.
/// Tracks which values changed since last frame.
/// Dereferences pointer-like values and checks for indirect state slot matches.
/// </summary>
private void DumpControllerPreSlots(nint controller)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var preSize = offsets.StatesBeginOffset;
if (preSize <= 0) { ControllerPreSlots = []; return; }
var data = mem.ReadBytes(controller, preSize);
if (data is null) { ControllerPreSlots = []; return; }
var count = preSize / 8;
var entries = new (int, nint, string?, bool, string?)[count];
for (var i = 0; i < count; i++)
{
var off = i * 8;
var val = (nint)BitConverter.ToInt64(data, off);
string? match = null;
if (val != 0 && _allStates.TryGetValue(val, out var st))
match = st.ToString();
var changed = i < _prevPreSlotValues.Length && _prevPreSlotValues[i] != val;
// Annotate discovered StdVector fields
string? derefInfo = null;
if (_discoveredVectorOffset >= 0)
{
if (off == _discoveredVectorOffset)
derefInfo = "vec.First";
else if (off == _discoveredVectorOffset + 8)
derefInfo = "vec.Last";
else if (off == _discoveredVectorOffset + 16)
derefInfo = "vec.End";
}
// Dereference pointer-like values and scan for state slot matches
if (derefInfo == null && val != 0 && match == null)
{
var high = (ulong)val >> 32;
if (high > 0 && high < 0x7FFF)
{
// Read first 16 qwords (128 bytes) of the target object
var targetData = mem.ReadBytes(val, 128);
if (targetData is not null)
{
for (var qi = 0; qi + 8 <= targetData.Length; qi += 8)
{
var innerVal = (nint)BitConverter.ToInt64(targetData, qi);
if (innerVal != 0 && _allStates.TryGetValue(innerVal, out var innerState))
{
derefInfo = $"*+0x{qi:X2} → {innerState}";
break;
}
}
// If no state match, show first qword as context
if (derefInfo == null)
{
var first = (nint)BitConverter.ToInt64(targetData, 0);
if (first != 0)
derefInfo = $"*→ 0x{first:X}";
}
}
}
}
entries[i] = (off, val, match, changed, derefInfo);
}
ControllerPreSlots = entries;
// Save for next frame diff
if (_prevPreSlotValues.Length != count)
_prevPreSlotValues = new nint[count];
for (var i = 0; i < count; i++)
_prevPreSlotValues[i] = entries[i].Item2;
}
/// <summary>
/// Build address→GameStateType dictionary from collected slot pointers.
/// </summary>
private void BuildStateDictionary()
{
_allStates.Clear();
var maxType = (int)GameStateType.GameNotLoaded;
for (var i = 0; i < StatesCount && i < maxType; i++)
{
if (_slotPointers[i] != 0)
_allStates[_slotPointers[i]] = (GameStateType)i;
}
}
/// <summary>
/// Cached base address and offset for the active states vector.
/// -1 = not yet scanned, -2 = scan failed.
/// </summary>
private int _discoveredVectorOffset = -1;
private nint _discoveredVectorBase;
/// <summary>
/// Resolve which GameStateType is currently active using the GameHelper2 approach:
/// Find the StdVector {First, Last, End}, read *(Last - 0x10) (second-to-last entry).
/// Searches BOTH the GameState object (*(originalPatternResult)) AND the controller,
/// since POE2 may have separated these into different objects.
/// </summary>
private void ResolveCurrentState(nint controller, nint igsPtr)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
// Fast path: use cached vector location
if (_discoveredVectorOffset >= 0 && _discoveredVectorBase != 0)
{
var state = ReadStateFromVector(_discoveredVectorBase, _discoveredVectorOffset);
if (state != GameStateType.GameNotLoaded)
{
CurrentState = state;
return;
}
// Vector went invalid (zone change, etc) — re-scan
_discoveredVectorOffset = -1;
_discoveredVectorBase = 0;
}
// Scan for the active states StdVector
if (_discoveredVectorOffset == -1 && _allStates.Count > 0)
{
// === Priority 1: GameState object (original pattern result, before PatternResultAdjust) ===
// GameHelper2 reads CurrentStatePtr from GameState+0x08. In POE2, the controller
// is at pattern_result+0x18, but the "real" GameState object may be at *(pattern_result+0x00).
if (offsets.PatternResultAdjust > 0)
{
var originalPatternResult = _ctx.GameStateBase - offsets.PatternResultAdjust;
// Try each pointer in the static region as a potential GameState object
for (var ptrOff = 0; ptrOff < offsets.PatternResultAdjust; ptrOff += 8)
{
var gameStateObj = mem.ReadPointer(originalPatternResult + ptrOff);
if (gameStateObj == 0 || gameStateObj == controller) continue;
// Scan this object for StdVector containing state slot pointers
for (var off = 0; off + 24 <= 0x100; off += 8)
{
var state = ReadStateFromVector(gameStateObj, off);
if (state != GameStateType.GameNotLoaded)
{
_discoveredVectorBase = gameStateObj;
_discoveredVectorOffset = off;
CurrentState = state;
Serilog.Log.Information(
"Active states vector at GameState(patResult+0x{PtrOff:X})+0x{Offset:X} → {State} (obj=0x{Obj:X})",
ptrOff, off, state, gameStateObj);
return;
}
}
}
// Also try reading the vector directly from the static region itself
for (var off = 0; off + 24 <= offsets.PatternResultAdjust + 0x40; off += 8)
{
var state = ReadStateFromVector(originalPatternResult, off);
if (state != GameStateType.GameNotLoaded)
{
_discoveredVectorBase = originalPatternResult;
_discoveredVectorOffset = off;
CurrentState = state;
Serilog.Log.Information(
"Active states vector at staticRegion+0x{Offset:X} → {State}",
off, state);
return;
}
}
}
// === Priority 2: Controller pre-slots region ===
var stateArrayStart = offsets.StatesBeginOffset;
if (offsets.ActiveStatesOffset > 0)
{
var state = ReadStateFromVector(controller, offsets.ActiveStatesOffset);
if (state != GameStateType.GameNotLoaded)
{
_discoveredVectorBase = controller;
_discoveredVectorOffset = offsets.ActiveStatesOffset;
CurrentState = state;
return;
}
}
for (var off = 0; off + 24 <= stateArrayStart; off += 8)
{
if (off == offsets.ActiveStatesOffset) continue;
var state = ReadStateFromVector(controller, off);
if (state != GameStateType.GameNotLoaded)
{
_discoveredVectorBase = controller;
_discoveredVectorOffset = off;
CurrentState = state;
Serilog.Log.Information(
"Active states vector at controller+0x{Offset:X} → {State}", off, state);
return;
}
}
// Mark scan as failed so we don't re-scan every frame
_discoveredVectorOffset = -2;
}
// Fallback: IsLoadingOffset (legacy, if configured)
if (offsets.IsLoadingOffset > 0)
{
var currentStateAddr = mem.ReadPointer(controller + offsets.IsLoadingOffset);
if (currentStateAddr != 0 && _allStates.TryGetValue(currentStateAddr, out var stateType))
{
CurrentState = stateType;
return;
}
}
// Fallback: default to InGameState if resolved (preliminary — GameMemoryReader
// will reconcile with reliable snap.IsLoading / snap.IsEscapeOpen afterwards)
if (igsPtr != 0)
CurrentState = GameStateType.InGameState;
}
/// <summary>
/// Try to read the current state from a StdVector at the given controller offset.
/// GameHelper2 approach: StdVector = {First, Last, End}, current = *(Last - 0x10).
/// Returns GameNotLoaded if the vector is invalid or doesn't contain state matches.
/// </summary>
private GameStateType ReadStateFromVector(nint controller, int vectorOffset)
{
var mem = _ctx.Memory;
var first = mem.ReadPointer(controller + vectorOffset);
var last = mem.ReadPointer(controller + vectorOffset + 8);
if (first == 0 || last <= first) return GameStateType.GameNotLoaded;
var size = (int)(last - first);
if (size < 16 || size > 0x400) return GameStateType.GameNotLoaded; // need ≥2 entries for 2nd-to-last
// Read the full vector buffer
var buf = mem.ReadBytes(first, size);
if (buf is null) return GameStateType.GameNotLoaded;
// Validate: at least 1 entry must be a known state slot address
var matchCount = 0;
for (var i = 0; i + 8 <= buf.Length; i += 8)
{
var val = (nint)BitConverter.ToInt64(buf, i);
if (val != 0 && _allStates.ContainsKey(val))
matchCount++;
}
if (matchCount == 0) return GameStateType.GameNotLoaded;
// GameHelper2: current state = *(Last - 0x10) = second-to-last entry
var secondToLast = (nint)BitConverter.ToInt64(buf, buf.Length - 16);
if (secondToLast != 0 && _allStates.TryGetValue(secondToLast, out var stateType))
return stateType;
// Fallback: try last entry if second-to-last didn't match
var lastEntry = (nint)BitConverter.ToInt64(buf, buf.Length - 8);
if (lastEntry != 0 && _allStates.TryGetValue(lastEntry, out stateType))
return stateType;
return GameStateType.GameNotLoaded;
}
/// <summary>
/// Reset all state to zero.
/// </summary>
public void Reset()
{
ControllerPtr = 0;
StatesCount = 0;
CurrentState = GameStateType.GameNotLoaded;
_discoveredVectorOffset = -1;
_discoveredVectorBase = 0;
_allStates.Clear();
AreaLoading.Reset();
InGame.Reset();
}
}

View file

@ -0,0 +1,51 @@
using Roboto.GameOffsets.States;
namespace Roboto.Memory.States;
/// <summary>
/// Reads InGameState struct (784B, 1 RPM instead of 4 individual reads).
/// Named "Reader" to avoid collision with <see cref="Roboto.GameOffsets.States.InGameState"/> struct.
/// Cascades to AreaInstanceState and WorldDataState children.
/// </summary>
public sealed class InGameStateReader : RemoteObject
{
private InGameState _data;
public bool IsEscapeOpen { get; private set; }
public AreaInstanceState AreaInstance { get; }
public WorldDataState WorldData { get; }
public InGameStateReader(MemoryContext ctx) : base(ctx)
{
AreaInstance = new AreaInstanceState(ctx);
WorldData = new WorldDataState(ctx);
}
protected override bool ReadData()
{
var mem = Ctx.Memory;
// Read the full InGameState struct (0x310 = 784 bytes, 1 RPM)
_data = mem.Read<InGameState>(Address);
// Escape state
IsEscapeOpen = _data.EscapeStateFlag != 0;
// Cascade to AreaInstance
AreaInstance.Update(_data.AreaInstanceDataPtr);
// Cascade to WorldData — set fallback camera before update
WorldData.FallbackCameraPtr = _data.CameraPtr;
WorldData.Update(_data.WorldDataPtr);
return true;
}
protected override void Clear()
{
_data = default;
IsEscapeOpen = false;
AreaInstance.Reset();
WorldData.Reset();
}
}

View file

@ -0,0 +1,44 @@
namespace Roboto.Memory.States;
/// <summary>
/// Base class for state objects that read a section of game memory.
/// Each subclass reads its own struct/fields from a remote address.
/// </summary>
public abstract class RemoteObject
{
protected readonly MemoryContext Ctx;
public nint Address { get; protected set; }
public bool IsValid => Address != 0;
protected RemoteObject(MemoryContext ctx) => Ctx = ctx;
/// <summary>
/// Update this object from a new address. Returns false if address is 0 or read fails.
/// </summary>
public bool Update(nint address)
{
Address = address;
if (address == 0)
{
Clear();
return false;
}
return ReadData();
}
/// <summary>
/// Reset address to 0 and clear all cached data.
/// </summary>
public void Reset()
{
Address = 0;
Clear();
}
/// <summary>Read data from the remote process at <see cref="Address"/>.</summary>
protected abstract bool ReadData();
/// <summary>Zero out all cached fields.</summary>
protected abstract void Clear();
}

View file

@ -0,0 +1,60 @@
using System.Numerics;
using Roboto.GameOffsets.States;
namespace Roboto.Memory.States;
/// <summary>
/// Reads WorldData struct (168B, 1 RPM) and resolves the camera matrix.
/// Primary camera source: WorldData.CameraPtr. Fallback: InGameState.CameraPtr (set via FallbackCameraPtr).
/// </summary>
public sealed class WorldDataState : RemoteObject
{
private WorldData _data;
/// <summary>Camera pointer from InGameState, set by InGameStateReader before Update() is called.</summary>
public nint FallbackCameraPtr { get; set; }
public Matrix4x4? CameraMatrix { get; private set; }
/// <summary>Resolved address of the camera matrix for hot-path caching.</summary>
public nint CameraMatrixAddress { get; private set; }
public WorldDataState(MemoryContext ctx) : base(ctx) { }
protected override bool ReadData()
{
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
// Read the full WorldData struct (0xA8 = 168 bytes, 1 RPM)
_data = mem.Read<WorldData>(Address);
// Resolve camera: primary from WorldData, fallback from InGameState
if (offsets.CameraMatrixOffset <= 0)
return true;
var camPtr = _data.CameraPtr;
if (camPtr == 0)
camPtr = FallbackCameraPtr;
if (camPtr == 0)
return true;
var matrixAddr = camPtr + offsets.CameraMatrixOffset;
CameraMatrixAddress = matrixAddr;
var m = mem.Read<Matrix4x4>(matrixAddr);
if (float.IsNaN(m.M11) || float.IsInfinity(m.M11))
return true;
CameraMatrix = m;
return true;
}
protected override void Clear()
{
_data = default;
FallbackCameraPtr = 0;
CameraMatrix = null;
CameraMatrixAddress = 0;
}
}