huge refactor
This commit is contained in:
parent
e5ebe05571
commit
a8341e8232
29 changed files with 3184 additions and 340 deletions
96
data/poe2/areas.json
Normal file
96
data/poe2/areas.json
Normal 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": [] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -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"
|
||||||
]
|
]
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
namespace Roboto.Core;
|
|
||||||
|
|
||||||
public interface IMemoryProvider
|
|
||||||
{
|
|
||||||
bool IsAttached { get; }
|
|
||||||
bool Attach();
|
|
||||||
void Detach();
|
|
||||||
GameState ReadGameState(GameState? previous);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
src/Roboto.Data/AreaNameLookup.cs
Normal file
42
src/Roboto.Data/AreaNameLookup.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/Roboto.Data/EntityClassifier.cs
Normal file
90
src/Roboto.Data/EntityClassifier.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/Roboto.Data/EntityMapper.cs
Normal file
54
src/Roboto.Data/EntityMapper.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
if (entity.Type == EntityType.Monster && lookup.TryGetValue("Life", out var lifeIdx))
|
|
||||||
{
|
|
||||||
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
|
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
|
||||||
if (lifeIdx >= 0 && lifeIdx < compCount)
|
|
||||||
|
// Read HP/Actor/Mods for monsters
|
||||||
|
if (entity.Components.Contains("Monster"))
|
||||||
|
{
|
||||||
|
if (lookup.TryGetValue("Life", out var lifeIdx) && 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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
if (ingameData != 0)
|
// Camera matrix from WorldDataState
|
||||||
|
if (gs.InGame.WorldData.CameraMatrix.HasValue)
|
||||||
{
|
{
|
||||||
// Area level
|
snap.CameraMatrix = gs.InGame.WorldData.CameraMatrix;
|
||||||
if (offsets.AreaLevelIsByte)
|
_cachedCameraMatrixAddr = gs.InGame.WorldData.CameraMatrixAddress;
|
||||||
{
|
|
||||||
var level = mem.Read<byte>(ingameData + offsets.AreaLevelOffset);
|
|
||||||
if (level > 0 && level < 200)
|
|
||||||
snap.AreaLevel = level;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var level = mem.Read<int>(ingameData + offsets.AreaLevelOffset);
|
// Fallback: direct camera read (inline or pointer-based)
|
||||||
if (level > 0 && level < 200)
|
ReadCameraMatrix(snap, gs.InGame.Address);
|
||||||
snap.AreaLevel = level;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Area hash
|
_lastInGameState = gs.InGame.Address;
|
||||||
snap.AreaHash = mem.Read<uint>(ingameData + offsets.AreaHashOffset);
|
_lastController = gs.ControllerPtr;
|
||||||
|
|
||||||
// ServerData pointer
|
// Diagnostic state slots — GameStateReader still used for MemoryDiagnostics compat
|
||||||
var serverData = mem.ReadPointer(ingameData + offsets.ServerDataOffset);
|
_stateReader!.ReadStateSlots(snap);
|
||||||
snap.ServerDataPtr = serverData;
|
|
||||||
|
|
||||||
// LocalPlayer — try direct offset first, fallback to ServerData chain
|
// Loading/escape overrides from GameStateReader (active states vector method)
|
||||||
if (offsets.LocalPlayerDirectOffset > 0)
|
_stateReader.ReadIsLoading(snap);
|
||||||
snap.LocalPlayerPtr = mem.ReadPointer(ingameData + offsets.LocalPlayerDirectOffset);
|
_stateReader.ReadEscapeState(snap);
|
||||||
if (snap.LocalPlayerPtr == 0 && serverData != 0)
|
|
||||||
snap.LocalPlayerPtr = mem.ReadPointer(serverData + offsets.LocalPlayerOffset);
|
|
||||||
|
|
||||||
// Entity count and list
|
// Reconcile CurrentGameState with reliable loading/escape detection
|
||||||
var entityCount = (int)mem.Read<long>(ingameData + offsets.EntityListOffset + offsets.EntityCountInternalOffset);
|
if (snap.IsLoading)
|
||||||
if (entityCount > 0 && entityCount < 50000)
|
snap.CurrentGameState = States.GameStateType.AreaLoadingState;
|
||||||
|
else if (snap.IsEscapeOpen)
|
||||||
|
snap.CurrentGameState = States.GameStateType.EscapeState;
|
||||||
|
|
||||||
|
var ingameData = gs.InGame.AreaInstance.Address;
|
||||||
|
if (ingameData != 0)
|
||||||
{
|
{
|
||||||
snap.EntityCount = entityCount;
|
// Entity list
|
||||||
|
if (snap.EntityCount > 0)
|
||||||
_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,
|
||||||
|
|
|
||||||
|
|
@ -113,24 +113,39 @@ 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);
|
var data = mem.ReadBytes(beginPtr, size);
|
||||||
if (data is not null)
|
if (data is not null)
|
||||||
{
|
{
|
||||||
var rawList = new List<nint>();
|
var rawList = new List<nint>();
|
||||||
for (var i = 0; i + 8 <= data.Length; i += offsets.StateStride)
|
for (var i = 0; i + 8 <= data.Length; i += 8)
|
||||||
{
|
{
|
||||||
var ptr = (nint)BitConverter.ToInt64(data, i);
|
var ptr = (nint)BitConverter.ToInt64(data, i);
|
||||||
rawList.Add(ptr);
|
rawList.Add(ptr);
|
||||||
|
|
@ -141,7 +156,6 @@ public sealed class GameStateReader
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Read all non-null pointer-like qwords from controller (outside state array)
|
// Read all non-null pointer-like qwords from controller (outside state array)
|
||||||
var stateArrayStart = offsets.StatesBeginOffset;
|
var stateArrayStart = offsets.StatesBeginOffset;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
226
src/Roboto.Memory/SkillReader.cs
Normal file
226
src/Roboto.Memory/SkillReader.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/Roboto.Memory/States/AreaInstanceState.cs
Normal file
63
src/Roboto.Memory/States/AreaInstanceState.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Roboto.Memory/States/AreaLoadingState.cs
Normal file
36
src/Roboto.Memory/States/AreaLoadingState.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Roboto.Memory/States/GameStateType.cs
Normal file
22
src/Roboto.Memory/States/GameStateType.cs
Normal 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
|
||||||
|
}
|
||||||
437
src/Roboto.Memory/States/GameStates.cs
Normal file
437
src/Roboto.Memory/States/GameStates.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/Roboto.Memory/States/InGameStateReader.cs
Normal file
51
src/Roboto.Memory/States/InGameStateReader.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/Roboto.Memory/States/RemoteObject.cs
Normal file
44
src/Roboto.Memory/States/RemoteObject.cs
Normal 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();
|
||||||
|
}
|
||||||
60
src/Roboto.Memory/States/WorldDataState.cs
Normal file
60
src/Roboto.Memory/States/WorldDataState.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue