lots done

This commit is contained in:
Boki 2026-03-02 11:17:37 -05:00
parent 1ba7c39c30
commit fbd0ba445a
59 changed files with 6074 additions and 3598 deletions

View file

@ -45,6 +45,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Data", "lib\Sideki
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Data.Builder", "lib\Sidekick\src\Sidekick.Data.Builder\Sidekick.Data.Builder.csproj", "{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Data.Builder", "lib\Sidekick\src\Sidekick.Data.Builder\Sidekick.Data.Builder.csproj", "{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "roboto", "roboto", "{D1A2B3C4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Core", "src\Roboto.Core\Roboto.Core.csproj", "{A31E6F94-A702-4B58-8317-83658E556B5C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Input", "src\Roboto.Input\Roboto.Input.csproj", "{E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Systems", "src\Roboto.Systems\Roboto.Systems.csproj", "{95AC4C34-26A0-4D7F-A712-375EB28B54B8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Engine", "src\Roboto.Engine\Roboto.Engine.csproj", "{C2E97306-20E4-4A69-A7AB-541A72614C76}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Navigation", "src\Roboto.Navigation\Roboto.Navigation.csproj", "{F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Data", "src\Roboto.Data\Roboto.Data.csproj", "{1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -130,6 +144,30 @@ Global
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Debug|Any CPU.Build.0 = Debug|Any CPU {E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Release|Any CPU.ActiveCfg = Release|Any CPU {E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Release|Any CPU.Build.0 = Release|Any CPU {E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Release|Any CPU.Build.0 = Release|Any CPU
{A31E6F94-A702-4B58-8317-83658E556B5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A31E6F94-A702-4B58-8317-83658E556B5C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A31E6F94-A702-4B58-8317-83658E556B5C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A31E6F94-A702-4B58-8317-83658E556B5C}.Release|Any CPU.Build.0 = Release|Any CPU
{E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A}.Release|Any CPU.Build.0 = Release|Any CPU
{95AC4C34-26A0-4D7F-A712-375EB28B54B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{95AC4C34-26A0-4D7F-A712-375EB28B54B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{95AC4C34-26A0-4D7F-A712-375EB28B54B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{95AC4C34-26A0-4D7F-A712-375EB28B54B8}.Release|Any CPU.Build.0 = Release|Any CPU
{C2E97306-20E4-4A69-A7AB-541A72614C76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C2E97306-20E4-4A69-A7AB-541A72614C76}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C2E97306-20E4-4A69-A7AB-541A72614C76}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C2E97306-20E4-4A69-A7AB-541A72614C76}.Release|Any CPU.Build.0 = Release|Any CPU
{F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D}.Release|Any CPU.Build.0 = Release|Any CPU
{1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{6432F6A5-11A0-4960-AFFC-E810D4325C35} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} {6432F6A5-11A0-4960-AFFC-E810D4325C35} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
@ -151,5 +189,12 @@ Global
{8CEE036C-A229-4F22-BD0E-D7CDAE13E54F} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC} {8CEE036C-A229-4F22-BD0E-D7CDAE13E54F} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{9428D5D4-4061-467A-BD26-C1FEED95E8E6} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC} {9428D5D4-4061-467A-BD26-C1FEED95E8E6} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC} {E5C26A34-5EDF-488B-93C7-F8738F2CEB97} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{D1A2B3C4-E5F6-7890-ABCD-EF1234567890} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
{A31E6F94-A702-4B58-8317-83658E556B5C} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
{E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
{95AC4C34-26A0-4D7F-A712-375EB28B54B8} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
{C2E97306-20E4-4A69-A7AB-541A72614C76} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
{F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
{1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View file

@ -3,11 +3,14 @@
"Animated", "Animated",
"AreaTransition", "AreaTransition",
"BaseEvents", "BaseEvents",
"Brackets",
"Buffs", "Buffs",
"Chest", "Chest",
"ControlZone", "ControlZone",
"CritterAI", "CritterAI",
"DiesAfterTime",
"Functions", "Functions",
"GlobalAudioParamEvents",
"HideoutDoodad", "HideoutDoodad",
"InteractionAction", "InteractionAction",
"Inventories", "Inventories",

View file

@ -8,8 +8,13 @@
"Metadata/Characters/Str/StrFourb", "Metadata/Characters/Str/StrFourb",
"Metadata/Characters/StrDex/StrDexFourb", "Metadata/Characters/StrDex/StrDexFourb",
"Metadata/Characters/StrInt/StrIntFourb", "Metadata/Characters/StrInt/StrIntFourb",
"Metadata/Chests/EzomyteChest_02",
"Metadata/Chests/EzomyteChest_05", "Metadata/Chests/EzomyteChest_05",
"Metadata/Chests/EzomyteChest_06", "Metadata/Chests/EzomyteChest_06",
"Metadata/Chests/LeagueIncursion/EncounterChest",
"Metadata/Chests/MossyChest11",
"Metadata/Chests/MossyChest20",
"Metadata/Chests/MossyChest21",
"Metadata/Chests/MossyChest26", "Metadata/Chests/MossyChest26",
"Metadata/Critters/Chicken/Chicken_kingsmarch", "Metadata/Critters/Chicken/Chicken_kingsmarch",
"Metadata/Critters/Hedgehog/HedgehogSlow", "Metadata/Critters/Hedgehog/HedgehogSlow",
@ -19,13 +24,16 @@
"Metadata/Effects/Microtransactions/foot_prints/harvest02/footprints_harvest", "Metadata/Effects/Microtransactions/foot_prints/harvest02/footprints_harvest",
"Metadata/Effects/PermanentEffect", "Metadata/Effects/PermanentEffect",
"Metadata/Effects/ServerEffect", "Metadata/Effects/ServerEffect",
"Metadata/Effects/Spells/monsters_effects/Act1_FOUR/CarrionCrone/IceSpike",
"Metadata/MiscellaneousObjects/AreaTransitionBlockage", "Metadata/MiscellaneousObjects/AreaTransitionBlockage",
"Metadata/MiscellaneousObjects/AreaTransitionDoodad", "Metadata/MiscellaneousObjects/AreaTransitionDoodad",
"Metadata/MiscellaneousObjects/AreaTransition_Animate", "Metadata/MiscellaneousObjects/AreaTransition_Animate",
"Metadata/MiscellaneousObjects/Checkpoint", "Metadata/MiscellaneousObjects/Checkpoint",
"Metadata/MiscellaneousObjects/Doodad", "Metadata/MiscellaneousObjects/Doodad",
"Metadata/MiscellaneousObjects/DoodadInvisible",
"Metadata/MiscellaneousObjects/DoodadNoBlocking", "Metadata/MiscellaneousObjects/DoodadNoBlocking",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_20_1", "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_20_1",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_4.75_1",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_6_4", "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_6_4",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_4.75_1", "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_4.75_1",
"Metadata/MiscellaneousObjects/GuildStash", "Metadata/MiscellaneousObjects/GuildStash",
@ -42,16 +50,22 @@
"Metadata/MiscellaneousObjects/Stash", "Metadata/MiscellaneousObjects/Stash",
"Metadata/MiscellaneousObjects/Waypoint", "Metadata/MiscellaneousObjects/Waypoint",
"Metadata/MiscellaneousObjects/WorldItem", "Metadata/MiscellaneousObjects/WorldItem",
"Metadata/Monsters/Hags/Objects/BossRoomMinimapIcon",
"Metadata/Monsters/Hags/UrchinHag1", "Metadata/Monsters/Hags/UrchinHag1",
"Metadata/Monsters/Hags/UrchinHagBoss",
"Metadata/Monsters/InvisibleFire/MDCarrionCroneWave",
"Metadata/Monsters/Urchins/MeleeUrchin1", "Metadata/Monsters/Urchins/MeleeUrchin1",
"Metadata/Monsters/Urchins/SlingUrchin1", "Metadata/Monsters/Urchins/SlingUrchin1",
"Metadata/Monsters/Wolves/RottenWolf1_", "Metadata/Monsters/Wolves/RottenWolf1_",
"Metadata/Monsters/Wolves/RottenWolfDead", "Metadata/Monsters/Wolves/RottenWolfDead",
"Metadata/Monsters/Wolves/RottenWolfHagSummonedDead",
"Metadata/Monsters/Zombies/CourtGuardZombieUnarmed", "Metadata/Monsters/Zombies/CourtGuardZombieUnarmed",
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxe", "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxe",
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxePhysics__",
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmed", "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmed",
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmedPhysics", "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmedPhysics",
"Metadata/NPC/Four_Act1/ClearfellPosting1", "Metadata/NPC/Four_Act1/ClearfellPosting1",
"Metadata/NPC/Four_Act1/ClearfellPosting3",
"Metadata/NPC/Four_Act1/DogTrader_Entrance", "Metadata/NPC/Four_Act1/DogTrader_Entrance",
"Metadata/NPC/Four_Act1/ExecutionerFemaleNPCTown", "Metadata/NPC/Four_Act1/ExecutionerFemaleNPCTown",
"Metadata/NPC/Four_Act1/EzomyteCivilianFemale01", "Metadata/NPC/Four_Act1/EzomyteCivilianFemale01",
@ -70,17 +84,31 @@
"Metadata/NPC/Four_Act1/UnaAfterIronCount", "Metadata/NPC/Four_Act1/UnaAfterIronCount",
"Metadata/NPC/Four_Act1/UnaHoodedOneInjured", "Metadata/NPC/Four_Act1/UnaHoodedOneInjured",
"Metadata/NPC/League/Incursion/AlvaIncursionWild", "Metadata/NPC/League/Incursion/AlvaIncursionWild",
"Metadata/Pet/BabyBossesHumans/BabyBrutus/BabyBrutus",
"Metadata/Pet/BabyChimera/BabyChimera",
"Metadata/Pet/BetaKiwis/BaronKiwi",
"Metadata/Pet/BetaKiwis/FaridunKiwi", "Metadata/Pet/BetaKiwis/FaridunKiwi",
"Metadata/Pet/BookAndQuillPet/BookAndQuillPet_Abyss",
"Metadata/Pet/FledglingBellcrow/FledglingBellcrow", "Metadata/Pet/FledglingBellcrow/FledglingBellcrow",
"Metadata/Pet/LandSharkPet/LandSharkPet",
"Metadata/Pet/OctopusParasite/OctopusParasiteCelestial",
"Metadata/Pet/OrigamiPet/OrigamiPetBase", "Metadata/Pet/OrigamiPet/OrigamiPetBase",
"Metadata/Pet/Phoenix/PhoenixPetBlue",
"Metadata/Pet/Phoenix/PhoenixPetGreen", "Metadata/Pet/Phoenix/PhoenixPetGreen",
"Metadata/Pet/Phoenix/PhoenixPetRed", "Metadata/Pet/Phoenix/PhoenixPetRed",
"Metadata/Pet/QuadrillaPet/QuadrillaArmoured",
"Metadata/Pet/ScavengerBat/ScavengerBat",
"Metadata/Projectiles/CarrionCroneIceSpear",
"Metadata/Projectiles/HagBossIceShard",
"Metadata/Projectiles/IceSpear",
"Metadata/Projectiles/SlingUrchinProjectile", "Metadata/Projectiles/SlingUrchinProjectile",
"Metadata/Projectiles/Twister", "Metadata/Projectiles/Twister",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1", "Metadata/Terrain/Doodads/Gallows/ClearfellBull1",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1_CountKilled", "Metadata/Terrain/Doodads/Gallows/ClearfellBull1_CountKilled",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull2", "Metadata/Terrain/Doodads/Gallows/ClearfellBull2",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull2_CountKilled", "Metadata/Terrain/Doodads/Gallows/ClearfellBull2_CountKilled",
"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_2/Objects/RuleSet",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/Act1_finished_LightController", "Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/Act1_finished_LightController",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBenchEzomyte", "Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBenchEzomyte",
@ -88,6 +116,9 @@
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBench_EnableRendering", "Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBench_EnableRendering",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_DisableRendering", "Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_DisableRendering",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_EnableRendering", "Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_EnableRendering",
"Metadata/Terrain/Tools/AudioTools/G1_2/BurrowEntrance",
"Metadata/Terrain/Tools/AudioTools/G1_2/ForestEntrance",
"Metadata/Terrain/Tools/AudioTools/G1_2/HagArena",
"Metadata/Terrain/Tools/AudioTools/G1_2/RiverRapidsMedium", "Metadata/Terrain/Tools/AudioTools/G1_2/RiverRapidsMedium",
"Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio", "Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio",
"Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio" "Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio"

View file

@ -51,18 +51,13 @@
"PositionXOffset": 312, "PositionXOffset": 312,
"PositionYOffset": 316, "PositionYOffset": 316,
"PositionZOffset": 320, "PositionZOffset": 320,
"CameraOffset": 776,
"CameraMatrixOffset": 416,
"TerrainListOffset": 3264, "TerrainListOffset": 3264,
"TerrainInline": true, "TerrainInline": true,
"TerrainDimensionsOffset": 144, "TerrainDimensionsOffset": 144,
"TerrainWalkableGridOffset": 328, "TerrainWalkableGridOffset": 328,
"TerrainBytesPerRowOffset": 424, "TerrainBytesPerRowOffset": 424,
"TerrainGridPtrOffset": 8, "TerrainGridPtrOffset": 8,
"SubTilesPerCell": 23, "SubTilesPerCell": 23
"InGameStateOffset": 0,
"IngameDataOffset": 0,
"TerrainDataOffset": 0,
"NumColsOffset": 0,
"NumRowsOffset": 0,
"LayerMeleeOffset": 0,
"BytesPerRowOffset": 0
} }

View file

@ -0,0 +1,382 @@
using System.Text;
using Serilog;
namespace Automata.Memory;
/// <summary>
/// Reads entity components via ECS: component list discovery, vitals, position, component lookup.
/// </summary>
public sealed class ComponentReader
{
private readonly MemoryContext _ctx;
private readonly MsvcStringReader _strings;
// Cached component indices — invalidated when LocalPlayer changes
private int _cachedLifeIndex = -1;
private int _cachedRenderIndex = -1;
private nint _lastLocalPlayer;
public ComponentReader(MemoryContext ctx, MsvcStringReader strings)
{
_ctx = ctx;
_strings = strings;
}
/// <summary>
/// Invalidates cached component indices when LocalPlayer entity changes (zone change, new character).
/// </summary>
public void InvalidateCaches(nint newLocalPlayer)
{
if (newLocalPlayer != _lastLocalPlayer)
{
_cachedLifeIndex = -1;
_cachedRenderIndex = -1;
_lastLocalPlayer = newLocalPlayer;
}
}
public int CachedLifeIndex => _cachedLifeIndex;
public nint LastLocalPlayer => _lastLocalPlayer;
/// <summary>
/// Finds the best component list from the entity, trying multiple strategies:
/// 1. StdVector at entity+ComponentListOffset (ExileCore standard)
/// 2. Inner entity: entity+0x000 → deref → StdVector at +ComponentListOffset (POE2 wrapper)
/// 3. Scan entity memory for any StdVector with many pointer-sized elements
/// </summary>
public (nint First, int Count) FindComponentList(nint entity)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
// Strategy 1: direct StdVector at entity+ComponentListOffset
var compFirst = mem.ReadPointer(entity + offsets.ComponentListOffset);
var compLast = mem.ReadPointer(entity + offsets.ComponentListOffset + 8);
var count = 0;
if (compFirst != 0 && compLast > compFirst && (compLast - compFirst) < 0x2000)
count = (int)((compLast - compFirst) / 8);
if (count > 1) return (compFirst, count);
// Strategy 2: POE2 may wrap entities — entity+0x000 is a pointer to the real entity
var innerEntity = mem.ReadPointer(entity);
if (innerEntity != 0 && innerEntity != entity && !_ctx.IsModuleAddress(innerEntity))
{
var high = (ulong)innerEntity >> 32;
if (high > 0 && high < 0x7FFF && (innerEntity & 0x3) == 0)
{
var innerFirst = mem.ReadPointer(innerEntity + offsets.ComponentListOffset);
var innerLast = mem.ReadPointer(innerEntity + offsets.ComponentListOffset + 8);
if (innerFirst != 0 && innerLast > innerFirst && (innerLast - innerFirst) < 0x2000)
{
var innerCount = (int)((innerLast - innerFirst) / 8);
if (innerCount > count)
{
Log.Debug("ECS: Using inner entity 0x{Addr:X} component list ({Count} entries)",
innerEntity, innerCount);
return (innerFirst, innerCount);
}
}
}
}
// Strategy 3: scan entity memory for StdVector patterns with ≥3 pointer-sized elements
var entityData = mem.ReadBytes(entity, 0x300);
if (entityData is not null)
{
for (var off = 0; off + 24 <= entityData.Length; off += 8)
{
var f = (nint)BitConverter.ToInt64(entityData, off);
var l = (nint)BitConverter.ToInt64(entityData, off + 8);
if (f == 0 || l <= f) continue;
var sz = l - f;
if (sz < 24 || sz > 0x2000 || sz % 8 != 0) continue;
var n = (int)(sz / 8);
if (n <= count) continue;
var firstEl = mem.ReadPointer(f);
var h = (ulong)firstEl >> 32;
if (h == 0 || h >= 0x7FFF || (firstEl & 0x3) != 0) continue;
Log.Debug("ECS: Found StdVector at entity+0x{Off:X} with {Count} elements", off, n);
if (n > count) { compFirst = f; count = n; }
}
}
return (compFirst, count);
}
/// <summary>
/// Reads vitals via ECS: LocalPlayer → ComponentList → Life component.
/// Auto-discovers the Life component index, caches it.
/// </summary>
public void ReadPlayerVitals(GameStateSnapshot snap)
{
var entity = snap.LocalPlayerPtr;
if (entity == 0) return;
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var (compFirst, count) = FindComponentList(entity);
if (count <= 0) return;
// Try cached index first
if (_cachedLifeIndex >= 0 && _cachedLifeIndex < count)
{
var lifeComp = mem.ReadPointer(compFirst + _cachedLifeIndex * 8);
if (lifeComp != 0 && TryReadVitals(snap, lifeComp))
return;
_cachedLifeIndex = -1;
}
// Scan all component pointers for VitalStruct pattern
for (var i = 0; i < count; i++)
{
var compPtr = mem.ReadPointer(compFirst + i * 8);
if (compPtr == 0) continue;
var high = (ulong)compPtr >> 32;
if (high == 0 || high >= 0x7FFF) continue;
if ((compPtr & 0x3) != 0) continue;
var hpTotal = mem.Read<int>(compPtr + offsets.LifeHealthOffset + offsets.VitalTotalOffset);
if (hpTotal < 20 || hpTotal > 200000) continue;
var hpCurrent = mem.Read<int>(compPtr + offsets.LifeHealthOffset + offsets.VitalCurrentOffset);
if (hpCurrent < 0 || hpCurrent > hpTotal + 1000) continue;
var manaTotal = mem.Read<int>(compPtr + offsets.LifeManaOffset + offsets.VitalTotalOffset);
if (manaTotal < 0 || manaTotal > 200000) continue;
var manaCurrent = mem.Read<int>(compPtr + offsets.LifeManaOffset + offsets.VitalCurrentOffset);
if (manaCurrent < 0 || manaCurrent > manaTotal + 1000) continue;
var esTotal = mem.Read<int>(compPtr + offsets.LifeEsOffset + offsets.VitalTotalOffset);
if (manaTotal == 0 && esTotal == 0) continue;
_cachedLifeIndex = i;
Log.Information("ECS: Life component at index {Index} (0x{Addr:X}) — HP: {Hp}/{HpMax}, Mana: {Mana}/{ManaMax}",
i, compPtr, hpCurrent, hpTotal, manaCurrent, manaTotal);
TryReadVitals(snap, compPtr);
return;
}
}
/// <summary>
/// Attempts to read all vitals from a Life component pointer.
/// </summary>
public bool TryReadVitals(GameStateSnapshot snap, nint lifeComp)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var hp = mem.Read<int>(lifeComp + offsets.LifeHealthOffset + offsets.VitalCurrentOffset);
var hpMax = mem.Read<int>(lifeComp + offsets.LifeHealthOffset + offsets.VitalTotalOffset);
var mana = mem.Read<int>(lifeComp + offsets.LifeManaOffset + offsets.VitalCurrentOffset);
var manaMax = mem.Read<int>(lifeComp + offsets.LifeManaOffset + offsets.VitalTotalOffset);
var es = mem.Read<int>(lifeComp + offsets.LifeEsOffset + offsets.VitalCurrentOffset);
var esMax = mem.Read<int>(lifeComp + offsets.LifeEsOffset + offsets.VitalTotalOffset);
if (hpMax <= 0 || hpMax > 200000 || hp < 0 || hp > hpMax + 1000) return false;
if (manaMax < 0 || manaMax > 200000 || mana < 0) return false;
snap.HasVitals = true;
snap.LifeCurrent = hp;
snap.LifeTotal = hpMax;
snap.ManaCurrent = mana;
snap.ManaTotal = manaMax;
snap.EsCurrent = es;
snap.EsTotal = esMax;
return true;
}
/// <summary>
/// Reads player position from the Render component via ECS.
/// Auto-discovers the Render component index, caches it.
/// </summary>
public void ReadPlayerPosition(GameStateSnapshot snap)
{
var entity = snap.LocalPlayerPtr;
if (entity == 0) return;
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var (compFirst, count) = FindComponentList(entity);
if (count <= 0) return;
// Try configured index first
if (offsets.RenderComponentIndex >= 0 && offsets.RenderComponentIndex < count)
{
var renderComp = mem.ReadPointer(compFirst + offsets.RenderComponentIndex * 8);
if (renderComp != 0 && TryReadPosition(snap, renderComp))
return;
}
// Try cached index
if (_cachedRenderIndex >= 0 && _cachedRenderIndex < count)
{
var renderComp = mem.ReadPointer(compFirst + _cachedRenderIndex * 8);
if (renderComp != 0 && TryReadPosition(snap, renderComp))
return;
_cachedRenderIndex = -1;
}
// Auto-discover: scan for float triplet that looks like world coordinates
for (var i = 0; i < count; i++)
{
if (i == _cachedLifeIndex) continue;
var compPtr = mem.ReadPointer(compFirst + i * 8);
if (compPtr == 0) continue;
var high = (ulong)compPtr >> 32;
if (high == 0 || high >= 0x7FFF) continue;
if ((compPtr & 0x3) != 0) continue;
if (TryReadPosition(snap, compPtr))
{
_cachedRenderIndex = i;
Log.Information("ECS: Render component at index {Index} (0x{Addr:X}) — Pos: ({X:F1}, {Y:F1}, {Z:F1})",
i, compPtr, snap.PlayerX, snap.PlayerY, snap.PlayerZ);
return;
}
}
}
/// <summary>
/// Attempts to read position from a Render component pointer.
/// </summary>
public bool TryReadPosition(GameStateSnapshot snap, nint renderComp)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var x = mem.Read<float>(renderComp + offsets.PositionXOffset);
var y = mem.Read<float>(renderComp + offsets.PositionYOffset);
var z = mem.Read<float>(renderComp + offsets.PositionZOffset);
if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) return false;
if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) return false;
if (x < 50 || x > 50000 || y < 50 || y > 50000) return false;
if (MathF.Abs(z) > 5000) return false;
snap.HasPosition = true;
snap.PlayerX = x;
snap.PlayerY = y;
snap.PlayerZ = z;
return true;
}
/// <summary>
/// Reads position floats and validates as world coordinates (for entity position reading).
/// </summary>
public bool TryReadPositionRaw(nint comp, out float x, out float y, out float z)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
x = mem.Read<float>(comp + offsets.PositionXOffset);
y = mem.Read<float>(comp + offsets.PositionYOffset);
z = mem.Read<float>(comp + offsets.PositionZOffset);
if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) return false;
if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) return false;
if (x < 50 || x > 50000 || y < 50 || y > 50000) return false;
if (MathF.Abs(z) > 5000) return false;
return true;
}
/// <summary>
/// Resolves EntityDetails pointer for an entity, handling ECS inner entity wrapper.
/// </summary>
public nint ResolveEntityDetails(nint entity)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var detailsPtr = mem.ReadPointer(entity + offsets.EntityHeaderOffset);
if (_ctx.IsValidHeapPtr(detailsPtr))
return detailsPtr;
var innerEntity = mem.ReadPointer(entity);
if (innerEntity == 0 || innerEntity == entity || _ctx.IsModuleAddress(innerEntity))
return 0;
if (!_ctx.IsValidHeapPtr(innerEntity))
return 0;
detailsPtr = mem.ReadPointer(innerEntity + offsets.EntityHeaderOffset);
return _ctx.IsValidHeapPtr(detailsPtr) ? detailsPtr : 0;
}
/// <summary>
/// Reads the component name→index mapping for an entity.
/// Chain: entity → EntityDetails(+0x28) → ComponentLookup obj(+0x28/+0x30) → Vec2 entries.
/// </summary>
public Dictionary<string, int>? ReadComponentLookup(nint entity)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
if (offsets.ComponentLookupEntrySize == 0) return null;
var detailsPtr = ResolveEntityDetails(entity);
if (detailsPtr == 0) return null;
var lookupObj = mem.ReadPointer(detailsPtr + offsets.ComponentLookupOffset);
if (!_ctx.IsValidHeapPtr(lookupObj)) return null;
var vec2Begin = mem.ReadPointer(lookupObj + offsets.ComponentLookupVec2Offset);
var vec2End = mem.ReadPointer(lookupObj + offsets.ComponentLookupVec2Offset + 8);
if (vec2Begin == 0 || vec2End <= vec2Begin) return null;
var size = vec2End - vec2Begin;
var entrySize = offsets.ComponentLookupEntrySize;
if (size % entrySize != 0 || size > 0x10000) return null;
var entryCount = (int)(size / entrySize);
var allData = mem.ReadBytes(vec2Begin, (int)size);
if (allData is null) return null;
var result = new Dictionary<string, int>(entryCount);
for (var i = 0; i < entryCount; i++)
{
var entryOff = i * entrySize;
var namePtr = (nint)BitConverter.ToInt64(allData, entryOff + offsets.ComponentLookupNameOffset);
if (namePtr == 0) continue;
var name = _strings.ReadCharPtr(namePtr);
if (name is null) continue;
var index = BitConverter.ToInt32(allData, entryOff + offsets.ComponentLookupIndexOffset);
if (index < 0 || index > 200) continue;
result[name] = index;
}
return result.Count > 0 ? result : null;
}
/// <summary>
/// Checks if an entity has a component by name.
/// </summary>
public bool HasComponent(nint entity, string componentName)
{
var lookup = ReadComponentLookup(entity);
return lookup?.ContainsKey(componentName) == true;
}
/// <summary>
/// Gets the component pointer by name from an entity's component list.
/// </summary>
public nint GetComponentAddress(nint entity, string componentName)
{
var lookup = ReadComponentLookup(entity);
if (lookup is null || !lookup.TryGetValue(componentName, out var index))
return 0;
var (compFirst, count) = FindComponentList(entity);
if (index < 0 || index >= count) return 0;
return _ctx.Memory.ReadPointer(compFirst + index * 8);
}
}

View file

@ -0,0 +1,200 @@
using Serilog;
namespace Automata.Memory;
/// <summary>
/// Reads entity list from AreaInstance's std::map red-black tree.
/// </summary>
public sealed class EntityReader
{
private readonly MemoryContext _ctx;
private readonly ComponentReader _components;
private readonly MsvcStringReader _strings;
public EntityReader(MemoryContext ctx, ComponentReader components, MsvcStringReader strings)
{
_ctx = ctx;
_components = components;
_strings = strings;
}
/// <summary>
/// Reads entity list into the snapshot for continuous display.
/// </summary>
public void ReadEntities(GameStateSnapshot snap, nint areaInstance)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var registry = _ctx.Registry;
var sentinel = mem.ReadPointer(areaInstance + offsets.EntityListOffset);
if (sentinel == 0) return;
var root = mem.ReadPointer(sentinel + offsets.EntityNodeParentOffset);
var entities = new List<Entity>();
var maxNodes = Math.Min(snap.EntityCount + 10, 500);
var hasComponentLookup = offsets.ComponentLookupEntrySize > 0;
var dirty = false;
WalkTreeInOrder(sentinel, root, maxNodes, node =>
{
var entityPtr = mem.ReadPointer(node + offsets.EntityNodeValueOffset);
if (entityPtr == 0) return;
var high = (ulong)entityPtr >> 32;
if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
var entityId = mem.Read<uint>(entityPtr + offsets.EntityIdOffset);
var path = TryReadEntityPath(entityPtr);
var entity = new Entity(entityPtr, entityId, path);
if (registry["entities"].Register(entity.Metadata))
dirty = true;
if (TryReadEntityPosition(entityPtr, out var x, out var y, out var z))
{
entity.HasPosition = true;
entity.X = x;
entity.Y = y;
entity.Z = z;
}
// Read component names for non-trivial entities
if (hasComponentLookup &&
entity.Type != EntityType.Effect &&
entity.Type != EntityType.Terrain &&
entity.Type != EntityType.Critter)
{
var lookup = _components.ReadComponentLookup(entityPtr);
if (lookup is not null)
{
entity.Components = new HashSet<string>(lookup.Keys);
entity.ReclassifyFromComponents();
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)
{
var lifeComp = mem.ReadPointer(compFirst + lifeIdx * 8);
if (lifeComp != 0)
{
var hp = mem.Read<int>(lifeComp + offsets.LifeHealthOffset + offsets.VitalCurrentOffset);
var hpMax = mem.Read<int>(lifeComp + offsets.LifeHealthOffset + offsets.VitalTotalOffset);
if (hpMax > 0 && hpMax < 200000 && hp >= 0 && hp <= hpMax + 1000)
{
entity.HasVitals = true;
entity.LifeCurrent = hp;
entity.LifeTotal = hpMax;
}
}
}
}
}
}
entities.Add(entity);
});
if (dirty)
registry.Flush();
snap.Entities = entities;
}
/// <summary>
/// Iterative in-order traversal of an MSVC std::map red-black tree.
/// </summary>
public void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action<nint> visitor)
{
if (root == 0 || root == sentinel) return;
var offsets = _ctx.Offsets;
var mem = _ctx.Memory;
var stack = new Stack<nint>();
var current = root;
var count = 0;
var visited = new HashSet<nint> { sentinel };
while ((current != sentinel && current != 0) || stack.Count > 0)
{
while (current != sentinel && current != 0)
{
if (!visited.Add(current))
{
current = sentinel;
break;
}
stack.Push(current);
current = mem.ReadPointer(current + offsets.EntityNodeLeftOffset);
}
if (stack.Count == 0) break;
current = stack.Pop();
visitor(current);
count++;
if (count >= maxNodes) break;
current = mem.ReadPointer(current + offsets.EntityNodeRightOffset);
}
}
/// <summary>
/// Reads entity path string via EntityDetailsPtr → std::wstring.
/// </summary>
public string? TryReadEntityPath(nint entity)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var detailsPtr = mem.ReadPointer(entity + offsets.EntityDetailsOffset);
if (detailsPtr == 0) return null;
var high = (ulong)detailsPtr >> 32;
if (high == 0 || high >= 0x7FFF) return null;
return _strings.ReadMsvcWString(detailsPtr + offsets.EntityPathStringOffset);
}
/// <summary>
/// Tries to read position from an entity by scanning its component list for the Render component.
/// </summary>
public bool TryReadEntityPosition(nint entity, out float x, out float y, out float z)
{
x = y = z = 0;
var offsets = _ctx.Offsets;
var (compFirst, count) = _components.FindComponentList(entity);
if (count <= 0) return false;
// If we know the Render component index, try it directly
if (offsets.RenderComponentIndex >= 0 && offsets.RenderComponentIndex < count)
{
var renderComp = _ctx.Memory.ReadPointer(compFirst + offsets.RenderComponentIndex * 8);
if (renderComp != 0 && _components.TryReadPositionRaw(renderComp, out x, out y, out z))
return true;
}
// Scan components (limit to avoid performance issues with many entities)
var scanLimit = Math.Min(count, 20);
for (var i = 0; i < scanLimit; i++)
{
var compPtr = _ctx.Memory.ReadPointer(compFirst + i * 8);
if (compPtr == 0) continue;
var high = (ulong)compPtr >> 32;
if (high == 0 || high >= 0x7FFF) continue;
if ((compPtr & 0x3) != 0) continue;
if (_components.TryReadPositionRaw(compPtr, out x, out y, out z))
return true;
}
return false;
}
}

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ using Serilog;
namespace Automata.Memory; namespace Automata.Memory;
public sealed class TerrainOffsets public sealed class GameOffsets
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@ -24,8 +24,6 @@ public sealed class TerrainOffsets
public int PatternResultAdjust { get; set; } = 0x18; public int PatternResultAdjust { get; set; } = 0x18;
// ── GameState → States ── // ── GameState → States ──
// Dump: GameStateOffset { [0x08] StdVector CurrentStatePtr, [0x48] GameStateBuffer States }
// GameStateBuffer = StdTuple2D<IntPtr> (begin/end pair). Each entry is an IntPtr (8 bytes).
/// <summary>Offset to States begin/end pair in GameState (dump: 0x48).</summary> /// <summary>Offset to States begin/end pair in GameState (dump: 0x48).</summary>
public int StatesBeginOffset { get; set; } = 0x48; public int StatesBeginOffset { get; set; } = 0x48;
/// <summary>Bytes per state entry (16 for inline slots, 8 for vector of pointers).</summary> /// <summary>Bytes per state entry (16 for inline slots, 8 for vector of pointers).</summary>
@ -38,7 +36,7 @@ public sealed class TerrainOffsets
public int InGameStateIndex { get; set; } = 4; public int InGameStateIndex { get; set; } = 4;
/// <summary>Offset from controller to active states vector begin/end pair (ExileCore: 0x20).</summary> /// <summary>Offset from controller to active states vector begin/end pair (ExileCore: 0x20).</summary>
public int ActiveStatesOffset { get; set; } = 0x20; public int ActiveStatesOffset { get; set; } = 0x20;
/// <summary>Offset from controller to the active state pointer. When it != InGameState, we're loading. 0 = disabled, use ScanAreaLoadingState to find.</summary> /// <summary>Offset from controller to the active state pointer. When it != InGameState, we're loading. 0 = disabled.</summary>
public int IsLoadingOffset { get; set; } = 0; public int IsLoadingOffset { get; set; } = 0;
/// <summary>If true, states are inline in the controller struct. If false, StatesBeginOffset points to a begin/end vector pair.</summary> /// <summary>If true, states are inline in the controller struct. If false, StatesBeginOffset points to a begin/end vector pair.</summary>
public bool StatesInline { get; set; } = true; public bool StatesInline { get; set; } = true;
@ -46,7 +44,6 @@ public sealed class TerrainOffsets
public int InGameStateDirectOffset { get; set; } = 0x210; public int InGameStateDirectOffset { get; set; } = 0x210;
// ── InGameState → sub-structures ── // ── InGameState → sub-structures ──
// Dump: InGameStateOffset { [0x208] EscapeState flags, [0x298] AreaInstanceData, [0x2F8] WorldData, [0x648] UiRootPtr, [0xC40] IngameUi }
/// <summary>InGameState → EscapeState int32 flag (0=closed, 1=open). Diff-scan confirmed: 0x20C. 0 = disabled.</summary> /// <summary>InGameState → EscapeState int32 flag (0=closed, 1=open). Diff-scan confirmed: 0x20C. 0 = disabled.</summary>
public int EscapeStateOffset { get; set; } = 0x20C; public int EscapeStateOffset { get; set; } = 0x20C;
/// <summary>InGameState → AreaInstance (IngameData) pointer (dump: 0x298, CE confirmed: 0x290).</summary> /// <summary>InGameState → AreaInstance (IngameData) pointer (dump: 0x298, CE confirmed: 0x290).</summary>
@ -55,14 +52,6 @@ public sealed class TerrainOffsets
public int WorldDataFromStateOffset { get; set; } = 0x2F8; public int WorldDataFromStateOffset { get; set; } = 0x2F8;
// ── AreaInstance (IngameData) → sub-structures ── // ── AreaInstance (IngameData) → sub-structures ──
// Dump: AreaInstanceOffsets {
// [0xAC] byte CurrentAreaLevel,
// [0xEC] uint CurrentAreaHash,
// [0x948] StdVector Environments,
// [0x9F0] LocalPlayerStruct PlayerInfo, ← contains ServerDataPtr at +0x0, LocalPlayerPtr at +0x20
// [0xAF8] EntityListStruct Entities, ← StdMap AwakeEntities + StdMap SleepingEntities
// [0xCC0] TerrainStruct TerrainMetadata ← inline, not behind pointer
// }
/// <summary>AreaInstance → CurrentAreaLevel (dump: byte at 0xAC, CE confirmed: byte at 0xC4).</summary> /// <summary>AreaInstance → CurrentAreaLevel (dump: byte at 0xAC, CE confirmed: byte at 0xC4).</summary>
public int AreaLevelOffset { get; set; } = 0xC4; public int AreaLevelOffset { get; set; } = 0xC4;
/// <summary>If true, AreaLevel is a byte. If false, read as int.</summary> /// <summary>If true, AreaLevel is a byte. If false, read as int.</summary>
@ -75,14 +64,12 @@ public sealed class TerrainOffsets
public int ServerDataOffset { get; set; } = 0x9F0; public int ServerDataOffset { get; set; } = 0x9F0;
/// <summary>AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr).</summary> /// <summary>AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr).</summary>
public int LocalPlayerDirectOffset { get; set; } = 0xA10; public int LocalPlayerDirectOffset { get; set; } = 0xA10;
/// <summary>AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50). Contains StdMap AwakeEntities then SleepingEntities.</summary> /// <summary>AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50).</summary>
public int EntityListOffset { get; set; } = 0xB50; public int EntityListOffset { get; set; } = 0xB50;
/// <summary>Offset within StdMap to _Mysize (entity count). MSVC std::map: head(8) + size(8).</summary> /// <summary>Offset within StdMap to _Mysize (entity count). MSVC std::map: head(8) + size(8).</summary>
public int EntityCountInternalOffset { get; set; } = 0x08; public int EntityCountInternalOffset { get; set; } = 0x08;
// ── Entity list node layout (MSVC std::map red-black tree) ── // ── Entity list node layout (MSVC std::map red-black tree) ──
// Node: _Left(+0x00), _Parent(+0x08), _Right(+0x10), _Color(+0x18), _Myval(+0x20)
// _Myval = pair<uint32 key, Entity* value>
/// <summary>Tree node → left child pointer.</summary> /// <summary>Tree node → left child pointer.</summary>
public int EntityNodeLeftOffset { get; set; } = 0x00; public int EntityNodeLeftOffset { get; set; } = 0x00;
/// <summary>Tree node → parent pointer.</summary> /// <summary>Tree node → parent pointer.</summary>
@ -97,7 +84,7 @@ public sealed class TerrainOffsets
public int EntityFlagsOffset { get; set; } = 0x84; public int EntityFlagsOffset { get; set; } = 0x84;
/// <summary>Entity → EntityDetailsPtr (Head/MainObject pointer, +0x08).</summary> /// <summary>Entity → EntityDetailsPtr (Head/MainObject pointer, +0x08).</summary>
public int EntityDetailsOffset { get; set; } = 0x08; public int EntityDetailsOffset { get; set; } = 0x08;
/// <summary>EntityDetails → std::string path (MSVC layout: ptr/SSO at +0, size at +0x10, capacity at +0x18). Offset within EntityDetails struct.</summary> /// <summary>EntityDetails → std::string path (MSVC layout). Offset within EntityDetails struct.</summary>
public int EntityPathStringOffset { get; set; } = 0x08; public int EntityPathStringOffset { get; set; } = 0x08;
// ServerData → fields // ServerData → fields
@ -105,14 +92,12 @@ public sealed class TerrainOffsets
public int LocalPlayerOffset { get; set; } = 0x20; public int LocalPlayerOffset { get; set; } = 0x20;
// ── Entity / Component ── // ── Entity / Component ──
// Dump: ItemStruct { [0x0] VTablePtr, [0x8] EntityDetailsPtr, [0x10] StdVector ComponentListPtr }
// Dump: EntityOffsets { [0x0] ItemStruct, [0x80] uint Id, [0x84] byte IsValid }
public int ComponentListOffset { get; set; } = 0x10; public int ComponentListOffset { get; set; } = 0x10;
/// <summary>Entity → ObjectHeader pointer (for alternative component lookup via name→index map). ExileCore: 0x08.</summary> /// <summary>Entity → ObjectHeader pointer (for alternative component lookup via name→index map). ExileCore: 0x08.</summary>
public int EntityHeaderOffset { get; set; } = 0x08; public int EntityHeaderOffset { get; set; } = 0x08;
/// <summary>EntityDetails → ComponentLookup object pointer. Confirmed: 0x28 (right after wstring path).</summary> /// <summary>EntityDetails → ComponentLookup object pointer. Confirmed: 0x28.</summary>
public int ComponentLookupOffset { get; set; } = 0x28; public int ComponentLookupOffset { get; set; } = 0x28;
/// <summary>ComponentLookup object → Vec2 begin/end (name entry array). Object layout: +0x10=Vec1(ptrs), +0x28=Vec2(names).</summary> /// <summary>ComponentLookup object → Vec2 begin/end (name entry array).</summary>
public int ComponentLookupVec2Offset { get; set; } = 0x28; public int ComponentLookupVec2Offset { get; set; } = 0x28;
/// <summary>Size of each entry in Vec2 (bytes). Confirmed: 16 = { char* name (8), int32 index (4), int32 flags (4) }.</summary> /// <summary>Size of each entry in Vec2 (bytes). Confirmed: 16 = { char* name (8), int32 index (4), int32 flags (4) }.</summary>
public int ComponentLookupEntrySize { get; set; } = 16; public int ComponentLookupEntrySize { get; set; } = 16;
@ -125,17 +110,11 @@ public sealed class TerrainOffsets
/// <summary>Index of Render/Position component in entity's component list. -1 = unknown.</summary> /// <summary>Index of Render/Position component in entity's component list. -1 = unknown.</summary>
public int RenderComponentIndex { get; set; } = -1; public int RenderComponentIndex { get; set; } = -1;
// ── Life component (via direct chain from AreaInstance) ── // ── Life component ──
// Deep scan confirmed: AreaInstance+0x420 → ptr+0x98 → Life component
// VitalStruct gaps match dump: HP→Mana = 0x50, Mana→ES = 0x38
// HP.Current@+0x188, Mana.Current@+0x1D8, ES.Current@+0x210
/// <summary>First offset from AreaInstance to reach Life component (AreaInstance → ptr). 0 = use entity component list instead.</summary> /// <summary>First offset from AreaInstance to reach Life component (AreaInstance → ptr). 0 = use entity component list instead.</summary>
public int LifeComponentOffset1 { get; set; } = 0x420; public int LifeComponentOffset1 { get; set; } = 0x420;
/// <summary>Second offset from intermediate pointer to Life component (ptr → Life).</summary> /// <summary>Second offset from intermediate pointer to Life component (ptr → Life).</summary>
public int LifeComponentOffset2 { get; set; } = 0x98; public int LifeComponentOffset2 { get; set; } = 0x98;
// VitalStruct offsets within Life component (VitalStruct base, add VitalCurrentOffset/VitalTotalOffset)
// ECS inner entity path: HP@+0x1D8 = 0x1A8+0x30, Mana@+0x228 = 0x1F8+0x30, ES@+0x260 = 0x230+0x30
// (shifted +0x50 from old direct chain due to inner entity component header)
public int LifeHealthOffset { get; set; } = 0x1A8; public int LifeHealthOffset { get; set; } = 0x1A8;
public int LifeManaOffset { get; set; } = 0x1F8; public int LifeManaOffset { get; set; } = 0x1F8;
public int LifeEsOffset { get; set; } = 0x230; public int LifeEsOffset { get; set; } = 0x230;
@ -143,22 +122,17 @@ public sealed class TerrainOffsets
public int VitalTotalOffset { get; set; } = 0x2C; public int VitalTotalOffset { get; set; } = 0x2C;
// ── Render/Position component ── // ── Render/Position component ──
// Scan confirmed: position float triplet at +0x138 in component [10] (with real Z height)
// Dump reference (older): RenderOffsets { [0xB0] CurrentWorldPosition } — shifted to 0x138 in current build
public int PositionXOffset { get; set; } = 0x138; public int PositionXOffset { get; set; } = 0x138;
public int PositionYOffset { get; set; } = 0x13C; public int PositionYOffset { get; set; } = 0x13C;
public int PositionZOffset { get; set; } = 0x140; public int PositionZOffset { get; set; } = 0x140;
// ── Camera (for WorldToScreen projection) ──
/// <summary>Offset from InGameState to Camera pointer. 0 = disabled (use ScanCamera to discover).</summary>
public int CameraOffset { get; set; } = 0x308;
/// <summary>Offset within Camera struct to the Matrix4x4 (64 bytes). 0 = disabled.</summary>
public int CameraMatrixOffset { get; set; } = 0x1A0;
// ── Terrain (inline in AreaInstance) ── // ── Terrain (inline in AreaInstance) ──
// Scan-confirmed TerrainStruct (at AreaInstance + 0xCC0):
// [0x90] StdTuple2D<long> TotalTiles (cols, rows as int64)
// [0xA0] StdVector TileDetailsPtr (56 bytes/tile)
// [0x100] int32 cols, int32 rows (redundant compact dims)
// [0x148] StdVector GridWalkableData (size = bytesPerRow * gridHeight)
// [0x160] StdVector GridLandscapeData
// [0x178] StdVector Grid3
// [0x190] StdVector Grid4
// [0x1A8] int BytesPerRow = ceil(cols * 23 / 2)
/// <summary>Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).</summary> /// <summary>Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).</summary>
public int TerrainListOffset { get; set; } = 0xCC0; public int TerrainListOffset { get; set; } = 0xCC0;
/// <summary>If true, terrain is inline in AreaInstance (no pointer dereference). If false, follow pointer.</summary> /// <summary>If true, terrain is inline in AreaInstance (no pointer dereference). If false, follow pointer.</summary>
@ -173,21 +147,12 @@ public sealed class TerrainOffsets
public int TerrainGridPtrOffset { get; set; } = 0x08; public int TerrainGridPtrOffset { get; set; } = 0x08;
public int SubTilesPerCell { get; set; } = 23; public int SubTilesPerCell { get; set; } = 23;
// Legacy terrain offsets (used by TerrainReader) public static GameOffsets Load(string path)
public int InGameStateOffset { get; set; }
public int IngameDataOffset { get; set; }
public int TerrainDataOffset { get; set; }
public int NumColsOffset { get; set; }
public int NumRowsOffset { get; set; }
public int LayerMeleeOffset { get; set; }
public int BytesPerRowOffset { get; set; }
public static TerrainOffsets Load(string path)
{ {
if (!File.Exists(path)) if (!File.Exists(path))
{ {
Log.Information("Offsets file not found at '{Path}', using defaults", path); Log.Information("Offsets file not found at '{Path}', using defaults", path);
var defaults = new TerrainOffsets(); var defaults = new GameOffsets();
defaults.Save(path); defaults.Save(path);
return defaults; return defaults;
} }
@ -195,11 +160,11 @@ public sealed class TerrainOffsets
try try
{ {
var json = File.ReadAllText(path); var json = File.ReadAllText(path);
var offsets = JsonSerializer.Deserialize<TerrainOffsets>(json, JsonOptions); var offsets = JsonSerializer.Deserialize<GameOffsets>(json, JsonOptions);
if (offsets is null) if (offsets is null)
{ {
Log.Warning("Failed to deserialize '{Path}', using defaults", path); Log.Warning("Failed to deserialize '{Path}', using defaults", path);
return new TerrainOffsets(); return new GameOffsets();
} }
Log.Information("Loaded offsets from '{Path}'", path); Log.Information("Loaded offsets from '{Path}'", path);
return offsets; return offsets;
@ -207,7 +172,7 @@ public sealed class TerrainOffsets
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, "Error loading offsets from '{Path}'", path); Log.Error(ex, "Error loading offsets from '{Path}'", path);
return new TerrainOffsets(); return new GameOffsets();
} }
} }

View file

@ -0,0 +1,203 @@
using Serilog;
namespace Automata.Memory;
/// <summary>
/// Resolves GameState → Controller → InGameState, reads state slots, loading/escape state.
/// </summary>
public sealed class GameStateReader
{
private readonly MemoryContext _ctx;
public GameStateReader(MemoryContext ctx)
{
_ctx = ctx;
}
/// <summary>
/// Resolves InGameState pointer from the GameState controller.
/// </summary>
public nint ResolveInGameState(GameStateSnapshot snap)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var controller = mem.ReadPointer(_ctx.GameStateBase);
if (controller == 0) return 0;
snap.ControllerPtr = controller;
// Direct offset mode: read InGameState straight from controller
if (offsets.InGameStateDirectOffset > 0)
{
var igs = mem.ReadPointer(controller + offsets.InGameStateDirectOffset);
if (igs != 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;
snap.StatesCount++;
}
return igs;
}
}
if (offsets.StatesInline)
{
var inlineOffset = offsets.StatesBeginOffset
+ offsets.InGameStateIndex * offsets.StateStride
+ offsets.StatePointerOffset;
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;
snap.StatesCount++;
}
return mem.ReadPointer(controller + inlineOffset);
}
else
{
var statesBegin = mem.ReadPointer(controller + offsets.StatesBeginOffset);
if (statesBegin == 0) return 0;
var statesEnd = mem.ReadPointer(controller + offsets.StatesBeginOffset + 8);
if (statesEnd > statesBegin && statesEnd - statesBegin < 0x1000 && offsets.StateStride > 0)
{
snap.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;
snap.StatesCount++;
}
}
if (offsets.InGameStateIndex < 0 || offsets.InGameStateIndex >= snap.StatesCount)
return 0;
return mem.ReadPointer(statesBegin + offsets.InGameStateIndex * offsets.StateStride + offsets.StatePointerOffset);
}
}
/// <summary>
/// Reads all state slot pointers and active states vector from the controller.
/// </summary>
public void ReadStateSlots(GameStateSnapshot snap)
{
var controller = snap.ControllerPtr;
if (controller == 0) return;
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var count = offsets.StateCount;
var slots = new nint[count];
for (var i = 0; i < count; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
slots[i] = mem.ReadPointer(controller + slotOffset);
}
snap.StateSlots = slots;
var values = new int[count];
for (var i = 0; i < count; i++)
{
if (slots[i] != 0)
values[i] = mem.Read<int>(slots[i] + 0x08);
}
snap.StateSlotValues = values;
// Read active states vector
if (offsets.ActiveStatesOffset > 0)
{
var beginPtr = mem.ReadPointer(controller + offsets.ActiveStatesOffset);
var endPtr = mem.ReadPointer(controller + offsets.ActiveStatesOffset + 16);
snap.ActiveStatesBegin = beginPtr;
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)
{
var ptr = (nint)BitConverter.ToInt64(data, i);
rawList.Add(ptr);
if (ptr != 0)
snap.ActiveStates.Add(ptr);
}
snap.ActiveStatesRaw = rawList.ToArray();
}
}
}
}
// Read all non-null pointer-like qwords from controller (outside state array)
var stateArrayStart = offsets.StatesBeginOffset;
var stateArrayEnd = stateArrayStart + count * offsets.StateStride;
var watches = new List<(int, nint)>();
var ctrlData = mem.ReadBytes(controller, 0x350);
if (ctrlData is not null)
{
for (var offset = 0; offset + 8 <= ctrlData.Length; offset += 8)
{
if (offset >= stateArrayStart && offset < stateArrayEnd) continue;
var value = (nint)BitConverter.ToInt64(ctrlData, offset);
if (value == 0) continue;
var high = (ulong)value >> 32;
if (high > 0 && high < 0x7FFF && (value & 0x3) == 0)
watches.Add((offset, value));
}
}
snap.WatchOffsets = watches.ToArray();
}
/// <summary>
/// Detects loading by comparing the active state pointer to InGameStatePtr.
/// </summary>
public void ReadIsLoading(GameStateSnapshot snap)
{
var controller = snap.ControllerPtr;
if (controller == 0 || _ctx.Offsets.IsLoadingOffset <= 0)
return;
var value = _ctx.Memory.ReadPointer(controller + _ctx.Offsets.IsLoadingOffset);
if (value == snap.InGameStatePtr && snap.InGameStatePtr != 0)
snap.IsLoading = false;
else if (value == 0)
snap.IsLoading = false;
else
snap.IsLoading = true;
}
/// <summary>
/// Reads escape menu state from active states vector or InGameState flag.
/// </summary>
public void ReadEscapeState(GameStateSnapshot snap)
{
if (snap.ActiveStates.Count > 0 && snap.StateSlots.Length > 3 && snap.StateSlots[3] != 0)
{
snap.IsEscapeOpen = snap.ActiveStates.Contains(snap.StateSlots[3]);
return;
}
if (snap.InGameStatePtr == 0 || _ctx.Offsets.EscapeStateOffset <= 0)
return;
var value = _ctx.Memory.Read<int>(snap.InGameStatePtr + _ctx.Offsets.EscapeStateOffset);
snap.IsEscapeOpen = value != 0;
}
}

View file

@ -0,0 +1,68 @@
using System.Numerics;
namespace Automata.Memory;
public class GameStateSnapshot
{
// Process
public bool Attached;
public int ProcessId;
public nint ModuleBase;
public int ModuleSize;
public string? Error;
// GameState
public nint GameStateBase;
public bool OffsetsConfigured;
public int StatesCount;
// Pointers
public nint ControllerPtr;
public nint InGameStatePtr;
public nint AreaInstancePtr;
public nint ServerDataPtr;
public nint LocalPlayerPtr;
// Area
public int AreaLevel;
public uint AreaHash;
// Player position (Render component)
public bool HasPosition;
public float PlayerX, PlayerY, PlayerZ;
// Player vitals (Life component)
public bool HasVitals;
public int LifeCurrent, LifeTotal;
public int ManaCurrent, ManaTotal;
public int EsCurrent, EsTotal;
// Entities
public int EntityCount;
public List<Entity>? Entities;
// Loading state
public bool IsLoading;
public bool IsEscapeOpen;
// Live state flags — individual bytes from InGameState+0x200 region
public byte[]? StateFlagBytes; // raw bytes from InGameState+0x200, length 0x30
public int StateFlagBaseOffset = 0x200;
// Active game states (ExileCore state machine)
public nint[] StateSlots = Array.Empty<nint>(); // State[0]..State[N] pointer values (all 12 slots)
public int[]? StateSlotValues; // int32 at state+0x08 for each slot
public HashSet<nint> ActiveStates = new(); // which state pointers are in the active list
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
// Camera
public Matrix4x4? CameraMatrix;
// Terrain
public int TerrainWidth, TerrainHeight;
public int TerrainCols, TerrainRows;
public WalkabilityGrid? Terrain;
public int TerrainWalkablePercent;
}

View file

@ -0,0 +1,35 @@
namespace Automata.Memory;
/// <summary>
/// Shared state for all memory reader classes. Holds the process handle, offsets, registry,
/// and resolved module/GameState base addresses.
/// </summary>
public sealed class MemoryContext
{
public ProcessMemory Memory { get; }
public GameOffsets Offsets { get; }
public ObjectRegistry Registry { get; }
public nint ModuleBase { get; set; }
public int ModuleSize { get; set; }
public nint GameStateBase { get; set; }
public MemoryContext(ProcessMemory memory, GameOffsets offsets, ObjectRegistry registry)
{
Memory = memory;
Offsets = offsets;
Registry = registry;
}
public bool IsModuleAddress(nint value)
{
return ModuleBase != 0 && ModuleSize > 0 &&
value >= ModuleBase && value < ModuleBase + ModuleSize;
}
public bool IsValidHeapPtr(nint ptr)
{
if (ptr == 0) return false;
var high = (ulong)ptr >> 32;
return high > 0 && high < 0x7FFF && (ptr & 0x3) == 0;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,110 @@
using System.Text;
namespace Automata.Memory;
/// <summary>
/// Reads MSVC std::string and std::wstring from process memory.
/// Handles SSO (Small String Optimization) for both narrow and wide strings.
/// </summary>
public sealed class MsvcStringReader
{
private readonly MemoryContext _ctx;
public MsvcStringReader(MemoryContext ctx)
{
_ctx = ctx;
}
/// <summary>
/// Reads an MSVC std::wstring (UTF-16) from the given address.
/// Layout: _Bx (16 bytes: SSO buffer or heap ptr), _Mysize (8), _Myres (8).
/// wchar_t is 2 bytes on Windows. SSO threshold: capacity &lt;= 7.
/// </summary>
public string? ReadMsvcWString(nint stringAddr)
{
var mem = _ctx.Memory;
var size = mem.Read<long>(stringAddr + 0x10);
var capacity = mem.Read<long>(stringAddr + 0x18);
if (size <= 0 || size > 512 || capacity < size) return null;
nint dataAddr;
if (capacity <= 7)
dataAddr = stringAddr; // SSO: inline in _Bx buffer
else
{
dataAddr = mem.ReadPointer(stringAddr);
if (dataAddr == 0) return null;
}
var bytes = mem.ReadBytes(dataAddr, (int)size * 2);
if (bytes is null) return null;
var str = Encoding.Unicode.GetString(bytes);
if (str.Length > 0 && str[0] >= 0x20 && str[0] <= 0x7E)
return str;
return null;
}
/// <summary>
/// Reads an MSVC std::string (narrow, UTF-8/ASCII) from the given address.
/// Layout: _Bx (16 bytes: SSO buffer or heap ptr), _Mysize (8), _Myres (8).
/// SSO threshold: capacity &lt;= 15.
/// </summary>
public string? ReadMsvcString(nint stringAddr)
{
var mem = _ctx.Memory;
var size = mem.Read<long>(stringAddr + 0x10);
var capacity = mem.Read<long>(stringAddr + 0x18);
if (size <= 0 || size > 512 || capacity < size) return null;
nint dataAddr;
if (capacity <= 15)
dataAddr = stringAddr; // SSO: inline in _Bx buffer
else
{
dataAddr = mem.ReadPointer(stringAddr);
if (dataAddr == 0) return null;
}
var bytes = mem.ReadBytes(dataAddr, (int)size);
if (bytes is null) return null;
var str = Encoding.UTF8.GetString(bytes);
if (str.Length > 0 && str[0] >= 0x20 && str[0] <= 0x7E)
return str;
return null;
}
/// <summary>
/// Reads a null-terminated char* string from a module-range or heap address.
/// Component names are char* literals in .rdata, e.g. "Life", "Render", "Monster".
/// </summary>
public string? ReadCharPtr(nint ptr)
{
if (ptr == 0) return null;
var data = _ctx.Memory.ReadBytes(ptr, 64);
if (data is null) return null;
var end = Array.IndexOf(data, (byte)0);
if (end < 1 || end > 50) return null;
var str = Encoding.ASCII.GetString(data, 0, end);
if (str.Length > 0 && str.All(c => c >= 0x20 && c <= 0x7E))
return str;
return null;
}
/// <summary>
/// Reads a null-terminated UTF-8 string (up to 256 bytes).
/// </summary>
public string ReadNullTermString(nint addr)
{
var data = _ctx.Memory.ReadBytes(addr, 256);
if (data is null) return "Error: read failed";
var end = Array.IndexOf(data, (byte)0);
if (end < 0) end = data.Length;
return Encoding.UTF8.GetString(data, 0, end);
}
}

View file

@ -0,0 +1,81 @@
using System.Text;
namespace Automata.Memory;
/// <summary>
/// Resolves MSVC x64 RTTI type names from vtable addresses and classifies pointers.
/// </summary>
public sealed class RttiResolver
{
private readonly MemoryContext _ctx;
public RttiResolver(MemoryContext ctx)
{
_ctx = ctx;
}
/// <summary>
/// Resolves a vtable address to its RTTI class name using MSVC x64 RTTI layout.
/// vtable[-1] → RTTICompleteObjectLocator → TypeDescriptor → mangled name
/// </summary>
public string? ResolveRttiName(nint vtableAddr)
{
var mem = _ctx.Memory;
if (mem is null || _ctx.ModuleBase == 0) return null;
try
{
var colPtr = mem.ReadPointer(vtableAddr - 8);
if (colPtr == 0) return null;
var signature = mem.Read<int>(colPtr);
if (signature != 1) return null;
var typeDescOffset = mem.Read<int>(colPtr + 0x0C);
if (typeDescOffset <= 0) return null;
var typeDesc = _ctx.ModuleBase + typeDescOffset;
var nameBytes = mem.ReadBytes(typeDesc + 0x10, 128);
if (nameBytes is null) return null;
var end = Array.IndexOf(nameBytes, (byte)0);
if (end <= 0) return null;
var mangled = Encoding.ASCII.GetString(nameBytes, 0, end);
if (mangled.StartsWith(".?AV") && mangled.EndsWith("@@"))
return mangled[4..^2];
if (mangled.StartsWith(".?AU") && mangled.EndsWith("@@"))
return mangled[4..^2];
return mangled;
}
catch
{
return null;
}
}
/// <summary>
/// Classifies a pointer value: returns a tag string ("module (vtable?)", "heap ptr", RTTI name) or null.
/// </summary>
public string? ClassifyPointer(nint value)
{
if (value == 0) return null;
if (_ctx.IsModuleAddress(value))
{
var name = ResolveRttiName(value);
return name ?? "module (vtable?)";
}
if (value > 0x10000 && value < (nint)0x7FFFFFFFFFFF && (value & 0x3) == 0)
{
var high = (ulong)value >> 32;
if (high > 0 && high < 0x7FFF)
return "heap ptr";
}
return null;
}
}

View file

@ -2,117 +2,126 @@ using Serilog;
namespace Automata.Memory; namespace Automata.Memory;
public sealed class WalkabilityGrid /// <summary>
/// Reads terrain walkability grid from AreaInstance, with caching and loading edge detection.
/// </summary>
public sealed class TerrainReader
{ {
public int Width { get; } private readonly MemoryContext _ctx;
public int Height { get; } private uint _cachedTerrainAreaHash;
public byte[] Data { get; } private WalkabilityGrid? _cachedTerrain;
private bool _wasLoading;
public WalkabilityGrid(int width, int height, byte[] data) public TerrainReader(MemoryContext ctx)
{ {
Width = width; _ctx = ctx;
Height = height;
Data = data;
} }
public bool IsWalkable(int x, int y) /// <summary>
/// Invalidates the terrain cache (called when LocalPlayer changes on zone change).
/// </summary>
public void InvalidateCache()
{ {
if (x < 0 || x >= Width || y < 0 || y >= Height) _cachedTerrain = null;
return false; _cachedTerrainAreaHash = 0;
return Data[y * Width + x] == 0;
}
}
public sealed class TerrainReader : IDisposable
{
private readonly TerrainOffsets _offsets;
private ProcessMemory? _memory;
private PatternScanner? _scanner;
private nint _gameStateBase;
private bool _disposed;
public bool IsReady => _gameStateBase != 0;
public TerrainReader(TerrainOffsets offsets)
{
_offsets = offsets;
} }
public bool Initialize() /// <summary>
/// Reads terrain data from AreaInstance into the snapshot.
/// Handles both inline and pointer-based terrain layouts.
/// </summary>
public void ReadTerrain(GameStateSnapshot snap, nint areaInstance)
{ {
_memory?.Dispose(); var mem = _ctx.Memory;
_memory = ProcessMemory.Attach(_offsets.ProcessName); var offsets = _ctx.Offsets;
if (_memory is null)
return false;
if (string.IsNullOrWhiteSpace(_offsets.GameStatePattern)) if (!offsets.TerrainInline)
{ {
Log.Warning("GameStatePattern is empty — offsets not yet configured for POE2"); // Pointer-based: AreaInstance → TerrainList → first terrain → dimensions
return false; var terrainListPtr = mem.ReadPointer(areaInstance + offsets.TerrainListOffset);
if (terrainListPtr == 0) return;
var terrainPtr = mem.ReadPointer(terrainListPtr);
if (terrainPtr == 0) return;
var dimsPtr = mem.ReadPointer(terrainPtr + offsets.TerrainDimensionsOffset);
if (dimsPtr == 0) return;
snap.TerrainCols = mem.Read<int>(dimsPtr);
snap.TerrainRows = mem.Read<int>(dimsPtr + 4);
if (snap.TerrainCols > 0 && snap.TerrainCols < 1000 &&
snap.TerrainRows > 0 && snap.TerrainRows < 1000)
{
snap.TerrainWidth = snap.TerrainCols * offsets.SubTilesPerCell;
snap.TerrainHeight = snap.TerrainRows * offsets.SubTilesPerCell;
}
else
{
snap.TerrainCols = 0;
snap.TerrainRows = 0;
}
return;
} }
_scanner = new PatternScanner(_memory); // Inline mode: TerrainStruct is inline at AreaInstance + TerrainListOffset
_gameStateBase = _scanner.FindPatternRip(_offsets.GameStatePattern); var terrainBase = areaInstance + offsets.TerrainListOffset;
var cols = (int)mem.Read<long>(terrainBase + offsets.TerrainDimensionsOffset);
var rows = (int)mem.Read<long>(terrainBase + offsets.TerrainDimensionsOffset + 8);
if (_gameStateBase == 0) if (cols <= 0 || cols >= 1000 || rows <= 0 || rows >= 1000)
return;
snap.TerrainCols = cols;
snap.TerrainRows = rows;
snap.TerrainWidth = cols * offsets.SubTilesPerCell;
snap.TerrainHeight = rows * offsets.SubTilesPerCell;
// While loading, clear cached terrain and don't read (data is stale/invalid)
if (snap.IsLoading)
{ {
Log.Error("Failed to resolve GameState base pointer"); _cachedTerrain = null;
return false; _cachedTerrainAreaHash = 0;
return;
} }
Log.Information("GameState base: 0x{Address:X}", _gameStateBase); // Loading just finished — clear cache to force a fresh read
return true; if (_wasLoading)
}
public WalkabilityGrid? ReadTerrain()
{
if (_memory is null || _gameStateBase == 0)
return null;
// Follow pointer chain: GameState → InGameState → IngameData → TerrainData
var terrainBase = _memory.FollowChain(_gameStateBase, [
_offsets.InGameStateOffset,
_offsets.IngameDataOffset,
_offsets.TerrainDataOffset
]);
if (terrainBase == 0)
{ {
Log.Debug("Terrain pointer chain returned null"); _cachedTerrain = null;
return null; _cachedTerrainAreaHash = 0;
} }
var numCols = _memory.Read<int>(terrainBase + _offsets.NumColsOffset); // Return cached grid if same area
var numRows = _memory.Read<int>(terrainBase + _offsets.NumRowsOffset); if (_cachedTerrain != null && _cachedTerrainAreaHash == snap.AreaHash)
var bytesPerRow = _memory.Read<int>(terrainBase + _offsets.BytesPerRowOffset);
if (numCols <= 0 || numRows <= 0 || bytesPerRow <= 0)
{ {
Log.Warning("Invalid terrain dimensions: {Cols}x{Rows}, bytesPerRow={Bpr}", numCols, numRows, bytesPerRow); snap.Terrain = _cachedTerrain;
return null; snap.TerrainWalkablePercent = CalcWalkablePercent(_cachedTerrain);
return;
} }
var gridWidth = numCols * _offsets.SubTilesPerCell; // Read GridWalkableData StdVector (begin/end/cap pointers)
var gridHeight = numRows * _offsets.SubTilesPerCell; var gridVecOffset = offsets.TerrainWalkableGridOffset;
var gridBegin = mem.ReadPointer(terrainBase + gridVecOffset);
var gridEnd = mem.ReadPointer(terrainBase + gridVecOffset + 8);
if (gridBegin == 0 || gridEnd <= gridBegin)
return;
// Read melee layer pointer var gridDataSize = (int)(gridEnd - gridBegin);
var layerPtr = _memory.ReadPointer(terrainBase + _offsets.LayerMeleeOffset); if (gridDataSize <= 0 || gridDataSize > 16 * 1024 * 1024)
if (layerPtr == 0) return;
{
Log.Warning("Melee layer pointer is null");
return null;
}
// Read raw terrain data var bytesPerRow = mem.Read<int>(terrainBase + offsets.TerrainBytesPerRowOffset);
var rawSize = bytesPerRow * gridHeight; if (bytesPerRow <= 0 || bytesPerRow > 0x10000)
var rawData = _memory.ReadBytes(layerPtr, rawSize); return;
var gridWidth = cols * offsets.SubTilesPerCell;
var gridHeight = rows * offsets.SubTilesPerCell;
var rawData = mem.ReadBytes(gridBegin, gridDataSize);
if (rawData is null) if (rawData is null)
{ return;
Log.Warning("Failed to read terrain data ({Size} bytes)", rawSize);
return null;
}
// Unpack 4-bit nibbles: each byte → 2 cells (low nibble = even col, high nibble = odd col) // Unpack 4-bit nibbles: each byte → 2 cells
var data = new byte[gridWidth * gridHeight]; var data = new byte[gridWidth * gridHeight];
for (var row = 0; row < gridHeight; row++) for (var row = 0; row < gridHeight; row++)
{ {
@ -128,14 +137,30 @@ public sealed class TerrainReader : IDisposable
} }
} }
Log.Information("Terrain read: {Width}x{Height} ({Cols}x{Rows} cells)", gridWidth, gridHeight, numCols, numRows); var grid = new WalkabilityGrid(gridWidth, gridHeight, data);
return new WalkabilityGrid(gridWidth, gridHeight, data); snap.Terrain = grid;
snap.TerrainWalkablePercent = CalcWalkablePercent(grid);
_cachedTerrain = grid;
_cachedTerrainAreaHash = snap.AreaHash;
Log.Information("Terrain grid read: {W}x{H} ({Cols}x{Rows} cells), {Pct}% walkable",
gridWidth, gridHeight, cols, rows, snap.TerrainWalkablePercent);
} }
public void Dispose() /// <summary>
/// Updates the loading edge detection state. Call after ReadTerrain.
/// </summary>
public void UpdateLoadingEdge(bool isLoading)
{ {
if (_disposed) return; _wasLoading = isLoading;
_disposed = true; }
_memory?.Dispose();
public static int CalcWalkablePercent(WalkabilityGrid grid)
{
var walkable = 0;
for (var i = 0; i < grid.Data.Length; i++)
if (grid.Data[i] != 0) walkable++;
return grid.Data.Length > 0 ? (int)(100L * walkable / grid.Data.Length) : 0;
} }
} }

View file

@ -0,0 +1,22 @@
namespace Automata.Memory;
public sealed class WalkabilityGrid
{
public int Width { get; }
public int Height { get; }
public byte[] Data { get; }
public WalkabilityGrid(int width, int height, byte[] data)
{
Width = width;
Height = height;
Data = data;
}
public bool IsWalkable(int x, int y)
{
if (x < 0 || x >= Width || y < 0 || y >= Height)
return false;
return Data[y * Width + x] != 0;
}
}

View file

@ -70,6 +70,7 @@ public partial class App : Application
services.AddSingleton<AtlasViewModel>(); services.AddSingleton<AtlasViewModel>();
services.AddSingleton<CraftingViewModel>(); services.AddSingleton<CraftingViewModel>();
services.AddSingleton<MemoryViewModel>(); services.AddSingleton<MemoryViewModel>();
services.AddSingleton<RobotoViewModel>();
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
@ -95,6 +96,7 @@ public partial class App : Application
mainVm.AtlasVm = provider.GetRequiredService<AtlasViewModel>(); mainVm.AtlasVm = provider.GetRequiredService<AtlasViewModel>();
mainVm.CraftingVm = provider.GetRequiredService<CraftingViewModel>(); mainVm.CraftingVm = provider.GetRequiredService<CraftingViewModel>();
mainVm.MemoryVm = provider.GetRequiredService<MemoryViewModel>(); mainVm.MemoryVm = provider.GetRequiredService<MemoryViewModel>();
mainVm.RobotoVm = provider.GetRequiredService<RobotoViewModel>();
var window = new MainWindow { DataContext = mainVm }; var window = new MainWindow { DataContext = mainVm };
window.SetConfigStore(store); window.SetConfigStore(store);
@ -108,6 +110,7 @@ public partial class App : Application
{ {
overlay.Shutdown(); overlay.Shutdown();
mainVm.Shutdown(); mainVm.Shutdown();
mainVm.RobotoVm?.Shutdown();
await bot.DisposeAsync(); await bot.DisposeAsync();
}; };
} }

View file

@ -24,6 +24,7 @@
<ProjectReference Include="..\Automata.Log\Automata.Log.csproj" /> <ProjectReference Include="..\Automata.Log\Automata.Log.csproj" />
<ProjectReference Include="..\Automata.Inventory\Automata.Inventory.csproj" /> <ProjectReference Include="..\Automata.Inventory\Automata.Inventory.csproj" />
<ProjectReference Include="..\Automata.Memory\Automata.Memory.csproj" /> <ProjectReference Include="..\Automata.Memory\Automata.Memory.csproj" />
<ProjectReference Include="..\Roboto.Engine\Roboto.Engine.csproj" />
</ItemGroup> </ItemGroup>
<!-- Sidekick data files (English only) --> <!-- Sidekick data files (English only) -->
<ItemGroup> <ItemGroup>

View file

@ -72,6 +72,7 @@ public sealed class D2dOverlay
_layers.Add(new D2dEnemyBoxLayer(ctx)); _layers.Add(new D2dEnemyBoxLayer(ctx));
_layers.Add(new D2dLootLabelLayer(ctx)); _layers.Add(new D2dLootLabelLayer(ctx));
_layers.Add(new D2dEntityLabelLayer(ctx));
_layers.Add(new D2dHudInfoLayer()); _layers.Add(new D2dHudInfoLayer());
_layers.Add(new D2dDebugTextLayer()); _layers.Add(new D2dDebugTextLayer());

View file

@ -0,0 +1,71 @@
using System.Drawing;
using System.Numerics;
using Automata.Ui.ViewModels;
using Vortice.DirectWrite;
namespace Automata.Ui.Overlay.Layers;
internal sealed class D2dEntityLabelLayer : ID2dOverlayLayer, IDisposable
{
private const int ScreenW = 2560;
private const int ScreenH = 1440;
private const float HalfW = ScreenW * 0.5f;
private const float HalfH = ScreenH * 0.5f;
private readonly D2dRenderContext _ctx;
private readonly Dictionary<string, IDWriteTextLayout> _labelCache = new();
public D2dEntityLabelLayer(D2dRenderContext ctx)
{
_ctx = ctx;
}
public void Draw(D2dRenderContext ctx, OverlayState state)
{
var data = RobotoViewModel.OverlayData;
if (data is null || data.Entries.Length == 0) return;
if (data.CameraMatrix is not { } matrix) return;
var rt = ctx.RenderTarget;
var playerZ = data.PlayerZ;
foreach (ref readonly var entry in data.Entries.AsSpan())
{
// WorldToScreen using camera's view-projection matrix (same as ExileCore)
var worldPos = new Vector4(entry.X, entry.Y, playerZ, 1f);
var clip = Vector4.Transform(worldPos, matrix);
// Perspective divide
if (clip.W is 0f or float.NaN) continue;
clip = Vector4.Divide(clip, clip.W);
// NDC → screen coordinates
var sx = (clip.X + 1f) * HalfW;
var sy = (1f - clip.Y) * HalfH;
// Skip off-screen
if (sx < -200 || sx > ScreenW + 200 || sy < -100 || sy > ScreenH + 100) continue;
if (!_labelCache.TryGetValue(entry.Label, out var layout))
{
layout = _ctx.CreateTextLayout(entry.Label, _ctx.LabelFormat);
_labelCache[entry.Label] = layout;
}
var m = layout.Metrics;
var labelX = sx - m.Width / 2f;
var labelY = sy - m.Height - 4;
rt.FillRectangle(
new RectangleF(labelX - 2, labelY - 1, m.Width + 4, m.Height + 2),
ctx.LabelBgBrush);
rt.DrawTextLayout(new Vector2(labelX, labelY), layout, ctx.Cyan);
}
}
public void Dispose()
{
foreach (var l in _labelCache.Values) l?.Dispose();
}
}

View file

@ -183,6 +183,7 @@ public partial class MainWindowViewModel : ObservableObject
public AtlasViewModel? AtlasVm { get; set; } public AtlasViewModel? AtlasVm { get; set; }
public CraftingViewModel? CraftingVm { get; set; } public CraftingViewModel? CraftingVm { get; set; }
public MemoryViewModel? MemoryVm { get; set; } public MemoryViewModel? MemoryVm { get; set; }
public RobotoViewModel? RobotoVm { get; set; }
partial void OnBotModeChanged(BotMode value) partial void OnBotModeChanged(BotMode value)
{ {

View file

@ -789,7 +789,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
RawResult = _reader.ReadAddress(RawAddress, RawOffsets, RawType); RawResult = _reader.Diagnostics!.ReadAddress(RawAddress, RawOffsets, RawType);
} }
[RelayCommand] [RelayCommand]
@ -804,7 +804,7 @@ public partial class MemoryViewModel : ObservableObject
if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size)) if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size))
size = 0x400; size = 0x400;
ScanResult = _reader.ScanRegion(ScanAddress, ScanOffsets, size); ScanResult = _reader.Diagnostics!.ScanRegion(ScanAddress, ScanOffsets, size);
} }
[RelayCommand] [RelayCommand]
@ -816,7 +816,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
ScanResult = _reader.ScanAllStates(); ScanResult = _reader.Diagnostics!.ScanAllStates();
} }
[RelayCommand] [RelayCommand]
@ -828,7 +828,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
ScanResult = _reader.ProbeInGameState(); ScanResult = _reader.Diagnostics!.ProbeInGameState();
} }
[RelayCommand] [RelayCommand]
@ -844,7 +844,7 @@ public partial class MemoryViewModel : ObservableObject
int.TryParse(VitalMana, out var mana); int.TryParse(VitalMana, out var mana);
int.TryParse(VitalEs, out var es); int.TryParse(VitalEs, out var es);
ScanResult = _reader.ScanComponents(hp, mana, es); ScanResult = _reader.Diagnostics!.ScanComponents(hp, mana, es);
} }
[RelayCommand] [RelayCommand]
@ -860,7 +860,7 @@ public partial class MemoryViewModel : ObservableObject
int.TryParse(VitalMana, out var mana); int.TryParse(VitalMana, out var mana);
int.TryParse(VitalEs, out var es); int.TryParse(VitalEs, out var es);
ScanResult = _reader.DeepScanVitals(hp, mana, es); ScanResult = _reader.Diagnostics!.DeepScanVitals(hp, mana, es);
} }
[RelayCommand] [RelayCommand]
@ -872,7 +872,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
ScanResult = _reader.DiagnoseVitals(); ScanResult = _reader.Diagnostics!.DiagnoseVitals();
} }
[RelayCommand] [RelayCommand]
@ -884,7 +884,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
ScanResult = _reader.ScanPosition(); ScanResult = _reader.Diagnostics!.ScanPosition();
} }
[RelayCommand] [RelayCommand]
@ -896,7 +896,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
ScanResult = _reader.DiagnoseEntity(); ScanResult = _reader.Diagnostics!.DiagnoseEntity();
} }
[RelayCommand] [RelayCommand]
@ -908,7 +908,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
ScanResult = _reader.ScanEntities(); ScanResult = _reader.Diagnostics!.ScanEntities();
} }
[RelayCommand] [RelayCommand]
@ -920,7 +920,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
ScanResult = _reader.ScanComponentLookup(); ScanResult = _reader.Diagnostics!.ScanComponentLookup();
} }
[RelayCommand] [RelayCommand]
@ -932,7 +932,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
ScanResult = _reader.ScanAreaLoadingState(); ScanResult = _reader.Diagnostics!.ScanAreaLoadingState();
} }
[RelayCommand] [RelayCommand]
@ -944,7 +944,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
ScanResult = _reader.ScanMemoryDiff(); ScanResult = _reader.Diagnostics!.ScanMemoryDiff();
} }
[RelayCommand] [RelayCommand]
@ -956,7 +956,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
ScanResult = _reader.ScanActiveStatesVector(); ScanResult = _reader.Diagnostics!.ScanActiveStatesVector();
} }
[RelayCommand] [RelayCommand]
@ -968,7 +968,7 @@ public partial class MemoryViewModel : ObservableObject
return; return;
} }
ScanResult = _reader.ScanTerrain(); ScanResult = _reader.Diagnostics!.ScanTerrain();
} }
[RelayCommand] [RelayCommand]
@ -983,6 +983,30 @@ public partial class MemoryViewModel : ObservableObject
if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size)) if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size))
size = 0x2000; size = 0x2000;
ScanResult = _reader.ScanStructure(ScanAddress, ScanOffsets, size); ScanResult = _reader.Diagnostics!.ScanStructure(ScanAddress, ScanOffsets, size);
}
[RelayCommand]
private void ScanCameraExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.Diagnostics!.ScanCamera();
}
[RelayCommand]
private void CameraDiffExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.Diagnostics!.CameraDiff();
} }
} }

View file

@ -0,0 +1,269 @@
using System.Collections.ObjectModel;
using System.Numerics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Roboto.Core;
using Roboto.Engine;
using Roboto.Input;
using Roboto.Navigation;
namespace Automata.Ui.ViewModels;
/// <summary>
/// Thread-safe snapshot read by the overlay layer each frame.
/// </summary>
public sealed class EntityOverlayData
{
public Vector2 PlayerPosition;
public float PlayerZ;
public Matrix4x4? CameraMatrix;
public EntityOverlayEntry[] Entries = [];
}
public readonly struct EntityOverlayEntry
{
public readonly float X, Y;
public readonly string Label;
public EntityOverlayEntry(float x, float y, string label)
{
X = x;
Y = y;
Label = label;
}
}
/// <summary>
/// View model item for entity checkbox list.
/// </summary>
public partial class EntityListItem : ObservableObject
{
public uint Id { get; }
public string Label { get; }
public string Category { get; }
public string Distance { get; set; }
public float X { get; set; }
public float Y { get; set; }
[ObservableProperty] private bool _isChecked;
public EntityListItem(uint id, string label, string category, float distance, float x, float y)
{
Id = id;
Label = label;
Category = category;
Distance = $"{distance:F0}";
X = x;
Y = y;
}
}
public partial class RobotoViewModel : ObservableObject, IDisposable
{
private readonly BotEngine _engine;
private long _lastUiUpdate;
private bool _disposed;
[ObservableProperty] private string _statusText = "Stopped";
[ObservableProperty] private bool _isRunning;
// Player state
[ObservableProperty] private string _playerPosition = "—";
[ObservableProperty] private string _playerLife = "—";
[ObservableProperty] private string _playerMana = "—";
[ObservableProperty] private string _playerEs = "—";
// Game state
[ObservableProperty] private string _areaInfo = "—";
[ObservableProperty] private string _dangerLevel = "—";
[ObservableProperty] private string _entityCount = "—";
[ObservableProperty] private string _hostileCount = "—";
[ObservableProperty] private string _tickInfo = "—";
// Systems
[ObservableProperty] private string _systemsInfo = "—";
// Navigation
[ObservableProperty] private string _navMode = "Idle";
[ObservableProperty] private string _navStatus = "—";
// Entity list for checkbox UI
[ObservableProperty] private bool _showAllEntities;
public ObservableCollection<EntityListItem> Entities { get; } = [];
/// <summary>
/// Thread-safe snapshot for the overlay layer (written on UI thread, read on overlay thread).
/// </summary>
public static volatile EntityOverlayData? OverlayData;
public RobotoViewModel()
{
var config = new BotConfig();
var memory = new MemoryAdapter();
var humanizer = new Humanizer(config);
var input = new InterceptionInputController(humanizer);
_engine = new BotEngine(config, memory, input);
_engine.StatusChanged += status =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() => StatusText = status);
};
_engine.StateUpdated += OnStateUpdated;
}
[RelayCommand]
private void Start()
{
if (_engine.IsRunning) return;
var ok = _engine.Start();
IsRunning = _engine.IsRunning;
if (!ok)
StatusText = _engine.Status;
}
[RelayCommand]
private void Stop()
{
_engine.Stop();
IsRunning = false;
StatusText = "Stopped";
PlayerPosition = "—";
PlayerLife = "—";
PlayerMana = "—";
PlayerEs = "—";
AreaInfo = "—";
DangerLevel = "—";
EntityCount = "—";
HostileCount = "—";
TickInfo = "—";
NavMode = "Idle";
NavStatus = "—";
Entities.Clear();
OverlayData = null;
}
[RelayCommand]
private void Explore()
{
if (!_engine.IsRunning) return;
_engine.Nav.Explore();
}
[RelayCommand]
private void StopNav()
{
_engine.Nav.Stop();
}
private void OnStateUpdated()
{
// Throttle UI updates to ~10Hz
var now = Environment.TickCount64;
if (now - _lastUiUpdate < 100) return;
_lastUiUpdate = now;
var state = _engine.CurrentState;
if (state is null) return;
Avalonia.Threading.Dispatcher.UIThread.Post(() => UpdateFromState(state));
}
private void UpdateFromState(GameState state)
{
var p = state.Player;
PlayerPosition = p.HasPosition
? $"({p.Position.X:F1}, {p.Position.Y:F1})"
: "—";
PlayerLife = p.LifeTotal > 0 ? $"{p.LifeCurrent}/{p.LifeTotal} ({p.LifePercent:F0}%)" : "—";
PlayerMana = p.ManaTotal > 0 ? $"{p.ManaCurrent}/{p.ManaTotal} ({p.ManaPercent:F0}%)" : "—";
PlayerEs = p.EsTotal > 0 ? $"{p.EsCurrent}/{p.EsTotal} ({p.EsPercent:F0}%)" : "—";
AreaInfo = state.AreaHash != 0
? $"Level {state.AreaLevel} (0x{state.AreaHash:X8})"
: "—";
DangerLevel = state.Danger.ToString();
EntityCount = $"{state.Entities.Count} total";
HostileCount = $"{state.HostileMonsters.Count} hostile";
TickInfo = $"Tick {state.TickNumber}, dt={state.DeltaTime * 1000:F0}ms";
// Systems status
var systems = _engine.Systems;
var enabled = systems.Count(s => s.IsEnabled);
SystemsInfo = $"{enabled}/{systems.Count} active";
// Navigation
NavMode = _engine.Nav.Mode.ToString();
NavStatus = _engine.Nav.Status;
// Entity list
UpdateEntityList(state);
}
private void UpdateEntityList(GameState state)
{
// Build lookup of currently checked IDs
var checkedIds = new HashSet<uint>();
foreach (var item in Entities)
if (item.IsChecked) checkedIds.Add(item.Id);
// Rebuild the list
Entities.Clear();
foreach (var e in state.Entities)
{
var shortLabel = 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;
Entities.Add(item);
}
// Build overlay snapshot from checked (or all) entities
var showAll = ShowAllEntities;
var overlayEntries = new List<EntityOverlayEntry>();
foreach (var item in Entities)
{
if (!showAll && !item.IsChecked) continue;
overlayEntries.Add(new EntityOverlayEntry(item.X, item.Y, item.Label));
}
if (overlayEntries.Count > 0)
{
OverlayData = new EntityOverlayData
{
PlayerPosition = state.Player.Position,
PlayerZ = state.Player.Z,
CameraMatrix = state.CameraMatrix,
Entries = overlayEntries.ToArray(),
};
}
else
{
OverlayData = null;
}
}
private static string GetShortLabel(string? path)
{
if (path is null) return "?";
// Strip @N instance suffix
var atIdx = path.LastIndexOf('@');
if (atIdx > 0) path = path[..atIdx];
// Take last segment
var slashIdx = path.LastIndexOf('/');
return slashIdx >= 0 ? path[(slashIdx + 1)..] : path;
}
public void Shutdown()
{
_engine.Stop();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_engine.Dispose();
}
}

View file

@ -766,6 +766,10 @@
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" /> Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Scan ActiveVec" Command="{Binding ScanActiveVecExecuteCommand}" <Button Content="Scan ActiveVec" Command="{Binding ScanActiveVecExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" /> Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="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" />
</WrapPanel> </WrapPanel>
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas" <TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
FontSize="10" Foreground="#e6edf3" Background="#0d1117" FontSize="10" Foreground="#e6edf3" Background="#0d1117"
@ -799,6 +803,132 @@
</DockPanel> </DockPanel>
</TabItem> </TabItem>
<!-- ========== ROBOTO TAB ========== -->
<TabItem Header="Roboto">
<ScrollViewer DataContext="{Binding RobotoVm}" Margin="0,6,0,0">
<StackPanel Spacing="8" Margin="6" x:DataType="vm:RobotoViewModel">
<!-- Controls -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="6">
<TextBlock Text="ENGINE CONTROL" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Start" Command="{Binding StartCommand}"
Padding="16,6" FontWeight="Bold" />
<Button Content="Stop" Command="{Binding StopCommand}"
Padding="16,6" FontWeight="Bold" />
<TextBlock Text="{Binding StatusText}" Foreground="#58a6ff"
FontWeight="SemiBold" VerticalAlignment="Center"
Margin="12,0,0,0" />
</StackPanel>
</StackPanel>
</Border>
<!-- Player State -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="4">
<TextBlock Text="PLAYER" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto" Margin="0,4,0,0">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Position:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding PlayerPosition}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Life:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding PlayerLife}" Foreground="#3fb950" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Mana:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding PlayerMana}" Foreground="#58a6ff" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="ES:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding PlayerEs}" Foreground="#bc8cff" FontFamily="Consolas" FontSize="12" />
</Grid>
</StackPanel>
</Border>
<!-- Game State -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="4">
<TextBlock Text="GAME STATE" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" Margin="0,4,0,0">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Area:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AreaInfo}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Danger:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding DangerLevel}" Foreground="#f0883e" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Entities:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding EntityCount}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Hostiles:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding HostileCount}" Foreground="#ff4444" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Systems:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding SystemsInfo}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="Tick:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding TickInfo}" Foreground="#484f58" FontFamily="Consolas" FontSize="12" />
</Grid>
</StackPanel>
</Border>
<!-- Navigation -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="6">
<TextBlock Text="NAVIGATION" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Explore" Command="{Binding ExploreCommand}"
Padding="16,6" FontWeight="Bold" />
<Button Content="Stop Nav" Command="{Binding StopNavCommand}"
Padding="16,6" FontWeight="Bold" />
</StackPanel>
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto" Margin="0,4,0,0">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Mode:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding NavMode}" Foreground="#58a6ff" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Status:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding NavStatus}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
</Grid>
</StackPanel>
</Border>
<!-- Entities -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Text="ENTITIES" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" VerticalAlignment="Center" />
<CheckBox IsChecked="{Binding ShowAllEntities}" Content="Show All on Overlay"
FontSize="11" Foreground="#8b949e" VerticalAlignment="Center"
MinWidth="0" Padding="4,0,0,0" />
</StackPanel>
<ListBox ItemsSource="{Binding Entities}" MaxHeight="300"
Background="Transparent" BorderThickness="0"
Padding="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:EntityListItem">
<StackPanel Orientation="Horizontal" Spacing="6">
<CheckBox IsChecked="{Binding IsChecked}" VerticalAlignment="Center"
MinWidth="0" Padding="0" />
<TextBlock Text="{Binding Label}" Foreground="#e6edf3"
FontFamily="Consolas" FontSize="11"
VerticalAlignment="Center" Width="200"
TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Category}" Foreground="#8b949e"
FontFamily="Consolas" FontSize="11"
VerticalAlignment="Center" Width="80" />
<TextBlock Text="{Binding Distance}" Foreground="#484f58"
FontFamily="Consolas" FontSize="11"
VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- ========== DEBUG TAB ========== --> <!-- ========== DEBUG TAB ========== -->
<TabItem Header="Debug"> <TabItem Header="Debug">
<ScrollViewer DataContext="{Binding DebugVm}" Margin="0,6,0,0"> <ScrollViewer DataContext="{Binding DebugVm}" Margin="0,6,0,0">

View file

@ -0,0 +1,81 @@
namespace Roboto.Core;
public class ActionQueue
{
private readonly List<BotAction> _actions = [];
public IReadOnlyList<BotAction> Actions => _actions;
public void Submit(BotAction action)
{
// Bump lower-priority same-type actions
for (var i = _actions.Count - 1; i >= 0; i--)
{
if (_actions[i].GetType() == action.GetType() && _actions[i].Priority >= action.Priority)
_actions.RemoveAt(i);
}
_actions.Add(action);
}
public void Enqueue(BotAction action) => Submit(action);
public void Clear() => _actions.Clear();
public T? GetHighestPriority<T>() where T : BotAction
{
T? best = null;
foreach (var action in _actions)
{
if (action is T typed && (best is null || typed.Priority < best.Priority))
best = typed;
}
return best;
}
/// <summary>
/// Resolve conflicts and return the final action list:
/// 1. FlaskActions always pass through
/// 2. Get highest-priority MoveAction + CastAction
/// 3. Urgent move (priority ≤ 10) → include move, BLOCK cast (flee)
/// 4. Normal → include both cast + move
/// 5. All other actions pass through
/// </summary>
public List<BotAction> Resolve()
{
var resolved = new List<BotAction>();
// Flasks always pass through
foreach (var action in _actions)
{
if (action is FlaskAction)
resolved.Add(action);
}
var bestMove = GetHighestPriority<MoveAction>();
var bestCast = GetHighestPriority<CastAction>();
if (bestMove is not null)
{
resolved.Add(bestMove);
// Urgent flee (priority ≤ 10) blocks casting
if (bestMove.Priority > 10 && bestCast is not null)
resolved.Add(bestCast);
}
else if (bestCast is not null)
{
resolved.Add(bestCast);
}
// Pass through everything else (Key, Click, Chat, Wait) except types already handled
foreach (var action in _actions)
{
if (action is MoveAction or CastAction or FlaskAction)
continue;
resolved.Add(action);
}
Clear();
return resolved;
}
}

View file

@ -0,0 +1,26 @@
using System.Numerics;
namespace Roboto.Core;
public enum ClickType { Left, Right }
public enum KeyActionType { Press, Down, Up }
public abstract record BotAction(int Priority);
public record MoveAction(int Priority, Vector2 Direction) : BotAction(Priority)
{
/// <summary>Normalized movement direction in world space.</summary>
public Vector2 Direction { get; init; } = Direction;
}
public record ClickAction(int Priority, Vector2 ScreenPosition, ClickType Type = ClickType.Left) : BotAction(Priority);
public record KeyAction(int Priority, ushort ScanCode, KeyActionType Type = KeyActionType.Press) : BotAction(Priority);
public record CastAction(int Priority, ushort SkillScanCode, Vector2? TargetScreenPos = null) : BotAction(Priority);
public record FlaskAction(int Priority, ushort FlaskScanCode) : BotAction(Priority);
public record ChatAction(int Priority, string Message) : BotAction(Priority);
public record WaitAction(int Priority, int DurationMs) : BotAction(Priority);

View file

@ -0,0 +1,40 @@
namespace Roboto.Core;
public class BotConfig
{
// Tick rates
public int LogicTickRateHz { get; set; } = 60;
public int MemoryPollRateHz { get; set; } = 30;
// Movement
public float SafeDistance { get; set; } = 400f;
public float RepulsionWeight { get; set; } = 1.5f;
public float WaypointReachedDistance { get; set; } = 80f;
// Navigation
public float WorldToGrid { get; set; } = 23f / 250f;
// Combat
public float CriticalHpPercent { get; set; } = 30f;
public float LowHpPercent { get; set; } = 50f;
// Loot
public float LootPickupRange { get; set; } = 600f;
// Humanization
public int MinReactionDelayMs { get; set; } = 50;
public int MaxReactionDelayMs { get; set; } = 150;
public float ClickJitterRadius { get; set; } = 3f;
public float TimingNoiseStdDev { get; set; } = 0.15f;
public int MaxApm { get; set; } = 250;
// Anti-detection
public float PollIntervalJitter { get; set; } = 0.2f;
// Flasks
public float LifeFlaskThreshold { get; set; } = 50f;
public float ManaFlaskThreshold { get; set; } = 50f;
public int FlaskCooldownMs { get; set; } = 4000;
public ushort LifeFlaskScanCode { get; set; } = 0x02; // Key1
public ushort ManaFlaskScanCode { get; set; } = 0x03; // Key2
}

9
src/Roboto.Core/Buff.cs Normal file
View file

@ -0,0 +1,9 @@
namespace Roboto.Core;
public record Buff
{
public string? Name { get; init; }
public float DurationRemaining { get; init; }
public int Charges { get; init; }
public bool IsDebuff { get; init; }
}

View file

@ -0,0 +1,42 @@
using System.Numerics;
namespace Roboto.Core;
public enum EntityCategory
{
Unknown,
Player,
Monster,
Npc,
WorldItem,
Chest,
Portal,
AreaTransition,
Effect,
Terrain,
MiscObject,
}
public enum MonsterThreatLevel
{
None,
Normal,
Magic,
Rare,
Unique,
}
public record EntitySnapshot
{
public uint Id { get; init; }
public string? Path { get; init; }
public EntityCategory Category { get; init; }
public MonsterThreatLevel ThreatLevel { get; init; }
public Vector2 Position { get; init; }
public float DistanceToPlayer { get; init; }
public bool IsAlive { get; init; }
public int LifeCurrent { get; init; }
public int LifeTotal { get; init; }
public bool IsTargetable { get; init; }
public HashSet<string>? Components { get; init; }
}

20
src/Roboto.Core/Enums.cs Normal file
View file

@ -0,0 +1,20 @@
namespace Roboto.Core;
public enum DangerLevel
{
Safe,
Low,
Medium,
High,
Critical,
}
public static class SystemPriority
{
public const int Threat = 50;
public const int Movement = 100;
public const int Navigation = 200;
public const int Combat = 300;
public const int Resource = 400;
public const int Loot = 500;
}

View file

@ -0,0 +1,10 @@
namespace Roboto.Core;
public record FlaskState
{
public int SlotIndex { get; init; }
public int ChargesCurrent { get; init; }
public int ChargesMax { get; init; }
public bool IsActive { get; init; }
public float CooldownRemaining { get; init; }
}

View file

@ -0,0 +1,28 @@
using System.Numerics;
namespace Roboto.Core;
public class GameState
{
public long TickNumber { get; set; }
public float DeltaTime { get; set; }
public long TimestampMs { get; set; }
public PlayerState Player { get; set; } = new();
public IReadOnlyList<EntitySnapshot> Entities { get; set; } = [];
public IReadOnlyList<EntitySnapshot> HostileMonsters { get; set; } = [];
public IReadOnlyList<EntitySnapshot> NearbyLoot { get; set; } = [];
public WalkabilitySnapshot? Terrain { get; set; }
public uint AreaHash { get; set; }
public int AreaLevel { get; set; }
public bool IsLoading { get; set; }
public bool IsEscapeOpen { get; set; }
public DangerLevel Danger { get; set; }
public Matrix4x4? CameraMatrix { get; set; }
// Derived (computed once per tick by GameStateEnricher)
public ThreatMap Threats { get; set; } = new();
public IReadOnlyList<EntitySnapshot> NearestEnemies { get; set; } = [];
public IReadOnlyList<GroundEffect> GroundEffects { get; set; } = [];
}

View file

@ -0,0 +1,20 @@
using System.Numerics;
namespace Roboto.Core;
public record GroundEffect
{
public Vector2 Position { get; init; }
public float Radius { get; init; }
public GroundEffectType Type { get; init; }
}
public enum GroundEffectType
{
Unknown,
Fire,
Cold,
Lightning,
Chaos,
Physical,
}

View file

@ -0,0 +1,17 @@
namespace Roboto.Core;
public interface IInputController
{
bool IsInitialized { get; }
void KeyDown(ushort scanCode);
void KeyUp(ushort scanCode);
void KeyPress(ushort scanCode, int holdMs = 50);
void MouseMoveTo(int x, int y);
void MouseMoveBy(int dx, int dy);
void LeftClick(int x, int y);
void RightClick(int x, int y);
void LeftDown();
void LeftUp();
void RightDown();
void RightUp();
}

View file

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

View file

@ -0,0 +1,9 @@
namespace Roboto.Core;
public interface ISystem
{
int Priority { get; }
string Name { get; }
bool IsEnabled { get; set; }
void Update(GameState state, ActionQueue actions);
}

View file

@ -0,0 +1,30 @@
using System.Numerics;
namespace Roboto.Core;
public record PlayerState
{
public Vector2 Position { get; init; }
public float Z { get; init; }
public bool HasPosition { get; init; }
public int LifeCurrent { get; init; }
public int LifeTotal { get; init; }
public int ManaCurrent { get; init; }
public int ManaTotal { get; init; }
public int EsCurrent { get; init; }
public int EsTotal { get; init; }
public float LifePercent => LifeTotal > 0 ? (float)LifeCurrent / LifeTotal * 100f : 0f;
public float ManaPercent => ManaTotal > 0 ? (float)ManaCurrent / ManaTotal * 100f : 0f;
public float EsPercent => EsTotal > 0 ? (float)EsCurrent / EsTotal * 100f : 0f;
// Flask state (populated by memory when available)
public IReadOnlyList<FlaskState> Flasks { get; init; } = [];
// Active buffs (populated by memory when available)
public IReadOnlyList<Buff> Buffs { get; init; } = [];
// Skill slots (populated by memory when available)
public IReadOnlyList<SkillState> Skills { get; init; } = [];
}

View file

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,12 @@
namespace Roboto.Core;
public record SkillState
{
public int SlotIndex { get; init; }
public ushort ScanCode { get; init; }
public string? Name { get; init; }
public int ChargesCurrent { get; init; }
public int ChargesMax { get; init; }
public float CooldownRemaining { get; init; }
public bool CanUse => CooldownRemaining <= 0 && ChargesCurrent > 0;
}

View file

@ -0,0 +1,14 @@
using System.Numerics;
namespace Roboto.Core;
public class ThreatMap
{
public int TotalHostiles { get; init; }
public int CloseRange { get; init; } // < 300 units
public int MidRange { get; init; } // 300600
public int FarRange { get; init; } // 6001200
public float ClosestDistance { get; init; } = float.MaxValue;
public Vector2 ThreatCentroid { get; init; }
public bool HasRareOrUnique { get; init; }
}

View file

@ -0,0 +1,15 @@
namespace Roboto.Core;
public record WalkabilitySnapshot
{
public int Width { get; init; }
public int Height { get; init; }
public byte[] Data { get; init; } = [];
public bool IsWalkable(int x, int y)
{
if (x < 0 || x >= Width || y < 0 || y >= Height)
return false;
return Data[y * Width + x] != 0;
}
}

View file

@ -0,0 +1,81 @@
using System.Numerics;
using Roboto.Core;
namespace Roboto.Data;
/// <summary>
/// Computes all derived fields on GameState once per tick.
/// Static methods, no allocations beyond the sorted list.
/// </summary>
public static class GameStateEnricher
{
public static void Enrich(GameState state)
{
state.NearestEnemies = ComputeNearestEnemies(state.HostileMonsters);
state.Threats = ComputeThreatMap(state.HostileMonsters);
state.Danger = ComputeDangerLevel(state);
state.GroundEffects = []; // stub until memory reads ground effects
}
private static IReadOnlyList<EntitySnapshot> ComputeNearestEnemies(IReadOnlyList<EntitySnapshot> hostiles)
{
if (hostiles.Count == 0) return [];
var sorted = new List<EntitySnapshot>(hostiles);
sorted.Sort((a, b) => a.DistanceToPlayer.CompareTo(b.DistanceToPlayer));
return sorted;
}
private static ThreatMap ComputeThreatMap(IReadOnlyList<EntitySnapshot> hostiles)
{
if (hostiles.Count == 0) return new ThreatMap();
int close = 0, mid = 0, far = 0;
float closest = float.MaxValue;
var weightedSum = Vector2.Zero;
bool hasRareOrUnique = false;
foreach (var m in hostiles)
{
var d = m.DistanceToPlayer;
if (d < closest) closest = d;
if (d < 300f) close++;
else if (d < 600f) mid++;
else if (d < 1200f) far++;
weightedSum += m.Position;
if (m.ThreatLevel is MonsterThreatLevel.Rare or MonsterThreatLevel.Unique)
hasRareOrUnique = true;
}
return new ThreatMap
{
TotalHostiles = hostiles.Count,
CloseRange = close,
MidRange = mid,
FarRange = far,
ClosestDistance = closest,
ThreatCentroid = weightedSum / hostiles.Count,
HasRareOrUnique = hasRareOrUnique,
};
}
private static DangerLevel ComputeDangerLevel(GameState state)
{
if (state.Player.LifePercent < 30f) return DangerLevel.Critical;
if (state.Player.LifePercent < 50f) return DangerLevel.High;
var nearbyHostiles = 0;
foreach (var m in state.HostileMonsters)
{
if (m.DistanceToPlayer < 500f) nearbyHostiles++;
}
if (nearbyHostiles > 10) return DangerLevel.High;
if (nearbyHostiles > 5) return DangerLevel.Medium;
if (nearbyHostiles > 0) return DangerLevel.Low;
return DangerLevel.Safe;
}
}

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Roboto.Core\Roboto.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,99 @@
using Roboto.Core;
namespace Roboto.Input;
public sealed class Humanizer
{
private readonly BotConfig _config;
private readonly Random _rng = new();
private readonly Queue<long> _actionTimestamps = new();
private bool _hasSpare;
private double _spare;
public Humanizer(BotConfig config)
{
_config = config;
}
/// <summary>
/// Returns baseMs ± gaussian noise, clamped to [MinReaction, MaxReaction].
/// </summary>
public int GaussianDelay(int baseMs)
{
var noise = NextGaussian() * _config.TimingNoiseStdDev * baseMs;
var result = (int)(baseMs + noise);
return Math.Clamp(result, _config.MinReactionDelayMs, _config.MaxReactionDelayMs);
}
/// <summary>
/// Adds gaussian pixel offset within configured jitter radius.
/// </summary>
public (int x, int y) JitterPosition(int x, int y)
{
var r = _config.ClickJitterRadius;
if (r <= 0) return (x, y);
var dx = (int)(NextGaussian() * r * 0.5);
var dy = (int)(NextGaussian() * r * 0.5);
return (x + dx, y + dy);
}
/// <summary>
/// Returns true if we're over the APM cap and should throttle.
/// </summary>
public bool ShouldThrottle()
{
var now = Environment.TickCount64;
// Purge timestamps older than 60s
while (_actionTimestamps.Count > 0 && now - _actionTimestamps.Peek() > 60_000)
_actionTimestamps.Dequeue();
return _actionTimestamps.Count >= _config.MaxApm;
}
/// <summary>
/// Records an action for APM tracking.
/// </summary>
public void RecordAction()
{
_actionTimestamps.Enqueue(Environment.TickCount64);
}
/// <summary>
/// Returns base interval ± jitter% for poll/tick randomization.
/// </summary>
public double RandomizedInterval(double baseMs)
{
var jitter = _config.PollIntervalJitter;
if (jitter <= 0) return baseMs;
var factor = 1.0 + (NextGaussian() * jitter * 0.5);
return baseMs * Math.Clamp(factor, 1.0 - jitter, 1.0 + jitter);
}
/// <summary>
/// Box-Muller transform for gaussian random numbers.
/// </summary>
private double NextGaussian()
{
if (_hasSpare)
{
_hasSpare = false;
return _spare;
}
double u, v, s;
do
{
u = _rng.NextDouble() * 2.0 - 1.0;
v = _rng.NextDouble() * 2.0 - 1.0;
s = u * u + v * v;
} while (s >= 1.0 || s == 0.0);
var mul = Math.Sqrt(-2.0 * Math.Log(s) / s);
_spare = v * mul;
_hasSpare = true;
return u * mul;
}
}

View file

@ -0,0 +1,139 @@
using InputInterceptorNS;
using Roboto.Core;
using Serilog;
namespace Roboto.Input;
public sealed class InterceptionInputController : IInputController, IDisposable
{
private readonly Humanizer? _humanizer;
private KeyboardHook? _keyboard;
private MouseHook? _mouse;
private bool _disposed;
public bool IsInitialized => _keyboard is not null && _mouse is not null;
public InterceptionInputController(Humanizer? humanizer = null)
{
_humanizer = humanizer;
}
public bool Initialize()
{
try
{
if (!InputInterceptor.CheckDriverInstalled())
{
Log.Warning("Interception driver not installed");
return false;
}
if (!InputInterceptor.Initialize())
{
Log.Warning("Failed to load Interception native library");
return false;
}
_keyboard = new KeyboardHook();
_mouse = new MouseHook();
Log.Information("Interception input controller initialized");
return true;
}
catch (Exception ex)
{
Log.Error(ex, "Failed to initialize Interception hooks");
_keyboard = null;
_mouse = null;
return false;
}
}
public void KeyDown(ushort scanCode)
{
_keyboard?.SimulateKeyDown((KeyCode)scanCode);
}
public void KeyUp(ushort scanCode)
{
_keyboard?.SimulateKeyUp((KeyCode)scanCode);
}
public void KeyPress(ushort scanCode, int holdMs = 50)
{
if (_humanizer is not null)
{
if (_humanizer.ShouldThrottle()) return;
holdMs = _humanizer.GaussianDelay(holdMs);
_humanizer.RecordAction();
}
_keyboard?.SimulateKeyPress((KeyCode)scanCode, holdMs);
}
public void MouseMoveTo(int x, int y)
{
_mouse?.SetCursorPosition(x, y, false);
}
public void MouseMoveBy(int dx, int dy)
{
_mouse?.MoveCursorBy(dx, dy, false);
}
public void LeftClick(int x, int y)
{
if (_humanizer is not null)
{
if (_humanizer.ShouldThrottle()) return;
(x, y) = _humanizer.JitterPosition(x, y);
Thread.Sleep(_humanizer.GaussianDelay(10));
_humanizer.RecordAction();
}
MouseMoveTo(x, y);
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
_mouse?.SimulateLeftButtonClick(_humanizer?.GaussianDelay(50) ?? 50);
}
public void RightClick(int x, int y)
{
if (_humanizer is not null)
{
if (_humanizer.ShouldThrottle()) return;
(x, y) = _humanizer.JitterPosition(x, y);
Thread.Sleep(_humanizer.GaussianDelay(10));
_humanizer.RecordAction();
}
MouseMoveTo(x, y);
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
_mouse?.SimulateRightButtonClick(_humanizer?.GaussianDelay(50) ?? 50);
}
public void LeftDown()
{
_mouse?.SimulateLeftButtonDown();
}
public void LeftUp()
{
_mouse?.SimulateLeftButtonUp();
}
public void RightDown()
{
_mouse?.SimulateRightButtonDown();
}
public void RightUp()
{
_mouse?.SimulateRightButtonUp();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_keyboard?.Dispose();
_mouse?.Dispose();
_keyboard = null;
_mouse = null;
}
}

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="InputInterceptor" Version="2.2.1" />
<PackageReference Include="Serilog" Version="4.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Roboto.Core\Roboto.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="interception.dll" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,56 @@
namespace Roboto.Input;
/// <summary>
/// Hardware scan codes for keyboard input via Interception driver.
/// </summary>
public static class ScanCodes
{
// WASD movement
public const ushort W = 0x11;
public const ushort A = 0x1E;
public const ushort S = 0x1F;
public const ushort D = 0x20;
// Number row
public const ushort Key1 = 0x02;
public const ushort Key2 = 0x03;
public const ushort Key3 = 0x04;
public const ushort Key4 = 0x05;
public const ushort Key5 = 0x06;
public const ushort Key6 = 0x07;
public const ushort Key7 = 0x08;
public const ushort Key8 = 0x09;
public const ushort Key9 = 0x0A;
public const ushort Key0 = 0x0B;
// Modifiers
public const ushort LShift = 0x2A;
public const ushort RShift = 0x36;
public const ushort LCtrl = 0x1D;
public const ushort LAlt = 0x38;
// Common keys
public const ushort Escape = 0x01;
public const ushort Tab = 0x0F;
public const ushort Space = 0x39;
public const ushort Enter = 0x1C;
public const ushort Backspace = 0x0E;
// Function keys
public const ushort F1 = 0x3B;
public const ushort F2 = 0x3C;
public const ushort F3 = 0x3D;
public const ushort F4 = 0x3E;
public const ushort F5 = 0x3F;
// Letters (commonly used)
public const ushort Q = 0x10;
public const ushort E = 0x12;
public const ushort R = 0x13;
public const ushort T = 0x14;
public const ushort I = 0x17;
public const ushort F = 0x21;
// Slash (for chat commands like /hideout)
public const ushort Slash = 0x35;
}

Binary file not shown.

View file

@ -0,0 +1,303 @@
using System.Numerics;
using Roboto.Core;
using Serilog;
namespace Roboto.Navigation;
public enum NavMode
{
Idle,
NavigatingToPosition,
NavigatingToEntity,
Exploring,
}
public sealed class NavigationController
{
private readonly BotConfig _config;
private readonly Random _rng = new();
private List<Vector2>? _path;
private int _waypointIndex;
private uint _lastAreaHash;
private long _pathTimestampMs;
private Vector2? _goalPosition;
private uint _targetEntityId;
// Stuck detection: rolling window of recent positions
private readonly Queue<Vector2> _positionHistory = new();
private const int StuckWindowSize = 10;
private const float StuckThreshold = 5f;
public NavMode Mode { get; private set; } = NavMode.Idle;
public Vector2? DesiredDirection { get; private set; }
public IReadOnlyList<Vector2>? CurrentPath => _path;
public string Status { get; private set; } = "Idle";
public NavigationController(BotConfig config)
{
_config = config;
}
public void NavigateTo(Vector2 position)
{
_goalPosition = position;
_targetEntityId = 0;
_path = null;
_waypointIndex = 0;
Mode = NavMode.NavigatingToPosition;
Status = $"Nav to ({position.X:F0}, {position.Y:F0})";
Log.Debug("NavigationController: navigating to {Position}", position);
}
public void NavigateToEntity(uint entityId)
{
_targetEntityId = entityId;
_goalPosition = null;
_path = null;
_waypointIndex = 0;
Mode = NavMode.NavigatingToEntity;
Status = $"Nav to entity {entityId}";
Log.Debug("NavigationController: navigating to entity {EntityId}", entityId);
}
public void Explore()
{
_goalPosition = null;
_targetEntityId = 0;
_path = null;
_waypointIndex = 0;
Mode = NavMode.Exploring;
Status = "Exploring";
Log.Debug("NavigationController: exploring");
}
public void Stop()
{
_goalPosition = null;
_targetEntityId = 0;
_path = null;
_waypointIndex = 0;
DesiredDirection = null;
Mode = NavMode.Idle;
Status = "Idle";
_positionHistory.Clear();
}
public void Update(GameState state)
{
DesiredDirection = null;
if (Mode == NavMode.Idle) return;
if (!state.Player.HasPosition) return;
if (state.Terrain is null) return;
var playerPos = state.Player.Position;
var now = state.TimestampMs;
// Area change → clear path
if (state.AreaHash != _lastAreaHash)
{
_lastAreaHash = state.AreaHash;
_path = null;
_waypointIndex = 0;
_positionHistory.Clear();
}
// Resolve goal based on mode
var goal = ResolveGoal(state);
if (goal is null)
{
if (Mode == NavMode.Exploring)
goal = PickExploreTarget(state);
if (goal is null)
{
if (Mode == NavMode.Exploring)
return; // Try again next tick
Stop();
return;
}
}
// Check if reached goal
if (Vector2.Distance(playerPos, goal.Value) < _config.WaypointReachedDistance)
{
if (Mode == NavMode.Exploring)
{
// Clear goal so next tick picks a new explore target
_goalPosition = null;
_path = null;
_waypointIndex = 0;
return;
}
Stop();
Status = "Arrived";
return;
}
// Stuck detection
_positionHistory.Enqueue(playerPos);
if (_positionHistory.Count > StuckWindowSize)
_positionHistory.Dequeue();
var isStuck = false;
if (_positionHistory.Count >= StuckWindowSize && _path is not null)
{
var oldest = _positionHistory.Peek();
if (Vector2.Distance(oldest, playerPos) < StuckThreshold)
{
isStuck = true;
if (Mode == NavMode.Exploring)
{
Log.Information("NavigationController: stuck while exploring, picking new target");
_goalPosition = null;
_path = null;
_waypointIndex = 0;
_positionHistory.Clear();
return;
}
Log.Debug("NavigationController: stuck detected, repathing");
}
}
// Repath conditions: no path, stuck, stale (>5s)
var needsRepath = _path is null || isStuck || (now - _pathTimestampMs > 5000);
// Entity moved significantly → repath
if (Mode == NavMode.NavigatingToEntity && _path is not null && _goalPosition.HasValue)
{
var pathGoal = _path[^1];
if (Vector2.Distance(pathGoal, goal.Value) > _config.WaypointReachedDistance * 2)
needsRepath = true;
}
if (needsRepath)
{
if (state.TickNumber % 60 == 0)
{
var gx = (int)(playerPos.X * _config.WorldToGrid);
var gy = (int)(playerPos.Y * _config.WorldToGrid);
var ggx = (int)(goal.Value.X * _config.WorldToGrid);
var ggy = (int)(goal.Value.Y * _config.WorldToGrid);
var startWalk = state.Terrain?.IsWalkable(gx, gy) ?? false;
var goalWalk = state.Terrain?.IsWalkable(ggx, ggy) ?? false;
Log.Information(
"PATH REQ: start=({Gx},{Gy}) walkable={SW}, goal=({Ggx},{Ggy}) walkable={GW}, gridSize={W}x{H}",
gx, gy, startWalk, ggx, ggy, goalWalk,
state.Terrain?.Width ?? 0, state.Terrain?.Height ?? 0);
}
_path = PathFinder.FindPath(state.Terrain, playerPos, goal.Value, _config.WorldToGrid);
_waypointIndex = 0;
_pathTimestampMs = now;
if (_path is null)
{
if (Mode == NavMode.Exploring)
{
Log.Debug("PATH FAIL: unreachable explore target, picking new");
_goalPosition = null;
}
else
{
Log.Warning("PATH FAIL: no path found");
}
Status = "No path";
return;
}
if (state.TickNumber % 60 == 0)
Log.Information("PATH OK: {Count} waypoints", _path.Count);
}
// Advance waypoints
while (_waypointIndex < _path!.Count)
{
if (Vector2.Distance(playerPos, _path[_waypointIndex]) < _config.WaypointReachedDistance)
_waypointIndex++;
else
break;
}
if (_waypointIndex >= _path.Count)
{
_path = null;
return;
}
// Output direction
var target = _path[_waypointIndex];
DesiredDirection = Vector2.Normalize(target - playerPos);
Status = $"{Mode} ({_waypointIndex}/{_path.Count} wp)";
// Diagnostic: log every ~60 ticks (once per second at 60Hz)
if (state.TickNumber % 60 == 0)
{
var gx = (int)(playerPos.X * _config.WorldToGrid);
var gy = (int)(playerPos.Y * _config.WorldToGrid);
var walkable = state.Terrain?.IsWalkable(gx, gy) ?? false;
Log.Information(
"NAV DIAG: playerWorld=({Px:F0},{Py:F0}) playerGrid=({Gx},{Gy}) walkable={W} " +
"waypointWorld=({Tx:F0},{Ty:F0}) dir=({Dx:F2},{Dy:F2})",
playerPos.X, playerPos.Y, gx, gy, walkable,
target.X, target.Y,
DesiredDirection.Value.X, DesiredDirection.Value.Y);
}
}
private Vector2? ResolveGoal(GameState state)
{
switch (Mode)
{
case NavMode.NavigatingToPosition:
return _goalPosition;
case NavMode.NavigatingToEntity:
foreach (var e in state.Entities)
{
if (e.Id == _targetEntityId)
{
_goalPosition = e.Position;
return e.Position;
}
}
// Entity not found
return _goalPosition;
case NavMode.Exploring:
return _goalPosition;
default:
return null;
}
}
private Vector2? PickExploreTarget(GameState state)
{
if (state.Terrain is null) return null;
var terrain = state.Terrain;
var gridToWorld = 1f / _config.WorldToGrid;
// Try random walkable points
for (var attempt = 0; attempt < 200; attempt++)
{
var gx = _rng.Next(terrain.Width);
var gy = _rng.Next(terrain.Height);
if (!terrain.IsWalkable(gx, gy)) continue;
var worldPos = new Vector2(gx * gridToWorld, gy * gridToWorld);
var dist = Vector2.Distance(state.Player.Position, worldPos);
// Pick points that are a reasonable distance away (not too close, not too far)
if (dist > 300f && dist < 8000f)
{
_goalPosition = worldPos;
return worldPos;
}
}
return null;
}
}

View file

@ -0,0 +1,153 @@
using System.Numerics;
using Roboto.Core;
namespace Roboto.Navigation;
public static class PathFinder
{
private static readonly int[] Dx = [-1, 0, 1, 0, -1, -1, 1, 1];
private static readonly int[] Dy = [0, -1, 0, 1, -1, 1, -1, 1];
private static readonly float[] Cost = [1f, 1f, 1f, 1f, 1.414f, 1.414f, 1.414f, 1.414f];
/// <summary>
/// A* pathfinding on WalkabilitySnapshot. Returns world-coord waypoints or null if no path.
/// </summary>
public static List<Vector2>? FindPath(WalkabilitySnapshot terrain, Vector2 start, Vector2 goal, float worldToGrid)
{
var w = terrain.Width;
var h = terrain.Height;
var gridToWorld = 1f / worldToGrid;
var startGx = Math.Clamp((int)(start.X * worldToGrid), 0, w - 1);
var startGy = Math.Clamp((int)(start.Y * worldToGrid), 0, h - 1);
var goalGx = Math.Clamp((int)(goal.X * worldToGrid), 0, w - 1);
var goalGy = Math.Clamp((int)(goal.Y * worldToGrid), 0, h - 1);
// Snap to nearest walkable if start/goal are in walls
(startGx, startGy) = SnapToWalkable(terrain, startGx, startGy, w, h);
(goalGx, goalGy) = SnapToWalkable(terrain, goalGx, goalGy, w, h);
var startNode = (startGx, startGy);
var goalNode = (goalGx, goalGy);
if (startNode == goalNode)
return [new Vector2(goalGx * gridToWorld, goalGy * gridToWorld)];
var openSet = new PriorityQueue<(int x, int y), float>();
var cameFrom = new Dictionary<(int, int), (int, int)>();
var gScore = new Dictionary<(int, int), float>();
gScore[startNode] = 0;
openSet.Enqueue(startNode, Heuristic(startNode, goalNode));
var iterations = 0;
const int maxIterations = 50_000;
while (openSet.Count > 0 && iterations++ < maxIterations)
{
var current = openSet.Dequeue();
if (current == goalNode)
return ReconstructAndSimplify(cameFrom, current, gridToWorld);
var currentG = gScore.GetValueOrDefault(current, float.MaxValue);
for (var i = 0; i < 8; i++)
{
var nx = current.x + Dx[i];
var ny = current.y + Dy[i];
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
if (!terrain.IsWalkable(nx, ny)) continue;
// Diagonal corner-cut check
if (i >= 4)
{
if (!terrain.IsWalkable(current.x + Dx[i], current.y) ||
!terrain.IsWalkable(current.x, current.y + Dy[i]))
continue;
}
var neighbor = (nx, ny);
var tentativeG = currentG + Cost[i];
if (tentativeG < gScore.GetValueOrDefault(neighbor, float.MaxValue))
{
cameFrom[neighbor] = current;
gScore[neighbor] = tentativeG;
openSet.Enqueue(neighbor, tentativeG + Heuristic(neighbor, goalNode));
}
}
}
return null;
}
private static float Heuristic((int x, int y) a, (int x, int y) b)
{
var dx = Math.Abs(a.x - b.x);
var dy = Math.Abs(a.y - b.y);
return Math.Max(dx, dy) + 0.414f * Math.Min(dx, dy);
}
private static (int, int) SnapToWalkable(WalkabilitySnapshot terrain, int gx, int gy, int w, int h)
{
if (terrain.IsWalkable(gx, gy)) return (gx, gy);
// BFS outward to find nearest walkable cell
for (var r = 1; r < 20; r++)
{
for (var dx = -r; dx <= r; dx++)
{
for (var dy = -r; dy <= r; dy++)
{
if (Math.Abs(dx) != r && Math.Abs(dy) != r) continue;
var nx = gx + dx;
var ny = gy + dy;
if (nx >= 0 && nx < w && ny >= 0 && ny < h && terrain.IsWalkable(nx, ny))
return (nx, ny);
}
}
}
return (gx, gy);
}
private static List<Vector2> ReconstructAndSimplify(
Dictionary<(int, int), (int, int)> cameFrom, (int x, int y) current, float gridToWorld)
{
var path = new List<(int x, int y)>();
var node = current;
while (cameFrom.ContainsKey(node))
{
path.Add(node);
node = cameFrom[node];
}
path.Reverse();
if (path.Count <= 2)
return path.Select(n => new Vector2(n.x * gridToWorld, n.y * gridToWorld)).ToList();
// Simplify: skip collinear waypoints
var simplified = new List<Vector2> { ToWorld(path[0], gridToWorld) };
for (var i = 2; i < path.Count; i++)
{
var prev = path[i - 2];
var mid = path[i - 1];
var curr = path[i];
var d1x = mid.x - prev.x;
var d1y = mid.y - prev.y;
var d2x = curr.x - mid.x;
var d2y = curr.y - mid.y;
// Direction changed
if (d1x != d2x || d1y != d2y)
simplified.Add(ToWorld(mid, gridToWorld));
}
simplified.Add(ToWorld(path[^1], gridToWorld));
return simplified;
}
private static Vector2 ToWorld((int x, int y) node, float gridToWorld)
=> new(node.x * gridToWorld, node.y * gridToWorld);
}

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Roboto.Core\Roboto.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,15 @@
using Roboto.Core;
namespace Roboto.Systems;
public class CombatSystem : ISystem
{
public int Priority => SystemPriority.Combat;
public string Name => "Combat";
public bool IsEnabled { get; set; } = false;
public void Update(GameState state, ActionQueue actions)
{
// STUB: skill usage and attack logic
}
}

View file

@ -0,0 +1,15 @@
using Roboto.Core;
namespace Roboto.Systems;
public class LootSystem : ISystem
{
public int Priority => SystemPriority.Loot;
public string Name => "Loot";
public bool IsEnabled { get; set; } = false;
public void Update(GameState state, ActionQueue actions)
{
// STUB: loot detection and pickup logic
}
}

View file

@ -0,0 +1,46 @@
using System.Numerics;
using Roboto.Core;
namespace Roboto.Systems;
/// <summary>
/// Force-based avoidance: applies inverse-square repulsion from hostile monsters
/// within safe distance. Emits a MoveAction with the escape direction.
/// </summary>
public class MovementSystem : ISystem
{
public int Priority => SystemPriority.Movement;
public string Name => "Movement";
public bool IsEnabled { get; set; } = true;
public float SafeDistance { get; set; } = 400f;
public float RepulsionWeight { get; set; } = 1.5f;
public void Update(GameState state, ActionQueue actions)
{
if (!state.Player.HasPosition) return;
if (state.HostileMonsters.Count == 0) return;
var playerPos = state.Player.Position;
var repulsion = Vector2.Zero;
foreach (var monster in state.HostileMonsters)
{
if (!monster.IsAlive) continue;
if (monster.DistanceToPlayer > SafeDistance) continue;
var delta = playerPos - monster.Position;
var distSq = delta.LengthSquared();
if (distSq < 1f) distSq = 1f;
// Inverse-square repulsion: stronger when closer
var force = delta / distSq * RepulsionWeight;
repulsion += force;
}
if (repulsion.LengthSquared() < 0.0001f) return;
var direction = Vector2.Normalize(repulsion);
actions.Enqueue(new MoveAction(Priority, direction));
}
}

View file

@ -0,0 +1,29 @@
using System.Numerics;
using Roboto.Core;
namespace Roboto.Systems;
/// <summary>
/// Simplified navigation system. Pathfinding has moved to Roboto.Navigation.PathFinder.
/// This system just submits a MoveAction if an external direction is set.
/// </summary>
public class NavigationSystem : ISystem
{
public int Priority => SystemPriority.Navigation;
public string Name => "Navigation";
public bool IsEnabled { get; set; } = true;
public float WorldToGrid { get; set; } = 23f / 250f;
public float WaypointReachedDistance { get; set; } = 80f;
/// <summary>
/// Set externally (e.g. by NavigationController via BotEngine) to drive movement.
/// </summary>
public Vector2? ExternalDirection { get; set; }
public void Update(GameState state, ActionQueue actions)
{
if (ExternalDirection.HasValue)
actions.Submit(new MoveAction(Priority, ExternalDirection.Value));
}
}

View file

@ -0,0 +1,42 @@
using Roboto.Core;
namespace Roboto.Systems;
public class ResourceSystem : ISystem
{
private readonly BotConfig _config;
private long _lastLifeFlaskMs;
private long _lastManaFlaskMs;
public int Priority => SystemPriority.Resource;
public string Name => "Resource";
public bool IsEnabled { get; set; } = true;
public ResourceSystem(BotConfig config)
{
_config = config;
}
public void Update(GameState state, ActionQueue actions)
{
var player = state.Player;
if (player.LifeTotal == 0) return;
var now = Environment.TickCount64;
if (player.LifePercent < _config.LifeFlaskThreshold
&& now - _lastLifeFlaskMs >= _config.FlaskCooldownMs)
{
actions.Submit(new FlaskAction(Priority, _config.LifeFlaskScanCode));
_lastLifeFlaskMs = now;
}
if (player.ManaTotal > 0
&& player.ManaPercent < _config.ManaFlaskThreshold
&& now - _lastManaFlaskMs >= _config.FlaskCooldownMs)
{
actions.Submit(new FlaskAction(Priority, _config.ManaFlaskScanCode));
_lastManaFlaskMs = now;
}
}
}

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Roboto.Core\Roboto.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,69 @@
using System.Numerics;
using Roboto.Core;
using Serilog;
namespace Roboto.Systems;
/// <summary>
/// Emergency threat response. Runs first (priority 50).
/// On Critical danger: urgent flee (blocks casting via priority ≤ 10).
/// On High danger: flee toward safety but allow casting.
/// Medium and below: no action (MovementSystem handles soft avoidance).
/// </summary>
public class ThreatSystem : ISystem
{
public int Priority => SystemPriority.Threat;
public string Name => "Threat";
public bool IsEnabled { get; set; } = true;
/// <summary>Priority ≤ 10 blocks casting in ActionQueue.Resolve — pure flee.</summary>
private const int UrgentFleePriority = 5;
/// <summary>If closest enemy is within this range, escalate to urgent flee.</summary>
public float PointBlankRange { get; set; } = 150f;
private DangerLevel _prevDanger = DangerLevel.Safe;
public void Update(GameState state, ActionQueue actions)
{
if (!state.Player.HasPosition) return;
var danger = state.Danger;
var threats = state.Threats;
// Log danger transitions
if (danger != _prevDanger)
{
if (danger >= DangerLevel.High)
Log.Warning("Threat: {Prev} -> {Cur} (hostiles={Total}, close={Close}, closest={Dist:F0})",
_prevDanger, danger, threats.TotalHostiles, threats.CloseRange, threats.ClosestDistance);
else
Log.Debug("Threat: {Prev} -> {Cur}", _prevDanger, danger);
_prevDanger = danger;
}
if (danger <= DangerLevel.Medium) return;
if (threats.TotalHostiles == 0) return;
// Compute flee direction: away from threat centroid
var fleeDir = state.Player.Position - threats.ThreatCentroid;
if (fleeDir.LengthSquared() < 0.0001f)
fleeDir = Vector2.UnitY; // fallback if at centroid
fleeDir = Vector2.Normalize(fleeDir);
// Point-blank override: if closest enemy is very close, escalate to urgent
var isPointBlank = threats.ClosestDistance < PointBlankRange;
if (danger == DangerLevel.Critical || isPointBlank)
{
// Urgent flee — blocks casting (priority ≤ 10)
actions.Submit(new MoveAction(UrgentFleePriority, fleeDir));
}
else // High
{
// Flee but allow casting alongside
actions.Submit(new MoveAction(Priority, fleeDir));
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After