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/LeagueIncursion/EncounterChest",
|
||||
"Metadata/Chests/MossyChest11",
|
||||
"Metadata/Chests/MossyChest13",
|
||||
"Metadata/Chests/MossyChest20",
|
||||
"Metadata/Chests/MossyChest21",
|
||||
"Metadata/Chests/MossyChest26",
|
||||
"Metadata/Critters/Chicken/Chicken_kingsmarch",
|
||||
"Metadata/Critters/Crow/Crow",
|
||||
"Metadata/Critters/Ferret/Ferret",
|
||||
"Metadata/Critters/Hedgehog/HedgehogSlow",
|
||||
"Metadata/Critters/Weta/Basic",
|
||||
"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/harvest02/footprints_harvest",
|
||||
"Metadata/Effects/PermanentEffect",
|
||||
"Metadata/Effects/ServerEffect",
|
||||
"Metadata/Effects/Spells/monsters_effects/Act1_FOUR/CarrionCrone/IceSpike",
|
||||
"Metadata/Effects/Spells/sandstorm_swipe/sandstorm_swipe_storm",
|
||||
"Metadata/MiscellaneousObjects/AreaTransitionBlockage",
|
||||
"Metadata/MiscellaneousObjects/AreaTransitionDoodad",
|
||||
"Metadata/MiscellaneousObjects/AreaTransition_Animate",
|
||||
|
|
@ -46,19 +52,28 @@
|
|||
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_6",
|
||||
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalEncounter",
|
||||
"Metadata/MiscellaneousObjects/MultiplexPortal",
|
||||
"Metadata/MiscellaneousObjects/ReviveIcon",
|
||||
"Metadata/MiscellaneousObjects/ServerDoodadHidden",
|
||||
"Metadata/MiscellaneousObjects/Stash",
|
||||
"Metadata/MiscellaneousObjects/Waypoint",
|
||||
"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/UrchinHag1",
|
||||
"Metadata/Monsters/Hags/UrchinHagBoss",
|
||||
"Metadata/Monsters/InvisibleFire/MDCarrionCroneWave",
|
||||
"Metadata/Monsters/MonsterMods/OnDeathColdExplosionParent",
|
||||
"Metadata/Monsters/Urchins/MeleeUrchin1",
|
||||
"Metadata/Monsters/Urchins/SlingUrchin1",
|
||||
"Metadata/Monsters/Werewolves/WerewolfPack1",
|
||||
"Metadata/Monsters/Werewolves/WerewolfProwler1",
|
||||
"Metadata/Monsters/Wolves/RottenWolf1_",
|
||||
"Metadata/Monsters/Wolves/RottenWolfDead",
|
||||
"Metadata/Monsters/Wolves/RottenWolfHagSummonedDead",
|
||||
"Metadata/Monsters/Zombies/CourtGuardZombieAxe",
|
||||
"Metadata/Monsters/Zombies/CourtGuardZombieUnarmed",
|
||||
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxe",
|
||||
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxePhysics__",
|
||||
|
|
@ -89,10 +104,16 @@
|
|||
"Metadata/Pet/BabyChimera/BabyChimera",
|
||||
"Metadata/Pet/BetaKiwis/BaronKiwi",
|
||||
"Metadata/Pet/BetaKiwis/FaridunKiwi",
|
||||
"Metadata/Pet/BetaKiwis/KaruiKiwi",
|
||||
"Metadata/Pet/BetaKiwis/VaalKiwi",
|
||||
"Metadata/Pet/BookAndQuillPet/BookAndQuillPet",
|
||||
"Metadata/Pet/BookAndQuillPet/BookAndQuillPet_Abyss",
|
||||
"Metadata/Pet/Cat/Sphynx/GiantSphynx/GiantSphynxBlack",
|
||||
"Metadata/Pet/EtchedBeetlePet/EtchedBeetlePetAsala",
|
||||
"Metadata/Pet/FledglingBellcrow/FledglingBellcrow",
|
||||
"Metadata/Pet/HeritagePeacock/HeritagePeacock",
|
||||
"Metadata/Pet/LandSharkPet/LandSharkPet",
|
||||
"Metadata/Pet/LightBringerCat/LightbringerCat",
|
||||
"Metadata/Pet/OctopusParasite/OctopusParasiteCelestial",
|
||||
"Metadata/Pet/OrigamiPet/OrigamiPetBase",
|
||||
"Metadata/Pet/Phoenix/PhoenixPetBlue",
|
||||
|
|
@ -100,10 +121,12 @@
|
|||
"Metadata/Pet/Phoenix/PhoenixPetRed",
|
||||
"Metadata/Pet/QuadrillaPet/QuadrillaArmoured",
|
||||
"Metadata/Pet/ScavengerBat/ScavengerBat",
|
||||
"Metadata/Pet/WayfinderWolf/WayfinderWolf",
|
||||
"Metadata/Projectiles/CarrionCroneIceSpear",
|
||||
"Metadata/Projectiles/HagBossIceShard",
|
||||
"Metadata/Projectiles/IceSpear",
|
||||
"Metadata/Projectiles/SlingUrchinProjectile",
|
||||
"Metadata/Projectiles/Spark",
|
||||
"Metadata/Projectiles/Twister",
|
||||
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1",
|
||||
"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/CampsiteController",
|
||||
"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/CraftingBenchEzomyte",
|
||||
"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/HagArena",
|
||||
"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/InsideWaterMillAudio"
|
||||
]
|
||||
|
|
@ -7,6 +7,7 @@ using Avalonia.Threading;
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Roboto.Memory;
|
||||
using Roboto.Memory.States;
|
||||
|
||||
namespace Automata.Ui.ViewModels;
|
||||
|
||||
|
|
@ -89,8 +90,10 @@ public partial class MemoryViewModel : ObservableObject
|
|||
private MemoryNodeViewModel? _playerLife;
|
||||
private MemoryNodeViewModel? _playerMana;
|
||||
private MemoryNodeViewModel? _playerEs;
|
||||
private MemoryNodeViewModel? _currentStateNode;
|
||||
private MemoryNodeViewModel? _isLoadingNode;
|
||||
private MemoryNodeViewModel? _escapeStateNode;
|
||||
private MemoryNodeViewModel? _activeStatesNode;
|
||||
private MemoryNodeViewModel? _statesNode;
|
||||
private MemoryNodeViewModel? _terrainCells;
|
||||
private MemoryNodeViewModel? _terrainGrid;
|
||||
|
|
@ -98,6 +101,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
private MemoryNodeViewModel? _entitySummary;
|
||||
private MemoryNodeViewModel? _entityTypesNode;
|
||||
private MemoryNodeViewModel? _entityListNode;
|
||||
private MemoryNodeViewModel? _skillsNode;
|
||||
|
||||
partial void OnIsEnabledChanged(bool value)
|
||||
{
|
||||
|
|
@ -163,16 +167,20 @@ public partial class MemoryViewModel : ObservableObject
|
|||
_gsController = new MemoryNodeViewModel("Controller:");
|
||||
_gsStates = new MemoryNodeViewModel("States:");
|
||||
_inGameState = new MemoryNodeViewModel("InGameState:");
|
||||
_currentStateNode = new MemoryNodeViewModel("Current State:");
|
||||
_isLoadingNode = new MemoryNodeViewModel("Loading:");
|
||||
_escapeStateNode = new MemoryNodeViewModel("Escape:");
|
||||
_activeStatesNode = new MemoryNodeViewModel("Controller") { IsExpanded = true };
|
||||
_statesNode = new MemoryNodeViewModel("State Slots") { IsExpanded = true };
|
||||
gameState.Children.Add(_gsPattern);
|
||||
gameState.Children.Add(_gsBase);
|
||||
gameState.Children.Add(_gsController);
|
||||
gameState.Children.Add(_gsStates);
|
||||
gameState.Children.Add(_inGameState);
|
||||
gameState.Children.Add(_currentStateNode);
|
||||
gameState.Children.Add(_isLoadingNode);
|
||||
gameState.Children.Add(_escapeStateNode);
|
||||
gameState.Children.Add(_activeStatesNode);
|
||||
gameState.Children.Add(_statesNode);
|
||||
|
||||
// InGameState children
|
||||
|
|
@ -199,10 +207,12 @@ public partial class MemoryViewModel : ObservableObject
|
|||
_playerLife = new MemoryNodeViewModel("Life:") { Value = "?", ValueColor = "#484f58" };
|
||||
_playerMana = new MemoryNodeViewModel("Mana:") { Value = "?", ValueColor = "#484f58" };
|
||||
_playerEs = new MemoryNodeViewModel("ES:") { Value = "?", ValueColor = "#484f58" };
|
||||
_skillsNode = new MemoryNodeViewModel("Skills") { IsExpanded = false };
|
||||
player.Children.Add(_playerPos);
|
||||
player.Children.Add(_playerLife);
|
||||
player.Children.Add(_playerMana);
|
||||
player.Children.Add(_playerEs);
|
||||
player.Children.Add(_skillsNode);
|
||||
|
||||
// Entities
|
||||
var entitiesGroup = new MemoryNodeViewModel("Entities");
|
||||
|
|
@ -276,9 +286,69 @@ public partial class MemoryViewModel : ObservableObject
|
|||
_inGameState!.Set(
|
||||
snap.InGameStatePtr != 0 ? $"0x{snap.InGameStatePtr:X}" : "not found",
|
||||
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);
|
||||
_escapeStateNode!.Set(snap.IsEscapeOpen ? "Open" : "Closed", !snap.IsEscapeOpen);
|
||||
|
||||
// Controller dump — show qwords before state slots to find active state offset
|
||||
if (_activeStatesNode is not null)
|
||||
{
|
||||
var pre = snap.ControllerPreSlots;
|
||||
_activeStatesNode.Value = pre.Length > 0
|
||||
? $"controller+0x00..0x{pre.Length * 8:X} ({pre.Length} qwords)"
|
||||
: "no data";
|
||||
_activeStatesNode.ValueColor = "#8b949e";
|
||||
|
||||
while (_activeStatesNode.Children.Count > pre.Length)
|
||||
_activeStatesNode.Children.RemoveAt(_activeStatesNode.Children.Count - 1);
|
||||
|
||||
for (var i = 0; i < pre.Length; i++)
|
||||
{
|
||||
var (off, val, match, changed, derefInfo) = pre[i];
|
||||
var label = $"+0x{off:X2}:";
|
||||
var changeTag = changed ? " [CHANGED]" : "";
|
||||
var derefTag = derefInfo != null ? $" ({derefInfo})" : "";
|
||||
var display = val == 0
|
||||
? "0"
|
||||
: match != null
|
||||
? $"0x{val:X} ← {match}{changeTag}"
|
||||
: $"0x{val:X}{derefTag}{changeTag}";
|
||||
var color = changed ? "#f85149"
|
||||
: match != null ? "#3fb950"
|
||||
: derefInfo != null && derefInfo.Contains('→') && derefInfo.Contains("State") ? "#d29922" // yellow for indirect state match
|
||||
: val == 0 ? "#484f58"
|
||||
: "#8b949e";
|
||||
|
||||
if (i < _activeStatesNode.Children.Count)
|
||||
{
|
||||
_activeStatesNode.Children[i].Name = label;
|
||||
_activeStatesNode.Children[i].Value = display;
|
||||
_activeStatesNode.Children[i].ValueColor = color;
|
||||
}
|
||||
else
|
||||
{
|
||||
var node = new MemoryNodeViewModel(label) { Value = display, ValueColor = color };
|
||||
_activeStatesNode.Children.Add(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// State Slots — show pointer + int32 at +0x08 for each state slot
|
||||
if (_statesNode is not null && snap.StateSlots.Length > 0)
|
||||
{
|
||||
|
|
@ -303,10 +373,18 @@ public partial class MemoryViewModel : ObservableObject
|
|||
}
|
||||
else
|
||||
{
|
||||
// Read int32 at state+0x08 (the value CE found)
|
||||
var int32Val = snap.StateSlotValues?.Length > i ? snap.StateSlotValues[i] : 0;
|
||||
val = $"0x{ptr:X} [+0x08]={int32Val}";
|
||||
color = ptr == snap.InGameStatePtr ? "#3fb950" : "#8b949e";
|
||||
var isActive = snap.ActiveStates.Contains(ptr);
|
||||
var activeTag = isActive ? " [ACTIVE]" : "";
|
||||
val = $"0x{ptr:X} [+0x08]={int32Val}{activeTag}";
|
||||
|
||||
// Green if current state, cyan if active, default gray
|
||||
if (i < (int)GameStateType.GameNotLoaded && (GameStateType)i == snap.CurrentGameState)
|
||||
color = "#3fb950"; // green — current state
|
||||
else if (isActive)
|
||||
color = "#58a6ff"; // blue — active but not current
|
||||
else
|
||||
color = "#8b949e"; // gray — inactive
|
||||
}
|
||||
|
||||
if (i < _statesNode.Children.Count)
|
||||
|
|
@ -325,11 +403,14 @@ public partial class MemoryViewModel : ObservableObject
|
|||
}
|
||||
}
|
||||
|
||||
// Status text
|
||||
// Status text — show resolved current state
|
||||
if (snap.Attached)
|
||||
StatusText = snap.InGameStatePtr != 0
|
||||
? $"Attached (PID {snap.ProcessId}) — InGame"
|
||||
: $"Attached (PID {snap.ProcessId})";
|
||||
{
|
||||
var stateLabel = snap.CurrentGameState != GameStateType.GameNotLoaded
|
||||
? snap.CurrentGameState.ToString()
|
||||
: snap.InGameStatePtr != 0 ? "InGame" : "unknown";
|
||||
StatusText = $"Attached (PID {snap.ProcessId}) — {stateLabel}";
|
||||
}
|
||||
else if (snap.Error is not null)
|
||||
StatusText = $"Error: {snap.Error}";
|
||||
|
||||
|
|
@ -373,6 +454,57 @@ public partial class MemoryViewModel : ObservableObject
|
|||
_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
|
||||
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 at = name.IndexOf('@');
|
||||
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}] ?";
|
||||
|
|
@ -716,6 +850,9 @@ public partial class MemoryViewModel : ObservableObject
|
|||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (e.Rarity != 0)
|
||||
parts.Add(e.Rarity.ToString());
|
||||
|
||||
if (e.HasVitals)
|
||||
parts.Add(e.IsAlive ? "Alive" : "Dead");
|
||||
|
||||
|
|
@ -725,6 +862,9 @@ public partial class MemoryViewModel : ObservableObject
|
|||
if (e.HasVitals)
|
||||
parts.Add($"HP:{e.LifeCurrent}/{e.LifeTotal}");
|
||||
|
||||
if (e.ActionId != 0)
|
||||
parts.Add($"act:0x{e.ActionId:X}");
|
||||
|
||||
if (e.Components is { Count: > 0 })
|
||||
parts.Add($"{e.Components.Count} comps");
|
||||
|
||||
|
|
@ -741,6 +881,9 @@ public partial class MemoryViewModel : ObservableObject
|
|||
if (e.Path is not null)
|
||||
needed.Add(("Path:", e.Path, true));
|
||||
|
||||
if (e.TransitionName is not null)
|
||||
needed.Add(("Destination:", e.TransitionName, true));
|
||||
|
||||
if (e.HasPosition)
|
||||
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));
|
||||
}
|
||||
|
||||
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 })
|
||||
{
|
||||
var compList = string.Join(", ", e.Components.OrderBy(c => c));
|
||||
|
|
@ -959,6 +1111,18 @@ public partial class MemoryViewModel : ObservableObject
|
|||
ScanResult = _reader.Diagnostics!.ScanActiveStatesVector();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ScanStateDiffExecute()
|
||||
{
|
||||
if (_reader is null || !_reader.IsAttached)
|
||||
{
|
||||
ScanResult = "Error: not attached";
|
||||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.Diagnostics!.ScanStateDiff();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ScanTerrainExecute()
|
||||
{
|
||||
|
|
@ -1009,4 +1173,28 @@ public partial class MemoryViewModel : ObservableObject
|
|||
|
||||
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();
|
||||
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);
|
||||
if (checkedIds.Contains(e.Id))
|
||||
item.IsChecked = true;
|
||||
|
|
|
|||
|
|
@ -766,10 +766,16 @@
|
|||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Scan ActiveVec" Command="{Binding ScanActiveVecExecuteCommand}"
|
||||
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}"
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Camera Diff" Command="{Binding CameraDiffExecuteCommand}"
|
||||
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>
|
||||
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
|
||||
FontSize="10" Foreground="#e6edf3" Background="#0d1117"
|
||||
|
|
|
|||
|
|
@ -10,11 +10,25 @@ public enum EntityCategory
|
|||
Npc,
|
||||
WorldItem,
|
||||
Chest,
|
||||
Shrine,
|
||||
Portal,
|
||||
AreaTransition,
|
||||
Effect,
|
||||
Terrain,
|
||||
MiscObject,
|
||||
Waypoint,
|
||||
Door,
|
||||
Doodad,
|
||||
TownPortal,
|
||||
Critter,
|
||||
}
|
||||
|
||||
public enum MonsterRarity
|
||||
{
|
||||
White,
|
||||
Magic,
|
||||
Rare,
|
||||
Unique,
|
||||
}
|
||||
|
||||
public enum MonsterThreatLevel
|
||||
|
|
@ -39,4 +53,15 @@ public record EntitySnapshot
|
|||
public int LifeTotal { get; init; }
|
||||
public bool IsTargetable { 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 ushort ScanCode { get; init; }
|
||||
public short SkillId { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? InternalName { get; init; }
|
||||
public int ChargesCurrent { get; init; }
|
||||
public int ChargesMax { 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.Core;
|
||||
using Serilog;
|
||||
using MemEntity = Roboto.Memory.Entity;
|
||||
using MemEntityType = Roboto.Memory.EntityType;
|
||||
|
||||
namespace Roboto.Data;
|
||||
|
||||
|
|
@ -288,7 +286,7 @@ public sealed class MemoryPoller : IDisposable
|
|||
{
|
||||
if (e.Address == snap.LocalPlayerPtr) continue;
|
||||
|
||||
var es = MapEntity(e, playerPos);
|
||||
var es = EntityMapper.MapEntity(e, playerPos);
|
||||
allEntities.Add(es);
|
||||
|
||||
if (es.Category == EntityCategory.Monster && es.IsAlive)
|
||||
|
|
@ -315,54 +313,6 @@ public sealed class MemoryPoller : IDisposable
|
|||
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()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
|
|
|||
|
|
@ -3,66 +3,85 @@ using Roboto.GameOffsets.Natives;
|
|||
|
||||
namespace Roboto.GameOffsets.Components;
|
||||
|
||||
/// <summary>Actor component — skills, animations, deployments.</summary>
|
||||
[StructLayout(LayoutKind.Explicit, Size = 0x2E8)]
|
||||
public struct Actor
|
||||
/// <summary>
|
||||
/// Actor component offsets — confirmed from ExileCore2.
|
||||
/// 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;
|
||||
|
||||
/// <summary>Pointer to animation controller.</summary>
|
||||
[FieldOffset(0x1D8)] public nint AnimationControllerPtr;
|
||||
|
||||
/// <summary>Active skills StdVector (of ActiveSkillStructure).</summary>
|
||||
[FieldOffset(0x2C0)] public StdVector ActiveSkills;
|
||||
|
||||
/// <summary>Deployed entities StdVector (of DeployedEntityStructure).</summary>
|
||||
[FieldOffset(0x2D8)] public StdVector DeployedEntities;
|
||||
public const int AnimationId = 0x370;
|
||||
public const int ActiveSkillsVector = 0xB00;
|
||||
public const int CooldownsVector = 0xB18;
|
||||
public const int DeployedEntitiesVector = 0xC10;
|
||||
}
|
||||
|
||||
/// <summary>An entry in the active skills vector.</summary>
|
||||
[StructLayout(LayoutKind.Explicit, Size = 0x28)]
|
||||
public struct ActiveSkillStructure
|
||||
/// <summary>
|
||||
/// An entry in the ActiveSkills vector: shared_ptr pair (0x10 bytes).
|
||||
/// Follow ActiveSkillPtr (first pointer) for skill details.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ActiveSkillEntry
|
||||
{
|
||||
[FieldOffset(0x00)] public nint SkillDetailsPtr;
|
||||
[FieldOffset(0x08)] public short SkillId;
|
||||
[FieldOffset(0x0C)] public byte CanBeUsed;
|
||||
[FieldOffset(0x0D)] public byte CanBeUsedWithWeapon;
|
||||
[FieldOffset(0x10)] public nint CooldownPtr;
|
||||
public nint ActiveSkillPtr;
|
||||
public nint ControlBlockPtr; // shared_ptr control block, not used
|
||||
}
|
||||
|
||||
/// <summary>Detailed info about a skill.</summary>
|
||||
[StructLayout(LayoutKind.Explicit, Size = 0x20)]
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
[FieldOffset(0x00)] public nint NamePtr;
|
||||
[FieldOffset(0x08)] public nint InternalNamePtr;
|
||||
[FieldOffset(0x10)] public int GrantedEffectsPerLevelIdx;
|
||||
[FieldOffset(0x14)] public int IconIndex;
|
||||
[FieldOffset(0x08)] public int UseStage;
|
||||
[FieldOffset(0x0C)] public int CastType;
|
||||
[FieldOffset(0x10)] public uint UnknownIdAndEquipmentInfo;
|
||||
[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>
|
||||
[StructLayout(LayoutKind.Explicit, Size = 0x20)]
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
[FieldOffset(0x00)] public nint CooldownGroupPtr;
|
||||
[FieldOffset(0x08)] public int MaxUses;
|
||||
[FieldOffset(0x0C)] public int CurrentUses;
|
||||
[FieldOffset(0x10)] public int CooldownTimer;
|
||||
[FieldOffset(0x08)] public int ActiveSkillsDatId;
|
||||
[FieldOffset(0x10)] public StdVector CooldownsList; // 0x10-byte entries
|
||||
[FieldOffset(0x30)] public int MaxUses;
|
||||
[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>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
[StructLayout(LayoutKind.Explicit, Pack = 1)]
|
||||
public struct VaalSoulStructure
|
||||
{
|
||||
public nint GrantedEffectsPtr;
|
||||
public int CurrentSouls;
|
||||
public int SoulCost;
|
||||
[FieldOffset(0x00)] public nint ActiveSkillsDatPtr;
|
||||
[FieldOffset(0x08)] public nint UselessPtr;
|
||||
[FieldOffset(0x10)] public int RequiredSouls;
|
||||
[FieldOffset(0x14)] public int CurrentSouls;
|
||||
|
||||
public readonly bool CannotBeUsed => CurrentSouls < RequiredSouls;
|
||||
}
|
||||
|
||||
/// <summary>A deployed entity (totem, mine, etc.).</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct DeployedEntityStructure
|
||||
{
|
||||
public uint EntityId;
|
||||
public int SkillIndex;
|
||||
public int EntityId;
|
||||
public int ActiveSkillsDatId;
|
||||
public int DeployedObjectType;
|
||||
public int PAD_0x014;
|
||||
public int Counter;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,16 +18,13 @@ public enum EntityType
|
|||
Waypoint,
|
||||
AreaTransition,
|
||||
Door,
|
||||
Doodad,
|
||||
}
|
||||
|
||||
public enum MonsterRarity
|
||||
{
|
||||
White,
|
||||
Magic,
|
||||
Rare,
|
||||
Unique,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw entity data read from process memory. No business logic or classification —
|
||||
/// type classification lives in EntityReader (Memory-internal) and EntityClassifier (Data layer).
|
||||
/// </summary>
|
||||
public class Entity
|
||||
{
|
||||
public nint Address { get; }
|
||||
|
|
@ -58,14 +55,25 @@ public class Entity
|
|||
public bool IsTargetable { get; internal set; }
|
||||
public bool IsOpened { 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
|
||||
public bool IsAlive => 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;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -86,7 +94,7 @@ public class Entity
|
|||
/// <summary>
|
||||
/// Short category string derived from path (e.g. "Monsters", "Effects", "NPC").
|
||||
/// </summary>
|
||||
public string Category
|
||||
public string PathCategory
|
||||
{
|
||||
get
|
||||
{
|
||||
|
|
@ -96,36 +104,12 @@ public class Entity
|
|||
}
|
||||
}
|
||||
|
||||
public EntityType Type { get; internal set; }
|
||||
|
||||
internal Entity(nint address, uint id, string? path)
|
||||
{
|
||||
Address = address;
|
||||
Id = id;
|
||||
Path = 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>
|
||||
|
|
@ -139,60 +123,6 @@ public class Entity
|
|||
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()
|
||||
{
|
||||
var pos = HasPosition ? $"({X:F0},{Y:F0})" : "no pos";
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ public sealed class EntityReader
|
|||
var entityId = treeNode.Data.Key.EntityId;
|
||||
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);
|
||||
entity.Type = ClassifyType(path);
|
||||
|
||||
if (registry["entities"].Register(entity.Metadata))
|
||||
dirty = true;
|
||||
|
|
@ -62,26 +66,24 @@ public sealed class EntityReader
|
|||
entity.Z = z;
|
||||
}
|
||||
|
||||
// Read component names for non-trivial entities
|
||||
if (hasComponentLookup &&
|
||||
entity.Type != EntityType.Effect &&
|
||||
entity.Type != EntityType.Terrain &&
|
||||
entity.Type != EntityType.Critter)
|
||||
// Read component names for non-trivial entities (skip effects, terrain, critters)
|
||||
if (hasComponentLookup && !IsLowPriorityPath(entity.Type))
|
||||
{
|
||||
var lookup = _components.ReadComponentLookup(entityPtr);
|
||||
if (lookup is not null)
|
||||
{
|
||||
entity.Components = new HashSet<string>(lookup.Keys);
|
||||
entity.ReclassifyFromComponents();
|
||||
ReclassifyFromComponents(entity);
|
||||
|
||||
if (registry["components"].Register(lookup.Keys))
|
||||
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);
|
||||
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);
|
||||
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>
|
||||
/// Reads entity path string via EntityDetailsPtr → std::wstring.
|
||||
/// </summary>
|
||||
|
|
@ -210,4 +327,93 @@ public sealed class EntityReader
|
|||
|
||||
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 Roboto.Memory.States;
|
||||
using Serilog;
|
||||
|
||||
namespace Roboto.Memory;
|
||||
|
|
@ -28,6 +29,7 @@ public class GameMemoryReader : IDisposable
|
|||
|
||||
// Sub-readers (created on Attach)
|
||||
private MemoryContext? _ctx;
|
||||
private GameStates? _gameStates;
|
||||
private GameStateReader? _stateReader;
|
||||
private nint _cachedCameraMatrixAddr;
|
||||
private nint _lastInGameState;
|
||||
|
|
@ -37,6 +39,7 @@ public class GameMemoryReader : IDisposable
|
|||
private TerrainReader? _terrain;
|
||||
private MsvcStringReader? _strings;
|
||||
private RttiResolver? _rtti;
|
||||
private SkillReader? _skills;
|
||||
|
||||
public ObjectRegistry Registry => _registry;
|
||||
public MemoryDiagnostics? Diagnostics { get; private set; }
|
||||
|
|
@ -88,12 +91,14 @@ public class GameMemoryReader : IDisposable
|
|||
}
|
||||
|
||||
// Create sub-readers
|
||||
_gameStates = new GameStates(_ctx);
|
||||
_strings = new MsvcStringReader(_ctx);
|
||||
_rtti = new RttiResolver(_ctx);
|
||||
_stateReader = new GameStateReader(_ctx);
|
||||
_components = new ComponentReader(_ctx, _strings);
|
||||
_entities = new EntityReader(_ctx, _components, _strings);
|
||||
_terrain = new TerrainReader(_ctx);
|
||||
_skills = new SkillReader(_ctx, _components, _strings);
|
||||
Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
|
||||
|
||||
return true;
|
||||
|
|
@ -103,12 +108,14 @@ public class GameMemoryReader : IDisposable
|
|||
{
|
||||
_ctx?.Memory.Dispose();
|
||||
_ctx = null;
|
||||
_gameStates = null;
|
||||
_stateReader = null;
|
||||
_components = null;
|
||||
_entities = null;
|
||||
_terrain = null;
|
||||
_strings = null;
|
||||
_rtti = null;
|
||||
_skills = null;
|
||||
Diagnostics = null;
|
||||
}
|
||||
|
||||
|
|
@ -145,76 +152,76 @@ public class GameMemoryReader : IDisposable
|
|||
|
||||
try
|
||||
{
|
||||
// Resolve InGameState from controller
|
||||
var inGameState = _stateReader!.ResolveInGameState(snap);
|
||||
if (inGameState == 0)
|
||||
// Hierarchical state read — resolves controller, state slots, cascades to children
|
||||
var gs = _gameStates!;
|
||||
if (!gs.Update())
|
||||
return snap;
|
||||
snap.InGameStatePtr = inGameState;
|
||||
_lastInGameState = inGameState;
|
||||
_lastController = snap.ControllerPtr;
|
||||
|
||||
// Read all state slot pointers
|
||||
_stateReader.ReadStateSlots(snap);
|
||||
// Populate snapshot from state hierarchy
|
||||
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
|
||||
var ingameData = mem.ReadPointer(inGameState + offsets.IngameDataFromStateOffset);
|
||||
snap.AreaInstancePtr = ingameData;
|
||||
// Area level — prefer hierarchical read, keep static offset as fallback
|
||||
var areaLevel = gs.InGame.AreaInstance.AreaLevel;
|
||||
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
|
||||
if (offsets.AreaLevelIsByte)
|
||||
{
|
||||
var level = mem.Read<byte>(ingameData + offsets.AreaLevelOffset);
|
||||
if (level > 0 && level < 200)
|
||||
snap.AreaLevel = level;
|
||||
snap.CameraMatrix = gs.InGame.WorldData.CameraMatrix;
|
||||
_cachedCameraMatrixAddr = gs.InGame.WorldData.CameraMatrixAddress;
|
||||
}
|
||||
else
|
||||
{
|
||||
var level = mem.Read<int>(ingameData + offsets.AreaLevelOffset);
|
||||
if (level > 0 && level < 200)
|
||||
snap.AreaLevel = level;
|
||||
// Fallback: direct camera read (inline or pointer-based)
|
||||
ReadCameraMatrix(snap, gs.InGame.Address);
|
||||
}
|
||||
|
||||
// Area hash
|
||||
snap.AreaHash = mem.Read<uint>(ingameData + offsets.AreaHashOffset);
|
||||
_lastInGameState = gs.InGame.Address;
|
||||
_lastController = gs.ControllerPtr;
|
||||
|
||||
// ServerData pointer
|
||||
var serverData = mem.ReadPointer(ingameData + offsets.ServerDataOffset);
|
||||
snap.ServerDataPtr = serverData;
|
||||
// Diagnostic state slots — GameStateReader still used for MemoryDiagnostics compat
|
||||
_stateReader!.ReadStateSlots(snap);
|
||||
|
||||
// LocalPlayer — try direct offset first, fallback to ServerData chain
|
||||
if (offsets.LocalPlayerDirectOffset > 0)
|
||||
snap.LocalPlayerPtr = mem.ReadPointer(ingameData + offsets.LocalPlayerDirectOffset);
|
||||
if (snap.LocalPlayerPtr == 0 && serverData != 0)
|
||||
snap.LocalPlayerPtr = mem.ReadPointer(serverData + offsets.LocalPlayerOffset);
|
||||
// Loading/escape overrides from GameStateReader (active states vector method)
|
||||
_stateReader.ReadIsLoading(snap);
|
||||
_stateReader.ReadEscapeState(snap);
|
||||
|
||||
// Entity count and list
|
||||
var entityCount = (int)mem.Read<long>(ingameData + offsets.EntityListOffset + offsets.EntityCountInternalOffset);
|
||||
if (entityCount > 0 && entityCount < 50000)
|
||||
// Reconcile CurrentGameState with reliable loading/escape detection
|
||||
if (snap.IsLoading)
|
||||
snap.CurrentGameState = States.GameStateType.AreaLoadingState;
|
||||
else if (snap.IsEscapeOpen)
|
||||
snap.CurrentGameState = States.GameStateType.EscapeState;
|
||||
|
||||
var ingameData = gs.InGame.AreaInstance.Address;
|
||||
if (ingameData != 0)
|
||||
{
|
||||
snap.EntityCount = entityCount;
|
||||
// Entity list
|
||||
if (snap.EntityCount > 0)
|
||||
_entities!.ReadEntities(snap, ingameData);
|
||||
}
|
||||
|
||||
// Player vitals & position — ECS
|
||||
if (snap.LocalPlayerPtr != 0)
|
||||
{
|
||||
// Invalidate caches if LocalPlayer entity changed (zone change)
|
||||
if (snap.LocalPlayerPtr != _components!.LastLocalPlayer)
|
||||
_terrain!.InvalidateCache();
|
||||
_components.InvalidateCaches(snap.LocalPlayerPtr);
|
||||
_components.ReadPlayerVitals(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
|
||||
if (snap.InGameStatePtr != 0)
|
||||
snap.StateFlagBytes = mem.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30);
|
||||
|
|
@ -296,8 +303,13 @@ public class GameMemoryReader : IDisposable
|
|||
/// </summary>
|
||||
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(
|
||||
_cachedCameraMatrixAddr,
|
||||
cameraAddr,
|
||||
_components?.CachedRenderComponentAddr ?? 0,
|
||||
_components?.CachedLifeComponentAddr ?? 0,
|
||||
_lastInGameState,
|
||||
|
|
|
|||
|
|
@ -113,24 +113,39 @@ public sealed class GameStateReader
|
|||
}
|
||||
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)
|
||||
{
|
||||
// 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 endPtr = mem.ReadPointer(controller + offsets.ActiveStatesOffset + 16);
|
||||
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;
|
||||
|
||||
if (beginPtr != 0 && endPtr > beginPtr)
|
||||
{
|
||||
var size = (int)(endPtr - beginPtr);
|
||||
if (size is > 0 and < 0x1000)
|
||||
{
|
||||
var data = mem.ReadBytes(beginPtr, size);
|
||||
if (data is not null)
|
||||
{
|
||||
var 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);
|
||||
rawList.Add(ptr);
|
||||
|
|
@ -141,7 +156,6 @@ public sealed class GameStateReader
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read all non-null pointer-like qwords from controller (outside state array)
|
||||
var stateArrayStart = offsets.StatesBeginOffset;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Numerics;
|
||||
using Roboto.Memory.States;
|
||||
|
||||
namespace Roboto.Memory;
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ public class GameStateSnapshot
|
|||
public nint GameStateBase;
|
||||
public bool OffsetsConfigured;
|
||||
public int StatesCount;
|
||||
public GameStateType CurrentGameState = GameStateType.GameNotLoaded;
|
||||
|
||||
// Pointers
|
||||
public nint ControllerPtr;
|
||||
|
|
@ -56,6 +58,10 @@ public class GameStateSnapshot
|
|||
public nint ActiveStatesBegin, ActiveStatesEnd; // debug: raw vector pointers
|
||||
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, string? Match, bool Changed, string? DerefInfo)[] ControllerPreSlots = []; // qwords before state array
|
||||
|
||||
// Player skills (from Actor component)
|
||||
public List<SkillSnapshot>? PlayerSkills;
|
||||
|
||||
// Camera
|
||||
public Matrix4x4? CameraMatrix;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -96,6 +96,38 @@ public sealed class MsvcStringReader
|
|||
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>
|
||||
/// Reads a null-terminated UTF-8 string (up to 256 bytes).
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@
|
|||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.12" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Automata.Core\Automata.Core.csproj" />
|
||||
<ProjectReference Include="..\Roboto.GameOffsets\Roboto.GameOffsets.csproj" />
|
||||
</ItemGroup>
|
||||
</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