lots done
This commit is contained in:
parent
1ba7c39c30
commit
fbd0ba445a
59 changed files with 6074 additions and 3598 deletions
45
Automata.sln
45
Automata.sln
|
|
@ -45,6 +45,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Data", "lib\Sideki
|
|||
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}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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}.Release|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{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}
|
||||
{9428D5D4-4061-467A-BD26-C1FEED95E8E6} = {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
|
||||
EndGlobal
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
"Animated",
|
||||
"AreaTransition",
|
||||
"BaseEvents",
|
||||
"Brackets",
|
||||
"Buffs",
|
||||
"Chest",
|
||||
"ControlZone",
|
||||
"CritterAI",
|
||||
"DiesAfterTime",
|
||||
"Functions",
|
||||
"GlobalAudioParamEvents",
|
||||
"HideoutDoodad",
|
||||
"InteractionAction",
|
||||
"Inventories",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,13 @@
|
|||
"Metadata/Characters/Str/StrFourb",
|
||||
"Metadata/Characters/StrDex/StrDexFourb",
|
||||
"Metadata/Characters/StrInt/StrIntFourb",
|
||||
"Metadata/Chests/EzomyteChest_02",
|
||||
"Metadata/Chests/EzomyteChest_05",
|
||||
"Metadata/Chests/EzomyteChest_06",
|
||||
"Metadata/Chests/LeagueIncursion/EncounterChest",
|
||||
"Metadata/Chests/MossyChest11",
|
||||
"Metadata/Chests/MossyChest20",
|
||||
"Metadata/Chests/MossyChest21",
|
||||
"Metadata/Chests/MossyChest26",
|
||||
"Metadata/Critters/Chicken/Chicken_kingsmarch",
|
||||
"Metadata/Critters/Hedgehog/HedgehogSlow",
|
||||
|
|
@ -19,13 +24,16 @@
|
|||
"Metadata/Effects/Microtransactions/foot_prints/harvest02/footprints_harvest",
|
||||
"Metadata/Effects/PermanentEffect",
|
||||
"Metadata/Effects/ServerEffect",
|
||||
"Metadata/Effects/Spells/monsters_effects/Act1_FOUR/CarrionCrone/IceSpike",
|
||||
"Metadata/MiscellaneousObjects/AreaTransitionBlockage",
|
||||
"Metadata/MiscellaneousObjects/AreaTransitionDoodad",
|
||||
"Metadata/MiscellaneousObjects/AreaTransition_Animate",
|
||||
"Metadata/MiscellaneousObjects/Checkpoint",
|
||||
"Metadata/MiscellaneousObjects/Doodad",
|
||||
"Metadata/MiscellaneousObjects/DoodadInvisible",
|
||||
"Metadata/MiscellaneousObjects/DoodadNoBlocking",
|
||||
"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/FlowSource_4.75_1",
|
||||
"Metadata/MiscellaneousObjects/GuildStash",
|
||||
|
|
@ -42,16 +50,22 @@
|
|||
"Metadata/MiscellaneousObjects/Stash",
|
||||
"Metadata/MiscellaneousObjects/Waypoint",
|
||||
"Metadata/MiscellaneousObjects/WorldItem",
|
||||
"Metadata/Monsters/Hags/Objects/BossRoomMinimapIcon",
|
||||
"Metadata/Monsters/Hags/UrchinHag1",
|
||||
"Metadata/Monsters/Hags/UrchinHagBoss",
|
||||
"Metadata/Monsters/InvisibleFire/MDCarrionCroneWave",
|
||||
"Metadata/Monsters/Urchins/MeleeUrchin1",
|
||||
"Metadata/Monsters/Urchins/SlingUrchin1",
|
||||
"Metadata/Monsters/Wolves/RottenWolf1_",
|
||||
"Metadata/Monsters/Wolves/RottenWolfDead",
|
||||
"Metadata/Monsters/Wolves/RottenWolfHagSummonedDead",
|
||||
"Metadata/Monsters/Zombies/CourtGuardZombieUnarmed",
|
||||
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxe",
|
||||
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxePhysics__",
|
||||
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmed",
|
||||
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmedPhysics",
|
||||
"Metadata/NPC/Four_Act1/ClearfellPosting1",
|
||||
"Metadata/NPC/Four_Act1/ClearfellPosting3",
|
||||
"Metadata/NPC/Four_Act1/DogTrader_Entrance",
|
||||
"Metadata/NPC/Four_Act1/ExecutionerFemaleNPCTown",
|
||||
"Metadata/NPC/Four_Act1/EzomyteCivilianFemale01",
|
||||
|
|
@ -70,17 +84,31 @@
|
|||
"Metadata/NPC/Four_Act1/UnaAfterIronCount",
|
||||
"Metadata/NPC/Four_Act1/UnaHoodedOneInjured",
|
||||
"Metadata/NPC/League/Incursion/AlvaIncursionWild",
|
||||
"Metadata/Pet/BabyBossesHumans/BabyBrutus/BabyBrutus",
|
||||
"Metadata/Pet/BabyChimera/BabyChimera",
|
||||
"Metadata/Pet/BetaKiwis/BaronKiwi",
|
||||
"Metadata/Pet/BetaKiwis/FaridunKiwi",
|
||||
"Metadata/Pet/BookAndQuillPet/BookAndQuillPet_Abyss",
|
||||
"Metadata/Pet/FledglingBellcrow/FledglingBellcrow",
|
||||
"Metadata/Pet/LandSharkPet/LandSharkPet",
|
||||
"Metadata/Pet/OctopusParasite/OctopusParasiteCelestial",
|
||||
"Metadata/Pet/OrigamiPet/OrigamiPetBase",
|
||||
"Metadata/Pet/Phoenix/PhoenixPetBlue",
|
||||
"Metadata/Pet/Phoenix/PhoenixPetGreen",
|
||||
"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/Twister",
|
||||
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1",
|
||||
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1_CountKilled",
|
||||
"Metadata/Terrain/Doodads/Gallows/ClearfellBull2",
|
||||
"Metadata/Terrain/Doodads/Gallows/ClearfellBull2_CountKilled",
|
||||
"Metadata/Terrain/Gallows/Act1/1_2/Objects/CampsiteChest",
|
||||
"Metadata/Terrain/Gallows/Act1/1_2/Objects/CampsiteController",
|
||||
"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/CraftingBenchEzomyte",
|
||||
|
|
@ -88,6 +116,9 @@
|
|||
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBench_EnableRendering",
|
||||
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_DisableRendering",
|
||||
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_EnableRendering",
|
||||
"Metadata/Terrain/Tools/AudioTools/G1_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_Town/FurnaceFireAudio",
|
||||
"Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio"
|
||||
|
|
|
|||
11
offsets.json
11
offsets.json
|
|
@ -51,18 +51,13 @@
|
|||
"PositionXOffset": 312,
|
||||
"PositionYOffset": 316,
|
||||
"PositionZOffset": 320,
|
||||
"CameraOffset": 776,
|
||||
"CameraMatrixOffset": 416,
|
||||
"TerrainListOffset": 3264,
|
||||
"TerrainInline": true,
|
||||
"TerrainDimensionsOffset": 144,
|
||||
"TerrainWalkableGridOffset": 328,
|
||||
"TerrainBytesPerRowOffset": 424,
|
||||
"TerrainGridPtrOffset": 8,
|
||||
"SubTilesPerCell": 23,
|
||||
"InGameStateOffset": 0,
|
||||
"IngameDataOffset": 0,
|
||||
"TerrainDataOffset": 0,
|
||||
"NumColsOffset": 0,
|
||||
"NumRowsOffset": 0,
|
||||
"LayerMeleeOffset": 0,
|
||||
"BytesPerRowOffset": 0
|
||||
"SubTilesPerCell": 23
|
||||
}
|
||||
382
src/Automata.Memory/ComponentReader.cs
Normal file
382
src/Automata.Memory/ComponentReader.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
200
src/Automata.Memory/EntityReader.cs
Normal file
200
src/Automata.Memory/EntityReader.cs
Normal 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
|
|
@ -4,7 +4,7 @@ using Serilog;
|
|||
|
||||
namespace Automata.Memory;
|
||||
|
||||
public sealed class TerrainOffsets
|
||||
public sealed class GameOffsets
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
|
|
@ -24,8 +24,6 @@ public sealed class TerrainOffsets
|
|||
public int PatternResultAdjust { get; set; } = 0x18;
|
||||
|
||||
// ── 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>
|
||||
public int StatesBeginOffset { get; set; } = 0x48;
|
||||
/// <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;
|
||||
/// <summary>Offset from controller to active states vector begin/end pair (ExileCore: 0x20).</summary>
|
||||
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;
|
||||
/// <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;
|
||||
|
|
@ -46,7 +44,6 @@ public sealed class TerrainOffsets
|
|||
public int InGameStateDirectOffset { get; set; } = 0x210;
|
||||
|
||||
// ── 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>
|
||||
public int EscapeStateOffset { get; set; } = 0x20C;
|
||||
/// <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;
|
||||
|
||||
// ── 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>
|
||||
public int AreaLevelOffset { get; set; } = 0xC4;
|
||||
/// <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;
|
||||
/// <summary>AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr).</summary>
|
||||
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;
|
||||
/// <summary>Offset within StdMap to _Mysize (entity count). MSVC std::map: head(8) + size(8).</summary>
|
||||
public int EntityCountInternalOffset { get; set; } = 0x08;
|
||||
|
||||
// ── 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>
|
||||
public int EntityNodeLeftOffset { get; set; } = 0x00;
|
||||
/// <summary>Tree node → parent pointer.</summary>
|
||||
|
|
@ -97,7 +84,7 @@ public sealed class TerrainOffsets
|
|||
public int EntityFlagsOffset { get; set; } = 0x84;
|
||||
/// <summary>Entity → EntityDetailsPtr (Head/MainObject pointer, +0x08).</summary>
|
||||
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;
|
||||
|
||||
// ServerData → fields
|
||||
|
|
@ -105,14 +92,12 @@ public sealed class TerrainOffsets
|
|||
public int LocalPlayerOffset { get; set; } = 0x20;
|
||||
|
||||
// ── 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;
|
||||
/// <summary>Entity → ObjectHeader pointer (for alternative component lookup via name→index map). ExileCore: 0x08.</summary>
|
||||
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;
|
||||
/// <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;
|
||||
/// <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;
|
||||
|
|
@ -125,17 +110,11 @@ public sealed class TerrainOffsets
|
|||
/// <summary>Index of Render/Position component in entity's component list. -1 = unknown.</summary>
|
||||
public int RenderComponentIndex { get; set; } = -1;
|
||||
|
||||
// ── Life component (via direct chain from AreaInstance) ──
|
||||
// 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
|
||||
// ── Life component ──
|
||||
/// <summary>First offset from AreaInstance to reach Life component (AreaInstance → ptr). 0 = use entity component list instead.</summary>
|
||||
public int LifeComponentOffset1 { get; set; } = 0x420;
|
||||
/// <summary>Second offset from intermediate pointer to Life component (ptr → Life).</summary>
|
||||
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 LifeManaOffset { get; set; } = 0x1F8;
|
||||
public int LifeEsOffset { get; set; } = 0x230;
|
||||
|
|
@ -143,22 +122,17 @@ public sealed class TerrainOffsets
|
|||
public int VitalTotalOffset { get; set; } = 0x2C;
|
||||
|
||||
// ── 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 PositionYOffset { get; set; } = 0x13C;
|
||||
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) ──
|
||||
// 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>
|
||||
public int TerrainListOffset { get; set; } = 0xCC0;
|
||||
/// <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 SubTilesPerCell { get; set; } = 23;
|
||||
|
||||
// Legacy terrain offsets (used by TerrainReader)
|
||||
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)
|
||||
public static GameOffsets Load(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Information("Offsets file not found at '{Path}', using defaults", path);
|
||||
var defaults = new TerrainOffsets();
|
||||
var defaults = new GameOffsets();
|
||||
defaults.Save(path);
|
||||
return defaults;
|
||||
}
|
||||
|
|
@ -195,11 +160,11 @@ public sealed class TerrainOffsets
|
|||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var offsets = JsonSerializer.Deserialize<TerrainOffsets>(json, JsonOptions);
|
||||
var offsets = JsonSerializer.Deserialize<GameOffsets>(json, JsonOptions);
|
||||
if (offsets is null)
|
||||
{
|
||||
Log.Warning("Failed to deserialize '{Path}', using defaults", path);
|
||||
return new TerrainOffsets();
|
||||
return new GameOffsets();
|
||||
}
|
||||
Log.Information("Loaded offsets from '{Path}'", path);
|
||||
return offsets;
|
||||
|
|
@ -207,7 +172,7 @@ public sealed class TerrainOffsets
|
|||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error loading offsets from '{Path}'", path);
|
||||
return new TerrainOffsets();
|
||||
return new GameOffsets();
|
||||
}
|
||||
}
|
||||
|
||||
203
src/Automata.Memory/GameStateReader.cs
Normal file
203
src/Automata.Memory/GameStateReader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
68
src/Automata.Memory/GameStateSnapshot.cs
Normal file
68
src/Automata.Memory/GameStateSnapshot.cs
Normal 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;
|
||||
}
|
||||
35
src/Automata.Memory/MemoryContext.cs
Normal file
35
src/Automata.Memory/MemoryContext.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2625
src/Automata.Memory/MemoryDiagnostics.cs
Normal file
2625
src/Automata.Memory/MemoryDiagnostics.cs
Normal file
File diff suppressed because it is too large
Load diff
110
src/Automata.Memory/MsvcStringReader.cs
Normal file
110
src/Automata.Memory/MsvcStringReader.cs
Normal 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 <= 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 <= 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);
|
||||
}
|
||||
}
|
||||
81
src/Automata.Memory/RttiResolver.cs
Normal file
81
src/Automata.Memory/RttiResolver.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,117 +2,126 @@ using Serilog;
|
|||
|
||||
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; }
|
||||
public int Height { get; }
|
||||
public byte[] Data { get; }
|
||||
private readonly MemoryContext _ctx;
|
||||
private uint _cachedTerrainAreaHash;
|
||||
private WalkabilityGrid? _cachedTerrain;
|
||||
private bool _wasLoading;
|
||||
|
||||
public WalkabilityGrid(int width, int height, byte[] data)
|
||||
public TerrainReader(MemoryContext ctx)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
Data = data;
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
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)
|
||||
return false;
|
||||
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;
|
||||
_cachedTerrain = null;
|
||||
_cachedTerrainAreaHash = 0;
|
||||
}
|
||||
|
||||
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();
|
||||
_memory = ProcessMemory.Attach(_offsets.ProcessName);
|
||||
if (_memory is null)
|
||||
return false;
|
||||
var mem = _ctx.Memory;
|
||||
var offsets = _ctx.Offsets;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_offsets.GameStatePattern))
|
||||
if (!offsets.TerrainInline)
|
||||
{
|
||||
Log.Warning("GameStatePattern is empty — offsets not yet configured for POE2");
|
||||
return false;
|
||||
// Pointer-based: AreaInstance → TerrainList → first terrain → dimensions
|
||||
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);
|
||||
_gameStateBase = _scanner.FindPatternRip(_offsets.GameStatePattern);
|
||||
// Inline mode: TerrainStruct is inline at AreaInstance + TerrainListOffset
|
||||
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");
|
||||
return false;
|
||||
_cachedTerrain = null;
|
||||
_cachedTerrainAreaHash = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Information("GameState base: 0x{Address:X}", _gameStateBase);
|
||||
return true;
|
||||
}
|
||||
|
||||
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)
|
||||
// Loading just finished — clear cache to force a fresh read
|
||||
if (_wasLoading)
|
||||
{
|
||||
Log.Debug("Terrain pointer chain returned null");
|
||||
return null;
|
||||
_cachedTerrain = null;
|
||||
_cachedTerrainAreaHash = 0;
|
||||
}
|
||||
|
||||
var numCols = _memory.Read<int>(terrainBase + _offsets.NumColsOffset);
|
||||
var numRows = _memory.Read<int>(terrainBase + _offsets.NumRowsOffset);
|
||||
var bytesPerRow = _memory.Read<int>(terrainBase + _offsets.BytesPerRowOffset);
|
||||
|
||||
if (numCols <= 0 || numRows <= 0 || bytesPerRow <= 0)
|
||||
// Return cached grid if same area
|
||||
if (_cachedTerrain != null && _cachedTerrainAreaHash == snap.AreaHash)
|
||||
{
|
||||
Log.Warning("Invalid terrain dimensions: {Cols}x{Rows}, bytesPerRow={Bpr}", numCols, numRows, bytesPerRow);
|
||||
return null;
|
||||
snap.Terrain = _cachedTerrain;
|
||||
snap.TerrainWalkablePercent = CalcWalkablePercent(_cachedTerrain);
|
||||
return;
|
||||
}
|
||||
|
||||
var gridWidth = numCols * _offsets.SubTilesPerCell;
|
||||
var gridHeight = numRows * _offsets.SubTilesPerCell;
|
||||
// Read GridWalkableData StdVector (begin/end/cap pointers)
|
||||
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 layerPtr = _memory.ReadPointer(terrainBase + _offsets.LayerMeleeOffset);
|
||||
if (layerPtr == 0)
|
||||
{
|
||||
Log.Warning("Melee layer pointer is null");
|
||||
return null;
|
||||
}
|
||||
var gridDataSize = (int)(gridEnd - gridBegin);
|
||||
if (gridDataSize <= 0 || gridDataSize > 16 * 1024 * 1024)
|
||||
return;
|
||||
|
||||
// Read raw terrain data
|
||||
var rawSize = bytesPerRow * gridHeight;
|
||||
var rawData = _memory.ReadBytes(layerPtr, rawSize);
|
||||
var bytesPerRow = mem.Read<int>(terrainBase + offsets.TerrainBytesPerRowOffset);
|
||||
if (bytesPerRow <= 0 || bytesPerRow > 0x10000)
|
||||
return;
|
||||
|
||||
var gridWidth = cols * offsets.SubTilesPerCell;
|
||||
var gridHeight = rows * offsets.SubTilesPerCell;
|
||||
|
||||
var rawData = mem.ReadBytes(gridBegin, gridDataSize);
|
||||
if (rawData is null)
|
||||
{
|
||||
Log.Warning("Failed to read terrain data ({Size} bytes)", rawSize);
|
||||
return null;
|
||||
}
|
||||
return;
|
||||
|
||||
// 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];
|
||||
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);
|
||||
return new WalkabilityGrid(gridWidth, gridHeight, data);
|
||||
var grid = 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;
|
||||
_disposed = true;
|
||||
_memory?.Dispose();
|
||||
_wasLoading = isLoading;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
src/Automata.Memory/WalkabilityGrid.cs
Normal file
22
src/Automata.Memory/WalkabilityGrid.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ public partial class App : Application
|
|||
services.AddSingleton<AtlasViewModel>();
|
||||
services.AddSingleton<CraftingViewModel>();
|
||||
services.AddSingleton<MemoryViewModel>();
|
||||
services.AddSingleton<RobotoViewModel>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
|
|
@ -95,6 +96,7 @@ public partial class App : Application
|
|||
mainVm.AtlasVm = provider.GetRequiredService<AtlasViewModel>();
|
||||
mainVm.CraftingVm = provider.GetRequiredService<CraftingViewModel>();
|
||||
mainVm.MemoryVm = provider.GetRequiredService<MemoryViewModel>();
|
||||
mainVm.RobotoVm = provider.GetRequiredService<RobotoViewModel>();
|
||||
|
||||
var window = new MainWindow { DataContext = mainVm };
|
||||
window.SetConfigStore(store);
|
||||
|
|
@ -108,6 +110,7 @@ public partial class App : Application
|
|||
{
|
||||
overlay.Shutdown();
|
||||
mainVm.Shutdown();
|
||||
mainVm.RobotoVm?.Shutdown();
|
||||
await bot.DisposeAsync();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
<ProjectReference Include="..\Automata.Log\Automata.Log.csproj" />
|
||||
<ProjectReference Include="..\Automata.Inventory\Automata.Inventory.csproj" />
|
||||
<ProjectReference Include="..\Automata.Memory\Automata.Memory.csproj" />
|
||||
<ProjectReference Include="..\Roboto.Engine\Roboto.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
<!-- Sidekick data files (English only) -->
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ public sealed class D2dOverlay
|
|||
|
||||
_layers.Add(new D2dEnemyBoxLayer(ctx));
|
||||
_layers.Add(new D2dLootLabelLayer(ctx));
|
||||
_layers.Add(new D2dEntityLabelLayer(ctx));
|
||||
_layers.Add(new D2dHudInfoLayer());
|
||||
_layers.Add(new D2dDebugTextLayer());
|
||||
|
||||
|
|
|
|||
71
src/Automata.Ui/Overlay/Layers/D2dEntityLabelLayer.cs
Normal file
71
src/Automata.Ui/Overlay/Layers/D2dEntityLabelLayer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -183,6 +183,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||
public AtlasViewModel? AtlasVm { get; set; }
|
||||
public CraftingViewModel? CraftingVm { get; set; }
|
||||
public MemoryViewModel? MemoryVm { get; set; }
|
||||
public RobotoViewModel? RobotoVm { get; set; }
|
||||
|
||||
partial void OnBotModeChanged(BotMode value)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -789,7 +789,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
RawResult = _reader.ReadAddress(RawAddress, RawOffsets, RawType);
|
||||
RawResult = _reader.Diagnostics!.ReadAddress(RawAddress, RawOffsets, RawType);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -804,7 +804,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size))
|
||||
size = 0x400;
|
||||
|
||||
ScanResult = _reader.ScanRegion(ScanAddress, ScanOffsets, size);
|
||||
ScanResult = _reader.Diagnostics!.ScanRegion(ScanAddress, ScanOffsets, size);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -816,7 +816,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanAllStates();
|
||||
ScanResult = _reader.Diagnostics!.ScanAllStates();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -828,7 +828,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ProbeInGameState();
|
||||
ScanResult = _reader.Diagnostics!.ProbeInGameState();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -844,7 +844,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
int.TryParse(VitalMana, out var mana);
|
||||
int.TryParse(VitalEs, out var es);
|
||||
|
||||
ScanResult = _reader.ScanComponents(hp, mana, es);
|
||||
ScanResult = _reader.Diagnostics!.ScanComponents(hp, mana, es);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -860,7 +860,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
int.TryParse(VitalMana, out var mana);
|
||||
int.TryParse(VitalEs, out var es);
|
||||
|
||||
ScanResult = _reader.DeepScanVitals(hp, mana, es);
|
||||
ScanResult = _reader.Diagnostics!.DeepScanVitals(hp, mana, es);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -872,7 +872,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.DiagnoseVitals();
|
||||
ScanResult = _reader.Diagnostics!.DiagnoseVitals();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -884,7 +884,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanPosition();
|
||||
ScanResult = _reader.Diagnostics!.ScanPosition();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -896,7 +896,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.DiagnoseEntity();
|
||||
ScanResult = _reader.Diagnostics!.DiagnoseEntity();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -908,7 +908,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanEntities();
|
||||
ScanResult = _reader.Diagnostics!.ScanEntities();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -920,7 +920,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanComponentLookup();
|
||||
ScanResult = _reader.Diagnostics!.ScanComponentLookup();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -932,7 +932,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanAreaLoadingState();
|
||||
ScanResult = _reader.Diagnostics!.ScanAreaLoadingState();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -944,7 +944,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanMemoryDiff();
|
||||
ScanResult = _reader.Diagnostics!.ScanMemoryDiff();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -956,7 +956,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanActiveStatesVector();
|
||||
ScanResult = _reader.Diagnostics!.ScanActiveStatesVector();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -968,7 +968,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanTerrain();
|
||||
ScanResult = _reader.Diagnostics!.ScanTerrain();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -983,6 +983,30 @@ public partial class MemoryViewModel : ObservableObject
|
|||
if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size))
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
269
src/Automata.Ui/ViewModels/RobotoViewModel.cs
Normal file
269
src/Automata.Ui/ViewModels/RobotoViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -766,6 +766,10 @@
|
|||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Scan ActiveVec" Command="{Binding ScanActiveVecExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="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>
|
||||
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
|
||||
FontSize="10" Foreground="#e6edf3" Background="#0d1117"
|
||||
|
|
@ -799,6 +803,132 @@
|
|||
</DockPanel>
|
||||
</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 ========== -->
|
||||
<TabItem Header="Debug">
|
||||
<ScrollViewer DataContext="{Binding DebugVm}" Margin="0,6,0,0">
|
||||
|
|
|
|||
81
src/Roboto.Core/ActionQueue.cs
Normal file
81
src/Roboto.Core/ActionQueue.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/Roboto.Core/Actions.cs
Normal file
26
src/Roboto.Core/Actions.cs
Normal 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);
|
||||
40
src/Roboto.Core/BotConfig.cs
Normal file
40
src/Roboto.Core/BotConfig.cs
Normal 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
9
src/Roboto.Core/Buff.cs
Normal 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; }
|
||||
}
|
||||
42
src/Roboto.Core/EntitySnapshot.cs
Normal file
42
src/Roboto.Core/EntitySnapshot.cs
Normal 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
20
src/Roboto.Core/Enums.cs
Normal 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;
|
||||
}
|
||||
10
src/Roboto.Core/FlaskState.cs
Normal file
10
src/Roboto.Core/FlaskState.cs
Normal 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; }
|
||||
}
|
||||
28
src/Roboto.Core/GameState.cs
Normal file
28
src/Roboto.Core/GameState.cs
Normal 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; } = [];
|
||||
}
|
||||
20
src/Roboto.Core/GroundEffect.cs
Normal file
20
src/Roboto.Core/GroundEffect.cs
Normal 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,
|
||||
}
|
||||
17
src/Roboto.Core/IInputController.cs
Normal file
17
src/Roboto.Core/IInputController.cs
Normal 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();
|
||||
}
|
||||
9
src/Roboto.Core/IMemoryProvider.cs
Normal file
9
src/Roboto.Core/IMemoryProvider.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace Roboto.Core;
|
||||
|
||||
public interface IMemoryProvider
|
||||
{
|
||||
bool IsAttached { get; }
|
||||
bool Attach();
|
||||
void Detach();
|
||||
GameState ReadGameState(GameState? previous);
|
||||
}
|
||||
9
src/Roboto.Core/ISystem.cs
Normal file
9
src/Roboto.Core/ISystem.cs
Normal 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);
|
||||
}
|
||||
30
src/Roboto.Core/PlayerState.cs
Normal file
30
src/Roboto.Core/PlayerState.cs
Normal 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; } = [];
|
||||
}
|
||||
7
src/Roboto.Core/Roboto.Core.csproj
Normal file
7
src/Roboto.Core/Roboto.Core.csproj
Normal 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>
|
||||
12
src/Roboto.Core/SkillState.cs
Normal file
12
src/Roboto.Core/SkillState.cs
Normal 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;
|
||||
}
|
||||
14
src/Roboto.Core/ThreatMap.cs
Normal file
14
src/Roboto.Core/ThreatMap.cs
Normal 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; } // 300–600
|
||||
public int FarRange { get; init; } // 600–1200
|
||||
public float ClosestDistance { get; init; } = float.MaxValue;
|
||||
public Vector2 ThreatCentroid { get; init; }
|
||||
public bool HasRareOrUnique { get; init; }
|
||||
}
|
||||
15
src/Roboto.Core/WalkabilitySnapshot.cs
Normal file
15
src/Roboto.Core/WalkabilitySnapshot.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
81
src/Roboto.Data/GameStateEnricher.cs
Normal file
81
src/Roboto.Data/GameStateEnricher.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/Roboto.Data/Roboto.Data.csproj
Normal file
13
src/Roboto.Data/Roboto.Data.csproj
Normal 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>
|
||||
99
src/Roboto.Input/Humanizer.cs
Normal file
99
src/Roboto.Input/Humanizer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
139
src/Roboto.Input/InterceptionInputController.cs
Normal file
139
src/Roboto.Input/InterceptionInputController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/Roboto.Input/Roboto.Input.csproj
Normal file
17
src/Roboto.Input/Roboto.Input.csproj
Normal 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>
|
||||
56
src/Roboto.Input/ScanCodes.cs
Normal file
56
src/Roboto.Input/ScanCodes.cs
Normal 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;
|
||||
}
|
||||
BIN
src/Roboto.Input/interception.dll
Normal file
BIN
src/Roboto.Input/interception.dll
Normal file
Binary file not shown.
303
src/Roboto.Navigation/NavigationController.cs
Normal file
303
src/Roboto.Navigation/NavigationController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
153
src/Roboto.Navigation/PathFinder.cs
Normal file
153
src/Roboto.Navigation/PathFinder.cs
Normal 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);
|
||||
}
|
||||
13
src/Roboto.Navigation/Roboto.Navigation.csproj
Normal file
13
src/Roboto.Navigation/Roboto.Navigation.csproj
Normal 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>
|
||||
15
src/Roboto.Systems/CombatSystem.cs
Normal file
15
src/Roboto.Systems/CombatSystem.cs
Normal 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
|
||||
}
|
||||
}
|
||||
15
src/Roboto.Systems/LootSystem.cs
Normal file
15
src/Roboto.Systems/LootSystem.cs
Normal 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
|
||||
}
|
||||
}
|
||||
46
src/Roboto.Systems/MovementSystem.cs
Normal file
46
src/Roboto.Systems/MovementSystem.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
29
src/Roboto.Systems/NavigationSystem.cs
Normal file
29
src/Roboto.Systems/NavigationSystem.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
42
src/Roboto.Systems/ResourceSystem.cs
Normal file
42
src/Roboto.Systems/ResourceSystem.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Roboto.Systems/Roboto.Systems.csproj
Normal file
13
src/Roboto.Systems/Roboto.Systems.csproj
Normal 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>
|
||||
69
src/Roboto.Systems/ThreatSystem.cs
Normal file
69
src/Roboto.Systems/ThreatSystem.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
terrain.png
BIN
terrain.png
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 34 KiB |
Loading…
Add table
Add a link
Reference in a new issue