diff --git a/src/Automata.Ui/ViewModels/MemoryViewModel.cs b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
index 9345e10..af8a748 100644
--- a/src/Automata.Ui/ViewModels/MemoryViewModel.cs
+++ b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
@@ -7,7 +7,7 @@ using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Roboto.Memory;
-using Roboto.Memory.States;
+using Roboto.Memory.Objects;
namespace Automata.Ui.ViewModels;
diff --git a/src/Roboto.GameOffsets/Components/Actor.cs b/src/Roboto.GameOffsets/Components/Actor.cs
index fd5971e..e95c6cb 100644
--- a/src/Roboto.GameOffsets/Components/Actor.cs
+++ b/src/Roboto.GameOffsets/Components/Actor.cs
@@ -1,6 +1,3 @@
-using System.Runtime.InteropServices;
-using Roboto.GameOffsets.Natives;
-
namespace Roboto.GameOffsets.Components;
///
@@ -14,76 +11,3 @@ public static class ActorOffsets
public const int CooldownsVector = 0xB18;
public const int DeployedEntitiesVector = 0xC10;
}
-
-///
-/// An entry in the ActiveSkills vector: shared_ptr pair (0x10 bytes).
-/// Follow ActiveSkillPtr (first pointer) for skill details.
-///
-[StructLayout(LayoutKind.Sequential, Pack = 1)]
-public struct ActiveSkillEntry
-{
- public nint ActiveSkillPtr;
- public nint ControlBlockPtr; // shared_ptr control block, not used
-}
-
-///
-/// Details of an active skill. The shared_ptr in the ActiveSkills vector points
-/// 0x10 bytes into the object (past vtable + UseStage/CastType), so we read from
-/// ActiveSkillPtr - 0x10 and all offsets are relative to the true object base.
-///
-[StructLayout(LayoutKind.Explicit, Pack = 1)]
-public struct ActiveSkillDetails
-{
- [FieldOffset(0x00)] public nint Vtable;
- [FieldOffset(0x08)] public int UseStage;
- [FieldOffset(0x0C)] public int CastType;
- [FieldOffset(0x20)] public uint UnknownIdAndEquipmentInfo;
- [FieldOffset(0x28)] public nint GrantedEffectsPerLevelDatRow;
- [FieldOffset(0x30)] public nint ActiveSkillsDatPtr;
- [FieldOffset(0x40)] public nint GrantedEffectStatSetsPerLevelDatRow;
- [FieldOffset(0xA8)] public int TotalUses;
- [FieldOffset(0xB8)] public int TotalCooldownTimeInMs;
-}
-
-///
-/// Cooldown state for a skill. Entries in Actor+0xB18 vector.
-/// From ExileCore2 GameOffsets.Objects.Components.ActiveSkillCooldown.
-///
-[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x48)]
-public struct ActiveSkillCooldown
-{
- [FieldOffset(0x08)] public int ActiveSkillsDatId;
- [FieldOffset(0x10)] public StdVector CooldownsList; // 0x10-byte entries
- [FieldOffset(0x30)] public int MaxUses;
- [FieldOffset(0x34)] public int TotalCooldownTimeInMs;
- [FieldOffset(0x3C)] public uint UnknownIdAndEquipmentInfo;
-
- /// Number of active cooldown timer entries.
- public readonly int TotalActiveCooldowns => (int)CooldownsList.TotalElements(0x10);
-
- /// True if all uses are on cooldown.
- public readonly bool CannotBeUsed => TotalActiveCooldowns >= MaxUses;
-}
-
-/// Vaal soul tracking.
-[StructLayout(LayoutKind.Explicit, Pack = 1)]
-public struct VaalSoulStructure
-{
- [FieldOffset(0x00)] public nint ActiveSkillsDatPtr;
- [FieldOffset(0x08)] public nint UselessPtr;
- [FieldOffset(0x10)] public int RequiredSouls;
- [FieldOffset(0x14)] public int CurrentSouls;
-
- public readonly bool CannotBeUsed => CurrentSouls < RequiredSouls;
-}
-
-/// A deployed entity (totem, mine, etc.).
-[StructLayout(LayoutKind.Sequential, Pack = 1)]
-public struct DeployedEntityStructure
-{
- public int EntityId;
- public int ActiveSkillsDatId;
- public int DeployedObjectType;
- public int PAD_0x014;
- public int Counter;
-}
diff --git a/src/Roboto.GameOffsets/Components/ActorDeployedEntity.cs b/src/Roboto.GameOffsets/Components/ActorDeployedEntity.cs
new file mode 100644
index 0000000..cfd3811
--- /dev/null
+++ b/src/Roboto.GameOffsets/Components/ActorDeployedEntity.cs
@@ -0,0 +1,14 @@
+using System.Runtime.InteropServices;
+
+namespace Roboto.GameOffsets.Components;
+
+/// A deployed entity (totem, mine, etc.).
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public struct ActorDeployedEntity
+{
+ public int EntityId;
+ public int ActiveSkillsDatId;
+ public int DeployedObjectType;
+ public int PAD_0x014;
+ public int Counter;
+}
diff --git a/src/Roboto.GameOffsets/Components/ActorSkill.cs b/src/Roboto.GameOffsets/Components/ActorSkill.cs
new file mode 100644
index 0000000..5000be5
--- /dev/null
+++ b/src/Roboto.GameOffsets/Components/ActorSkill.cs
@@ -0,0 +1,33 @@
+using System.Runtime.InteropServices;
+
+namespace Roboto.GameOffsets.Components;
+
+///
+/// An entry in the ActiveSkills vector: shared_ptr pair (0x10 bytes).
+/// Follow ActiveSkillPtr (first pointer) for skill details.
+///
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public struct ActorSkillEntry
+{
+ public nint ActiveSkillPtr;
+ public nint ControlBlockPtr; // shared_ptr control block, not used
+}
+
+///
+/// Details of an active skill. The shared_ptr in the ActiveSkills vector points
+/// 0x10 bytes into the object (past vtable + UseStage/CastType), so we read from
+/// ActiveSkillPtr - 0x10 and all offsets are relative to the true object base.
+///
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public struct ActorSkillDetails
+{
+ [FieldOffset(0x00)] public nint Vtable;
+ [FieldOffset(0x08)] public int UseStage;
+ [FieldOffset(0x0C)] public int CastType;
+ [FieldOffset(0x20)] public uint UnknownIdAndEquipmentInfo;
+ [FieldOffset(0x28)] public nint GrantedEffectsPerLevelDatRow;
+ [FieldOffset(0x30)] public nint ActiveSkillsDatPtr;
+ [FieldOffset(0x40)] public nint GrantedEffectStatSetsPerLevelDatRow;
+ [FieldOffset(0xA8)] public int TotalUses;
+ [FieldOffset(0xB8)] public int TotalCooldownTimeInMs;
+}
diff --git a/src/Roboto.GameOffsets/Components/ActorSkillCooldown.cs b/src/Roboto.GameOffsets/Components/ActorSkillCooldown.cs
new file mode 100644
index 0000000..d0f242c
--- /dev/null
+++ b/src/Roboto.GameOffsets/Components/ActorSkillCooldown.cs
@@ -0,0 +1,24 @@
+using System.Runtime.InteropServices;
+using Roboto.GameOffsets.Natives;
+
+namespace Roboto.GameOffsets.Components;
+
+///
+/// Cooldown state for a skill. Entries in Actor+0xB18 vector.
+/// From ExileCore2 GameOffsets.Objects.Components.ActiveSkillCooldown.
+///
+[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x48)]
+public struct ActorSkillCooldown
+{
+ [FieldOffset(0x08)] public int ActiveSkillsDatId;
+ [FieldOffset(0x10)] public StdVector CooldownsList; // 0x10-byte entries
+ [FieldOffset(0x30)] public int MaxUses;
+ [FieldOffset(0x34)] public int TotalCooldownTimeInMs;
+ [FieldOffset(0x3C)] public uint UnknownIdAndEquipmentInfo;
+
+ /// Number of active cooldown timer entries.
+ public readonly int TotalActiveCooldowns => (int)CooldownsList.TotalElements(0x10);
+
+ /// True if all uses are on cooldown.
+ public readonly bool CannotBeUsed => TotalActiveCooldowns >= MaxUses;
+}
diff --git a/src/Roboto.GameOffsets/Components/ActorVaalSkill.cs b/src/Roboto.GameOffsets/Components/ActorVaalSkill.cs
new file mode 100644
index 0000000..3cdd359
--- /dev/null
+++ b/src/Roboto.GameOffsets/Components/ActorVaalSkill.cs
@@ -0,0 +1,15 @@
+using System.Runtime.InteropServices;
+
+namespace Roboto.GameOffsets.Components;
+
+/// Vaal soul tracking.
+[StructLayout(LayoutKind.Explicit, Pack = 1)]
+public struct ActorVaalSoulStructure
+{
+ [FieldOffset(0x00)] public nint ActiveSkillsDatPtr;
+ [FieldOffset(0x08)] public nint UselessPtr;
+ [FieldOffset(0x10)] public int RequiredSouls;
+ [FieldOffset(0x14)] public int CurrentSouls;
+
+ public readonly bool CannotBeUsed => CurrentSouls < RequiredSouls;
+}
diff --git a/src/Roboto.GameOffsets/States/WorldData.cs b/src/Roboto.GameOffsets/States/WorldData.cs
index 13f0ba4..badd798 100644
--- a/src/Roboto.GameOffsets/States/WorldData.cs
+++ b/src/Roboto.GameOffsets/States/WorldData.cs
@@ -14,14 +14,16 @@ public struct WorldData
[FieldOffset(0xA0)] public nint CameraPtr;
}
-/// Details about the current world area (act, waypoint, etc.).
-[StructLayout(LayoutKind.Explicit, Size = 0x20)]
+///
+/// WorldAreaDetails pointer leads to an AreaTemplate struct.
+/// Fields are read via configurable offsets in GameOffsets (AreaTemplate* section).
+/// ExileCore layout: +0x00 RawName (wchar*), +0x08 Name (wchar*), +0x10 Act (int),
+/// +0x14 IsTown (byte), +0x15 HasWaypoint (byte), +0x26 MonsterLevel (int), +0x2A WorldAreaId (int).
+///
+[StructLayout(LayoutKind.Explicit, Size = 0x30)]
public struct WorldAreaDetails
{
- [FieldOffset(0x00)] public nint NamePtr;
- [FieldOffset(0x08)] public nint ActPtr;
- [FieldOffset(0x10)] public int IsTown;
- [FieldOffset(0x14)] public int HasWaypoint;
+ // All fields read dynamically via GameOffsets — this struct is kept for documentation.
}
/// Camera structure — contains the world-to-screen projection matrix.
diff --git a/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs b/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs
index 0004d1d..68ea396 100644
--- a/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs
+++ b/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs
@@ -3,6 +3,7 @@ using System.Drawing.Imaging;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
+using Roboto.Memory.Objects;
using Serilog;
namespace Roboto.Memory;
@@ -16,7 +17,7 @@ public sealed class MemoryDiagnostics
private readonly MemoryContext _ctx;
private readonly GameStateReader _stateReader;
private readonly ComponentReader _components;
- private readonly EntityReader _entities;
+ private readonly EntityList _entities;
private readonly MsvcStringReader _strings;
private readonly RttiResolver _rtti;
@@ -33,7 +34,7 @@ public sealed class MemoryDiagnostics
MemoryContext ctx,
GameStateReader stateReader,
ComponentReader components,
- EntityReader entities,
+ EntityList entities,
MsvcStringReader strings,
RttiResolver rtti)
{
@@ -3713,7 +3714,9 @@ public sealed class MemoryDiagnostics
}
// Also try to read a few nearby monsters' Actor components for comparison
- _entities.ReadEntities(snap, ingameData);
+ _entities.ExpectedCount = snap.EntityCount;
+ _entities.Update(ingameData);
+ snap.Entities = _entities.Entities;
if (snap.Entities is { Count: > 0 })
{
var monsterCount = 0;
diff --git a/src/Roboto.Memory/GameMemoryReader.cs b/src/Roboto.Memory/GameMemoryReader.cs
index 4aa2cf3..24f1982 100644
--- a/src/Roboto.Memory/GameMemoryReader.cs
+++ b/src/Roboto.Memory/GameMemoryReader.cs
@@ -1,5 +1,5 @@
using System.Numerics;
-using Roboto.Memory.States;
+using Roboto.Memory.Objects;
using Serilog;
namespace Roboto.Memory;
@@ -35,12 +35,8 @@ public class GameMemoryReader : IDisposable
private nint _lastInGameState;
private nint _lastController;
private ComponentReader? _components;
- private EntityReader? _entities;
- private TerrainReader? _terrain;
private MsvcStringReader? _strings;
private RttiResolver? _rtti;
- private SkillReader? _skills;
- private QuestReader? _quests;
private QuestNameLookup? _questNames;
public ObjectRegistry Registry => _registry;
@@ -92,18 +88,19 @@ public class GameMemoryReader : IDisposable
Log.Information("GameState base (manual): 0x{Address:X}", _ctx.GameStateBase);
}
- // Create sub-readers
- _gameStates = new GameStates(_ctx);
+ // Create infrastructure
_strings = new MsvcStringReader(_ctx);
_rtti = new RttiResolver(_ctx);
_stateReader = new GameStateReader(_ctx);
_components = new ComponentReader(_ctx, _strings);
- _entities = new EntityReader(_ctx, _components, _strings);
- _terrain = new TerrainReader(_ctx);
- _skills = new SkillReader(_ctx, _components, _strings);
_questNames ??= LoadQuestNames();
- _quests = new QuestReader(_ctx, _strings, _questNames);
- Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
+
+ // Hierarchical state tree — owns EntityList, PlayerSkills, QuestFlags, Terrain
+ _gameStates = new GameStates(_ctx, _components, _strings, _questNames);
+
+ // Diagnostics uses the EntityList from the hierarchy
+ var entityList = _gameStates.InGame.AreaInstance.EntityList;
+ Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, entityList, _strings, _rtti);
return true;
}
@@ -115,12 +112,8 @@ public class GameMemoryReader : IDisposable
_gameStates = null;
_stateReader = null;
_components = null;
- _entities = null;
- _terrain = null;
_strings = null;
_rtti = null;
- _skills = null;
- _quests = null;
// _questNames intentionally kept — reloaded only once
Diagnostics = null;
}
@@ -169,11 +162,17 @@ public class GameMemoryReader : IDisposable
try
{
- // Hierarchical state read — resolves controller, state slots, cascades to children
+ // Hierarchical state read — resolves controller, state slots, cascades to all children
var gs = _gameStates!;
+
+ // Set loading state on terrain before cascade
+ gs.InGame.AreaInstance.SetLoadingState(gs.AreaLoading.IsLoading);
+
if (!gs.Update())
return snap;
+ var ai = gs.InGame.AreaInstance;
+
// Populate snapshot from state hierarchy
snap.ControllerPtr = gs.ControllerPtr;
snap.StatesCount = gs.StatesCount;
@@ -182,18 +181,31 @@ public class GameMemoryReader : IDisposable
snap.InGameStatePtr = gs.InGame.Address;
snap.IsLoading = gs.AreaLoading.IsLoading;
snap.IsEscapeOpen = gs.InGame.IsEscapeOpen;
- snap.AreaInstancePtr = gs.InGame.AreaInstance.Address;
- snap.ServerDataPtr = gs.InGame.AreaInstance.ServerDataPtr;
- snap.LocalPlayerPtr = gs.InGame.AreaInstance.LocalPlayerPtr;
- snap.EntityCount = gs.InGame.AreaInstance.EntityCount;
+ snap.AreaInstancePtr = ai.Address;
+ snap.ServerDataPtr = ai.ServerDataPtr;
+ snap.LocalPlayerPtr = ai.LocalPlayerPtr;
+ snap.EntityCount = ai.EntityCount;
// Area level — prefer hierarchical read, keep static offset as fallback
- var areaLevel = gs.InGame.AreaInstance.AreaLevel;
+ var areaLevel = ai.AreaLevel;
if (areaLevel > 0)
snap.AreaLevel = areaLevel;
- snap.AreaHash = gs.InGame.AreaInstance.AreaHash;
+ snap.AreaHash = ai.AreaHash;
- // Camera matrix from WorldDataState
+ // Area template from WorldData
+ var at = gs.InGame.WorldData.AreaTemplate;
+ if (at.IsValid)
+ {
+ snap.AreaRawName = at.RawName;
+ snap.AreaName = at.Name;
+ snap.AreaAct = at.Act;
+ snap.AreaIsTown = at.IsTown;
+ snap.AreaHasWaypoint = at.HasWaypoint;
+ snap.AreaMonsterLevel = at.MonsterLevel;
+ snap.WorldAreaId = at.WorldAreaId;
+ }
+
+ // Camera matrix from WorldData
if (gs.InGame.WorldData.CameraMatrix.HasValue)
{
snap.CameraMatrix = gs.InGame.WorldData.CameraMatrix;
@@ -201,7 +213,6 @@ public class GameMemoryReader : IDisposable
}
else
{
- // Fallback: direct camera read (inline or pointer-based)
ReadCameraMatrix(snap, gs.InGame.Address);
}
@@ -210,53 +221,46 @@ public class GameMemoryReader : IDisposable
// Diagnostic state slots — GameStateReader still used for MemoryDiagnostics compat
_stateReader!.ReadStateSlots(snap);
-
- // Loading/escape overrides from GameStateReader (active states vector method)
_stateReader.ReadIsLoading(snap);
_stateReader.ReadEscapeState(snap);
// Reconcile CurrentGameState with reliable loading/escape detection
if (snap.IsLoading)
- snap.CurrentGameState = States.GameStateType.AreaLoadingState;
+ snap.CurrentGameState = GameStateType.AreaLoadingState;
else if (snap.IsEscapeOpen)
- snap.CurrentGameState = States.GameStateType.EscapeState;
+ snap.CurrentGameState = GameStateType.EscapeState;
- var ingameData = gs.InGame.AreaInstance.Address;
- if (ingameData != 0)
+ if (ai.Address != 0)
{
- // Entity list
- if (snap.EntityCount > 0)
- _entities!.ReadEntities(snap, ingameData);
+ // Entities — read from hierarchy
+ snap.Entities = ai.EntityList.Entities;
- // Player vitals & position — ECS
+ // Player vitals & position — still via ComponentReader (ECS)
if (snap.LocalPlayerPtr != 0)
{
if (snap.LocalPlayerPtr != _components!.LastLocalPlayer)
- _terrain!.InvalidateCache();
+ ai.InvalidateTerrainCache();
_components.InvalidateCaches(snap.LocalPlayerPtr);
_components.ReadPlayerVitals(snap);
_components.ReadPlayerPosition(snap);
snap.CharacterName = _components.ReadPlayerName(snap.LocalPlayerPtr);
-
- // Resolve PSD for skill bar + quest reads
- nint psdPtr = 0;
- if (snap.ServerDataPtr != 0)
- {
- var psdVecBegin = mem.ReadPointer(snap.ServerDataPtr + offsets.PlayerServerDataOffset);
- if (psdVecBegin != 0)
- psdPtr = mem.ReadPointer(psdVecBegin);
- }
-
- snap.PlayerSkills = _skills!.ReadPlayerSkills(snap.LocalPlayerPtr, psdPtr);
- snap.QuestFlags = _quests!.ReadQuestFlags(snap.ServerDataPtr);
}
+ // Skills & quests — read from hierarchy
+ snap.PlayerSkills = ai.PlayerSkills.Skills;
+ snap.QuestFlags = ai.QuestFlags.Quests;
+
// Read state flag bytes
if (snap.InGameStatePtr != 0)
snap.StateFlagBytes = mem.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30);
- // Terrain
- _terrain!.ReadTerrain(snap, ingameData);
+ // Terrain — read from hierarchy
+ snap.TerrainCols = ai.Terrain.TerrainCols;
+ snap.TerrainRows = ai.Terrain.TerrainRows;
+ snap.TerrainWidth = ai.Terrain.TerrainWidth;
+ snap.TerrainHeight = ai.Terrain.TerrainHeight;
+ snap.Terrain = ai.Terrain.Grid;
+ snap.TerrainWalkablePercent = ai.Terrain.WalkablePercent;
}
}
catch (Exception ex)
@@ -265,7 +269,7 @@ public class GameMemoryReader : IDisposable
}
// Update edge detection for next tick
- _terrain!.UpdateLoadingEdge(snap.IsLoading);
+ _gameStates!.InGame.AreaInstance.Terrain.UpdateLoadingEdge(snap.IsLoading);
return snap;
}
@@ -277,8 +281,6 @@ public class GameMemoryReader : IDisposable
if (offsets.CameraMatrixOffset <= 0) return;
- // If CameraOffset > 0: follow pointer from InGameState, then read matrix
- // If CameraOffset == 0: matrix is inline in InGameState at CameraMatrixOffset
nint matrixAddr;
if (offsets.CameraOffset > 0)
{
@@ -291,13 +293,9 @@ public class GameMemoryReader : IDisposable
matrixAddr = inGameState + offsets.CameraMatrixOffset;
}
- // Cache the resolved address for fast per-frame reads
_cachedCameraMatrixAddr = matrixAddr;
- // Read 64-byte Matrix4x4 as a single struct (System.Numerics.Matrix4x4 is already unmanaged/sequential)
var m = mem.Read(matrixAddr);
-
- // Quick sanity check
if (float.IsNaN(m.M11) || float.IsInfinity(m.M11)) return;
snap.CameraMatrix = m;
@@ -332,7 +330,6 @@ public class GameMemoryReader : IDisposable
///
public HotAddresses ResolveHotAddresses()
{
- // Prefer camera address from hierarchical state, fallback to cached
var cameraAddr = _gameStates?.InGame.WorldData.CameraMatrixAddress ?? 0;
if (cameraAddr == 0)
cameraAddr = _cachedCameraMatrixAddr;
diff --git a/src/Roboto.Memory/GameOffsets.cs b/src/Roboto.Memory/GameOffsets.cs
index 0a3c5ea..784a577 100644
--- a/src/Roboto.Memory/GameOffsets.cs
+++ b/src/Roboto.Memory/GameOffsets.cs
@@ -235,6 +235,24 @@ public sealed class GameOffsets
/// Offset within Camera struct to the Matrix4x4 (64 bytes). 0 = disabled.
public int CameraMatrixOffset { get; set; } = 0x1A0;
+ // ── AreaTemplate (WorldData → WorldAreaDetailsPtr → AreaTemplate) ──
+ /// WorldData struct → WorldAreaDetailsPtr offset. Already in struct at 0x98.
+ public int WorldAreaDetailsOffset { get; set; } = 0x98;
+ /// AreaTemplate → RawName wchar_t* pointer.
+ public int AreaTemplateRawNameOffset { get; set; } = 0x00;
+ /// AreaTemplate → Name wchar_t* pointer (display name).
+ public int AreaTemplateNameOffset { get; set; } = 0x08;
+ /// AreaTemplate → Act int32.
+ public int AreaTemplateActOffset { get; set; } = 0x10;
+ /// AreaTemplate → IsTown byte (1 = town).
+ public int AreaTemplateIsTownOffset { get; set; } = 0x14;
+ /// AreaTemplate → HasWaypoint byte (1 = has waypoint).
+ public int AreaTemplateHasWaypointOffset { get; set; } = 0x15;
+ /// AreaTemplate → MonsterLevel int32 (nominal area level).
+ public int AreaTemplateMonsterLevelOffset { get; set; } = 0x26;
+ /// AreaTemplate → WorldAreaId int32.
+ public int AreaTemplateWorldAreaIdOffset { get; set; } = 0x2A;
+
// ── UiRootStruct (InGameState → UI tree roots) ──
/// Offset from InGameState to UiRootStruct pointer. GameOverlay2: 0x340.
public int UiRootStructOffset { get; set; } = 0x340;
diff --git a/src/Roboto.Memory/Objects/AreaInstance.cs b/src/Roboto.Memory/Objects/AreaInstance.cs
index 824fdb9..f971679 100644
--- a/src/Roboto.Memory/Objects/AreaInstance.cs
+++ b/src/Roboto.Memory/Objects/AreaInstance.cs
@@ -1,11 +1,14 @@
-namespace Roboto.Memory.States;
+using Roboto.Memory;
+
+namespace Roboto.Memory.Objects;
///
/// Reads fields from the AreaInstance (IngameData) address.
/// Individual field reads — the full struct is 3280B, too large to bulk-read.
/// Uses GameOffsets for configurable offsets.
+/// Owns EntityList, PlayerSkills, QuestFlags, and Terrain children.
///
-public sealed class AreaInstanceState : RemoteObject
+public sealed class AreaInstance : RemoteObject
{
public int AreaLevel { get; private set; }
public uint AreaHash { get; private set; }
@@ -13,7 +16,19 @@ public sealed class AreaInstanceState : RemoteObject
public nint LocalPlayerPtr { get; private set; }
public int EntityCount { get; private set; }
- public AreaInstanceState(MemoryContext ctx) : base(ctx) { }
+ public EntityList EntityList { get; }
+ public PlayerSkills PlayerSkills { get; }
+ public QuestFlags QuestFlags { get; }
+ public Terrain Terrain { get; }
+
+ public AreaInstance(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
+ : base(ctx)
+ {
+ EntityList = new EntityList(ctx, components, strings);
+ PlayerSkills = new PlayerSkills(ctx, components, strings);
+ QuestFlags = new QuestFlags(ctx, strings, questNames);
+ Terrain = new Terrain(ctx);
+ }
protected override bool ReadData()
{
@@ -49,9 +64,64 @@ public sealed class AreaInstanceState : RemoteObject
var count = (int)mem.Read(Address + offsets.EntityListOffset + offsets.EntityCountInternalOffset);
EntityCount = count is > 0 and < 50000 ? count : 0;
+ // Cascade to children
+ if (EntityCount > 0)
+ {
+ EntityList.ExpectedCount = EntityCount;
+ EntityList.Update(Address);
+ }
+ else
+ {
+ EntityList.Reset();
+ }
+
+ // Resolve PSD for skill bar + quest reads
+ nint psdPtr = 0;
+ if (ServerDataPtr != 0)
+ {
+ var psdVecBegin = mem.ReadPointer(ServerDataPtr + offsets.PlayerServerDataOffset);
+ if (psdVecBegin != 0)
+ psdPtr = mem.ReadPointer(psdVecBegin);
+ }
+
+ if (LocalPlayerPtr != 0)
+ {
+ PlayerSkills.PsdPtr = psdPtr;
+ PlayerSkills.Update(LocalPlayerPtr);
+ }
+ else
+ {
+ PlayerSkills.Reset();
+ }
+
+ if (ServerDataPtr != 0)
+ QuestFlags.Update(ServerDataPtr);
+ else
+ QuestFlags.Reset();
+
+ // Terrain — pass loading/area state before update
+ Terrain.AreaHash = AreaHash;
+ Terrain.Update(Address);
+
return true;
}
+ ///
+ /// Sets loading state on Terrain (called from InGameState/GameMemoryReader).
+ ///
+ public void SetLoadingState(bool isLoading)
+ {
+ Terrain.IsLoading = isLoading;
+ }
+
+ ///
+ /// Invalidates terrain cache (called when LocalPlayer changes on zone change).
+ ///
+ public void InvalidateTerrainCache()
+ {
+ Terrain.InvalidateCache();
+ }
+
protected override void Clear()
{
AreaLevel = 0;
@@ -59,5 +129,9 @@ public sealed class AreaInstanceState : RemoteObject
ServerDataPtr = 0;
LocalPlayerPtr = 0;
EntityCount = 0;
+ EntityList.Reset();
+ PlayerSkills.Reset();
+ QuestFlags.Reset();
+ Terrain.Reset();
}
}
diff --git a/src/Roboto.Memory/Objects/AreaLoading.cs b/src/Roboto.Memory/Objects/AreaLoading.cs
index 5f37508..37f93b2 100644
--- a/src/Roboto.Memory/Objects/AreaLoading.cs
+++ b/src/Roboto.Memory/Objects/AreaLoading.cs
@@ -1,11 +1,11 @@
-using Roboto.GameOffsets.States;
+using Roboto.Memory;
-namespace Roboto.Memory.States;
+namespace Roboto.Memory.Objects;
///
/// Reads AreaLoading state (slot 0). Individual field reads — the full struct is 3672B, wasteful to bulk-read.
///
-public sealed class AreaLoadingState : RemoteObject
+public sealed class AreaLoading : RemoteObject
{
// AreaLoading struct field offsets
private const int IsLoadingOffset = 0x660;
@@ -14,7 +14,7 @@ public sealed class AreaLoadingState : RemoteObject
public bool IsLoading { get; private set; }
public long TotalLoadingScreenTimeMs { get; private set; }
- public AreaLoadingState(MemoryContext ctx) : base(ctx) { }
+ public AreaLoading(MemoryContext ctx) : base(ctx) { }
protected override bool ReadData()
{
diff --git a/src/Roboto.Memory/Objects/AreaTemplate.cs b/src/Roboto.Memory/Objects/AreaTemplate.cs
new file mode 100644
index 0000000..be061e4
--- /dev/null
+++ b/src/Roboto.Memory/Objects/AreaTemplate.cs
@@ -0,0 +1,57 @@
+namespace Roboto.Memory.Objects;
+
+///
+/// Reads AreaTemplate fields from WorldData → WorldAreaDetailsPtr.
+/// ExileCore layout: RawName, Name, Act, IsTown, HasWaypoint, MonsterLevel, WorldAreaId.
+///
+public sealed class AreaTemplate : RemoteObject
+{
+ private readonly MsvcStringReader _strings;
+
+ public string? RawName { get; private set; }
+ public string? Name { get; private set; }
+ public int Act { get; private set; }
+ public bool IsTown { get; private set; }
+ public bool HasWaypoint { get; private set; }
+ public int MonsterLevel { get; private set; }
+ public int WorldAreaId { get; private set; }
+
+ public AreaTemplate(MemoryContext ctx, MsvcStringReader strings) : base(ctx)
+ {
+ _strings = strings;
+ }
+
+ protected override bool ReadData()
+ {
+ var mem = Ctx.Memory;
+ var o = Ctx.Offsets;
+
+ // RawName: ptr → wchar_t*
+ var rawNamePtr = mem.ReadPointer(Address + o.AreaTemplateRawNameOffset);
+ RawName = _strings.ReadNullTermWString(rawNamePtr);
+
+ // Name: ptr → wchar_t*
+ var namePtr = mem.ReadPointer(Address + o.AreaTemplateNameOffset);
+ Name = _strings.ReadNullTermWString(namePtr);
+
+ // Scalar fields
+ Act = mem.Read(Address + o.AreaTemplateActOffset);
+ IsTown = mem.Read(Address + o.AreaTemplateIsTownOffset) == 1;
+ HasWaypoint = mem.Read(Address + o.AreaTemplateHasWaypointOffset) == 1;
+ MonsterLevel = mem.Read(Address + o.AreaTemplateMonsterLevelOffset);
+ WorldAreaId = mem.Read(Address + o.AreaTemplateWorldAreaIdOffset);
+
+ return true;
+ }
+
+ protected override void Clear()
+ {
+ RawName = null;
+ Name = null;
+ Act = 0;
+ IsTown = false;
+ HasWaypoint = false;
+ MonsterLevel = 0;
+ WorldAreaId = 0;
+ }
+}
diff --git a/src/Roboto.Memory/Objects/EntityList.cs b/src/Roboto.Memory/Objects/EntityList.cs
new file mode 100644
index 0000000..242e778
--- /dev/null
+++ b/src/Roboto.Memory/Objects/EntityList.cs
@@ -0,0 +1,391 @@
+using Roboto.Memory;
+using Roboto.GameOffsets.Components;
+using Roboto.GameOffsets.Entities;
+
+namespace Roboto.Memory.Objects;
+
+///
+/// Reads entity list from AreaInstance's std::map red-black tree.
+/// RemoteObject wrapping the previous EntityReader logic.
+/// Update(areaInstanceAddr) to read entities.
+///
+public sealed class EntityList : RemoteObject
+{
+ private readonly ComponentReader _components;
+ private readonly MsvcStringReader _strings;
+
+ public List? Entities { get; private set; }
+
+ public EntityList(MemoryContext ctx, ComponentReader components, MsvcStringReader strings)
+ : base(ctx)
+ {
+ _components = components;
+ _strings = strings;
+ }
+
+ ///
+ /// Reads entities. Requires EntityCount to be set before calling Update().
+ ///
+ public int ExpectedCount { get; set; }
+
+ protected override bool ReadData()
+ {
+ var mem = Ctx.Memory;
+ var offsets = Ctx.Offsets;
+ var registry = Ctx.Registry;
+
+ var sentinel = mem.ReadPointer(Address + offsets.EntityListOffset);
+ if (sentinel == 0) { Entities = null; return true; }
+
+ var root = mem.ReadPointer(sentinel + offsets.EntityNodeParentOffset);
+ var entities = new List();
+ var maxNodes = Math.Min(ExpectedCount + 10, 500);
+ var hasComponentLookup = offsets.ComponentLookupEntrySize > 0;
+ var dirty = false;
+
+ WalkTreeInOrder(sentinel, root, maxNodes, (_, treeNode) =>
+ {
+ var entityPtr = treeNode.Data.EntityPtr;
+ if (entityPtr == 0) return;
+
+ var high = (ulong)entityPtr >> 32;
+ if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
+
+ var entityId = treeNode.Data.Key.EntityId;
+ var path = TryReadEntityPath(entityPtr);
+
+ if (IsDoodadPath(path)) return;
+
+ var entity = new Entity(entityPtr, entityId, path);
+ entity.Type = ClassifyType(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;
+ }
+
+ if (hasComponentLookup && !IsLowPriorityPath(entity.Type))
+ {
+ var lookup = _components.ReadComponentLookup(entityPtr);
+ if (lookup is not null)
+ {
+ entity.Components = new HashSet(lookup.Keys);
+ ReclassifyFromComponents(entity);
+
+ if (registry["components"].Register(lookup.Keys))
+ dirty = true;
+
+ var (compFirst, compCount) = _components.FindComponentList(entityPtr);
+
+ if (lookup.TryGetValue("Targetable", out var targetIdx) && targetIdx >= 0 && targetIdx < compCount)
+ {
+ var targetComp = mem.ReadPointer(compFirst + targetIdx * 8);
+ if (targetComp != 0)
+ {
+ var targetable = mem.Read(targetComp);
+ entity.IsTargetable = targetable.IsTargetable != 0;
+ }
+ }
+
+ if (entity.Components.Contains("Monster"))
+ {
+ if (lookup.TryGetValue("Life", out var lifeIdx) && lifeIdx >= 0 && lifeIdx < compCount)
+ {
+ var lifeComp = mem.ReadPointer(compFirst + lifeIdx * 8);
+ if (lifeComp != 0)
+ {
+ var life = mem.Read(lifeComp);
+ if (life.Health.Total > 0 && life.Health.Total < 200000 &&
+ life.Health.Current >= 0 && life.Health.Current <= life.Health.Total + 1000)
+ {
+ entity.HasVitals = true;
+ entity.LifeCurrent = life.Health.Current;
+ entity.LifeTotal = life.Health.Total;
+ }
+ }
+ }
+
+ if (lookup.TryGetValue("Actor", out var actorIdx) && actorIdx >= 0 && actorIdx < compCount)
+ {
+ var actorComp = mem.ReadPointer(compFirst + actorIdx * 8);
+ if (actorComp != 0)
+ {
+ var animId = mem.Read(actorComp + ActorOffsets.AnimationId);
+ entity.ActionId = (short)(animId & 0xFFFF);
+ }
+ }
+
+ if (lookup.TryGetValue("Mods", out var modsIdx) && modsIdx >= 0 && modsIdx < compCount)
+ {
+ var modsComp = mem.ReadPointer(compFirst + modsIdx * 8);
+ if (modsComp != 0)
+ ReadEntityMods(entity, modsComp);
+ }
+ }
+
+ if (entity.Components.Contains("AreaTransition") &&
+ lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compCount)
+ {
+ var atComp = mem.ReadPointer(compFirst + atIdx * 8);
+ if (atComp != 0)
+ entity.TransitionName = ReadAreaTransitionName(atComp);
+ }
+
+ if (entity.Components.Contains("Transitionable") &&
+ lookup.TryGetValue("Transitionable", out var trIdx) && trIdx >= 0 && trIdx < compCount)
+ {
+ var trComp = mem.ReadPointer(compFirst + trIdx * 8);
+ if (trComp != 0)
+ {
+ var tr = mem.Read(trComp);
+ entity.TransitionState = tr.CurrentStateEnum;
+ }
+ }
+ }
+ }
+
+ entities.Add(entity);
+ });
+
+ if (dirty)
+ registry.Flush();
+
+ Entities = entities;
+ return true;
+ }
+
+ protected override void Clear()
+ {
+ Entities = null;
+ ExpectedCount = 0;
+ }
+
+ // ── Tree walking ─────────────────────────────────────────────────────
+
+ public void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action visitor)
+ {
+ WalkTreeInOrder(sentinel, root, maxNodes, (addr, _) => visitor(addr));
+ }
+
+ public void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action visitor)
+ {
+ if (root == 0 || root == sentinel) return;
+
+ var mem = Ctx.Memory;
+ var stack = new Stack<(nint Addr, EntityTreeNode Node)>();
+ var current = root;
+ var count = 0;
+ var visited = new HashSet { sentinel };
+
+ while ((current != sentinel && current != 0) || stack.Count > 0)
+ {
+ while (current != sentinel && current != 0)
+ {
+ if (!visited.Add(current))
+ {
+ current = sentinel;
+ break;
+ }
+ var node = mem.Read(current);
+ stack.Push((current, node));
+ current = node.Left;
+ }
+
+ if (stack.Count == 0) break;
+
+ var (nodeAddr, treeNode) = stack.Pop();
+ visitor(nodeAddr, treeNode);
+ count++;
+ if (count >= maxNodes) break;
+
+ current = treeNode.Right;
+ }
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────
+
+ 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);
+ }
+
+ 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 (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;
+ }
+
+ 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;
+ }
+
+ private string? ReadAreaTransitionName(nint atComp)
+ {
+ var mem = Ctx.Memory;
+
+ var data = mem.ReadBytes(atComp, 0x80);
+ if (data is null) return null;
+
+ for (var off = 0x10; off + 8 <= data.Length; off += 8)
+ {
+ var ptr = (nint)BitConverter.ToInt64(data, off);
+ if (ptr == 0) continue;
+ if (((ulong)ptr >> 32) is 0 or >= 0x7FFF) continue;
+
+ var raw = _strings.ReadNullTermWString(ptr);
+ if (raw is not null && raw.Length >= 3)
+ return raw;
+
+ var inner = mem.ReadPointer(ptr);
+ if (inner != 0 && ((ulong)inner >> 32) is > 0 and < 0x7FFF)
+ {
+ raw = _strings.ReadNullTermWString(inner);
+ if (raw is not null && raw.Length >= 3)
+ return raw;
+ }
+ }
+
+ return null;
+ }
+
+ private void ReadEntityMods(Entity entity, nint modsComp)
+ {
+ var mem = Ctx.Memory;
+
+ var mods = mem.Read(modsComp);
+
+ if (mods.ObjectMagicPropertiesPtr != 0 &&
+ ((ulong)mods.ObjectMagicPropertiesPtr >> 32) is > 0 and < 0x7FFF)
+ {
+ var props = mem.Read(mods.ObjectMagicPropertiesPtr);
+ if (props.Rarity is >= 0 and <= 3)
+ entity.Rarity = props.Rarity;
+ }
+
+ if (mods.AllModsPtr == 0 || ((ulong)mods.AllModsPtr >> 32) is 0 or >= 0x7FFF)
+ return;
+
+ var allMods = mem.Read(mods.AllModsPtr);
+ var explicitCount = (int)allMods.ExplicitMods.TotalElements(16);
+ if (explicitCount <= 0 || explicitCount > 20) return;
+
+ var modNames = new List();
+ for (var i = 0; i < explicitCount; i++)
+ {
+ var modEntry = mem.Read(allMods.ExplicitMods.First + i * 16);
+ if (modEntry.ModPtr == 0) continue;
+ if (((ulong)modEntry.ModPtr >> 32) is 0 or >= 0x7FFF) continue;
+
+ var name = _strings.ReadNullTermWString(modEntry.ModPtr);
+ if (name is not null)
+ {
+ modNames.Add(name);
+ continue;
+ }
+
+ name = _strings.ReadMsvcWString(modEntry.ModPtr);
+ if (name is not null)
+ modNames.Add(name);
+ }
+
+ if (modNames.Count > 0)
+ entity.ModNames = modNames;
+ }
+
+ // ── Classification helpers ───────────────────────────────────────────
+
+ private static bool IsDoodadPath(string? path)
+ {
+ if (path is null) return false;
+ return path.Contains("Doodad", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsLowPriorityPath(EntityType type)
+ => type is EntityType.Effect or EntityType.Terrain or EntityType.Critter;
+
+ private static EntityType ClassifyType(string? path)
+ {
+ if (path is null) return EntityType.Unknown;
+
+ var firstSlash = path.IndexOf('/');
+ if (firstSlash < 0) return EntityType.Unknown;
+
+ var secondSlash = path.IndexOf('/', firstSlash + 1);
+ var category = secondSlash > 0
+ ? path[(firstSlash + 1)..secondSlash]
+ : path[(firstSlash + 1)..];
+
+ switch (category)
+ {
+ case "Characters": return EntityType.Player;
+ case "Monsters":
+ if (path.Contains("/Critters/", StringComparison.OrdinalIgnoreCase)) return EntityType.Critter;
+ if (path.Contains("/NPC/", StringComparison.OrdinalIgnoreCase) ||
+ path.Contains("/TownNPC/", StringComparison.OrdinalIgnoreCase)) return EntityType.Npc;
+ return EntityType.Monster;
+ case "NPC": return EntityType.Npc;
+ case "Effects": return EntityType.Effect;
+ case "MiscellaneousObjects":
+ if (path.Contains("/Chest", StringComparison.OrdinalIgnoreCase) ||
+ path.Contains("/Stash", StringComparison.OrdinalIgnoreCase)) return EntityType.Chest;
+ if (path.Contains("/Shrine", StringComparison.OrdinalIgnoreCase)) return EntityType.Shrine;
+ if (path.Contains("/Portal", StringComparison.OrdinalIgnoreCase)) return EntityType.Portal;
+ return EntityType.MiscellaneousObject;
+ case "Terrain": return EntityType.Terrain;
+ case "Items": return EntityType.WorldItem;
+ default: return EntityType.Unknown;
+ }
+ }
+
+ private static void ReclassifyFromComponents(Entity entity)
+ {
+ var components = entity.Components;
+ if (components is null || components.Count == 0) return;
+
+ if (components.Contains("Monster")) { entity.Type = EntityType.Monster; return; }
+ if (components.Contains("Chest")) { entity.Type = EntityType.Chest; return; }
+ if (components.Contains("Shrine")) { entity.Type = EntityType.Shrine; return; }
+ if (components.Contains("Waypoint")) { entity.Type = EntityType.Waypoint; return; }
+ if (components.Contains("AreaTransition")) { entity.Type = EntityType.AreaTransition; return; }
+ if (components.Contains("Portal")) { entity.Type = EntityType.Portal; return; }
+ if (components.Contains("TownPortal")) { entity.Type = EntityType.TownPortal; return; }
+ if (components.Contains("NPC")) { entity.Type = EntityType.Npc; return; }
+ if (components.Contains("Player")) { entity.Type = EntityType.Player; return; }
+ }
+}
diff --git a/src/Roboto.Memory/Objects/GameStateType.cs b/src/Roboto.Memory/Objects/GameStateType.cs
index cf2c4c2..421b04f 100644
--- a/src/Roboto.Memory/Objects/GameStateType.cs
+++ b/src/Roboto.Memory/Objects/GameStateType.cs
@@ -1,4 +1,4 @@
-namespace Roboto.Memory.States;
+namespace Roboto.Memory.Objects;
///
/// Game state types by slot index. Order must match the state array in the controller.
diff --git a/src/Roboto.Memory/Objects/GameStates.cs b/src/Roboto.Memory/Objects/GameStates.cs
index 67fb2b9..ca75288 100644
--- a/src/Roboto.Memory/Objects/GameStates.cs
+++ b/src/Roboto.Memory/Objects/GameStates.cs
@@ -1,4 +1,6 @@
-namespace Roboto.Memory.States;
+using Roboto.Memory;
+
+namespace Roboto.Memory.Objects;
///
/// Root state orchestrator. Reads controller from GameStateBase, resolves state slot pointers,
@@ -16,17 +18,17 @@ public sealed class GameStates
public int StatesCount { get; private set; }
public GameStateType CurrentState { get; private set; } = GameStateType.GameNotLoaded;
public IReadOnlyDictionary AllStates => _allStates;
- public AreaLoadingState AreaLoading { get; }
- public InGameStateReader InGame { get; }
+ public AreaLoading AreaLoading { get; }
+ public InGameState InGame { get; }
/// Raw qwords from controller 0x00-0x48 (before state slots), for UI diagnostics.
public (int Offset, nint Value, string? Match, bool Changed, string? DerefInfo)[] ControllerPreSlots { get; private set; } = [];
- public GameStates(MemoryContext ctx)
+ public GameStates(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
{
_ctx = ctx;
- AreaLoading = new AreaLoadingState(ctx);
- InGame = new InGameStateReader(ctx);
+ AreaLoading = new AreaLoading(ctx);
+ InGame = new InGameState(ctx, components, strings, questNames);
}
///
diff --git a/src/Roboto.Memory/Objects/InGameState.cs b/src/Roboto.Memory/Objects/InGameState.cs
index ca6ad9d..32438d5 100644
--- a/src/Roboto.Memory/Objects/InGameState.cs
+++ b/src/Roboto.Memory/Objects/InGameState.cs
@@ -1,24 +1,25 @@
-using Roboto.GameOffsets.States;
+using Roboto.Memory;
+using IgsStruct = Roboto.GameOffsets.States.InGameState;
-namespace Roboto.Memory.States;
+namespace Roboto.Memory.Objects;
///
/// Reads InGameState struct (784B, 1 RPM instead of 4 individual reads).
-/// Named "Reader" to avoid collision with struct.
-/// Cascades to AreaInstanceState and WorldDataState children.
+/// Cascades to AreaInstance and WorldData children.
///
-public sealed class InGameStateReader : RemoteObject
+public sealed class InGameState : RemoteObject
{
- private InGameState _data;
+ private IgsStruct _data;
public bool IsEscapeOpen { get; private set; }
- public AreaInstanceState AreaInstance { get; }
- public WorldDataState WorldData { get; }
+ public AreaInstance AreaInstance { get; }
+ public WorldData WorldData { get; }
- public InGameStateReader(MemoryContext ctx) : base(ctx)
+ public InGameState(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
+ : base(ctx)
{
- AreaInstance = new AreaInstanceState(ctx);
- WorldData = new WorldDataState(ctx);
+ AreaInstance = new AreaInstance(ctx, components, strings, questNames);
+ WorldData = new WorldData(ctx, strings);
}
protected override bool ReadData()
@@ -26,7 +27,7 @@ public sealed class InGameStateReader : RemoteObject
var mem = Ctx.Memory;
// Read the full InGameState struct (0x310 = 784 bytes, 1 RPM)
- _data = mem.Read(Address);
+ _data = mem.Read(Address);
// Escape state
IsEscapeOpen = _data.EscapeStateFlag != 0;
diff --git a/src/Roboto.Memory/Objects/PlayerSkills.cs b/src/Roboto.Memory/Objects/PlayerSkills.cs
new file mode 100644
index 0000000..54235f2
--- /dev/null
+++ b/src/Roboto.Memory/Objects/PlayerSkills.cs
@@ -0,0 +1,235 @@
+using Roboto.Memory;
+using Roboto.GameOffsets.Components;
+
+namespace Roboto.Memory.Objects;
+
+///
+/// Reads active skills from the local player's Actor component.
+/// RemoteObject wrapping the previous SkillReader logic.
+/// Update(localPlayerPtr) to read skills.
+///
+public sealed class PlayerSkills : RemoteObject
+{
+ private readonly ComponentReader _components;
+ private readonly MsvcStringReader _strings;
+
+ // Name cache — skill names are static per area, only refresh on actor change
+ private readonly Dictionary _nameCache = new();
+ private nint _lastActorComp;
+
+ public List? Skills { get; private set; }
+
+ /// PSD pointer for skill bar ID matching. Set before calling Update().
+ public nint PsdPtr { get; set; }
+
+ public PlayerSkills(MemoryContext ctx, ComponentReader components, MsvcStringReader strings)
+ : base(ctx)
+ {
+ _components = components;
+ _strings = strings;
+ }
+
+ protected override bool ReadData()
+ {
+ if (Address == 0) { Skills = null; return false; }
+ var mem = Ctx.Memory;
+
+ var actorComp = _components.GetComponentAddress(Address, "Actor");
+ if (actorComp == 0) { Skills = null; return true; }
+
+ // Invalidate name cache if actor component address changed (area transition)
+ if (actorComp != _lastActorComp)
+ {
+ _nameCache.Clear();
+ _lastActorComp = actorComp;
+ }
+
+ // Read SkillBarIds from PSD if offset is configured
+ var skillBarIds = ReadSkillBarIds(PsdPtr);
+
+ // Read ActiveSkills vector at Actor+0xB00
+ var vecFirst = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector);
+ var vecLast = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector + 8);
+ if (vecFirst == 0 || vecLast <= vecFirst) { Skills = null; return true; }
+
+ var totalBytes = (int)(vecLast - vecFirst);
+ const int entrySize = 0x10;
+ var entryCount = totalBytes / entrySize;
+ if (entryCount <= 0 || entryCount > 128) { Skills = null; return true; }
+
+ var vecData = mem.ReadBytes(vecFirst, totalBytes);
+ if (vecData is null) { Skills = null; return true; }
+
+ var cooldowns = ReadCooldowns(actorComp);
+
+ var result = new List();
+ var seen = new HashSet();
+
+ for (var i = 0; i < entryCount; i++)
+ {
+ var activeSkillPtr = (nint)BitConverter.ToInt64(vecData, i * entrySize);
+ if (activeSkillPtr == 0) continue;
+ var high = (ulong)activeSkillPtr >> 32;
+ if (high == 0 || high >= 0x7FFF) continue;
+
+ var details = mem.Read(activeSkillPtr - 0x10);
+ var name = ResolveSkillName(activeSkillPtr, details);
+ if (name is null) continue;
+ if (!seen.Add(details.UnknownIdAndEquipmentInfo)) continue;
+
+ var id = (ushort)(details.UnknownIdAndEquipmentInfo & 0xFFFF);
+ var id2 = (ushort)(details.UnknownIdAndEquipmentInfo >> 16);
+
+ var slot = -1;
+ if (skillBarIds is not null)
+ {
+ for (var s = 0; s < skillBarIds.Length; s++)
+ {
+ if (skillBarIds[s].Id == id && skillBarIds[s].Id2 == id2)
+ {
+ slot = s;
+ break;
+ }
+ }
+ }
+
+ var canBeUsed = true;
+ var activeCooldowns = 0;
+ var cdMaxUses = 0;
+ if (cooldowns is not null)
+ {
+ foreach (var (cd, _) in cooldowns)
+ {
+ if (cd.UnknownIdAndEquipmentInfo == details.UnknownIdAndEquipmentInfo)
+ {
+ canBeUsed = !cd.CannotBeUsed;
+ activeCooldowns = cd.TotalActiveCooldowns;
+ cdMaxUses = cd.MaxUses;
+ break;
+ }
+ }
+ }
+
+ var rawBytes = mem.ReadBytes(activeSkillPtr - 0x10, 0xC0);
+
+ result.Add(new SkillSnapshot
+ {
+ Name = name,
+ InternalName = name,
+ Address = activeSkillPtr - 0x10,
+ RawBytes = rawBytes,
+ CanBeUsed = canBeUsed,
+ UseStage = details.UseStage,
+ CastType = details.CastType,
+ TotalUses = details.TotalUses,
+ CooldownTimeMs = details.TotalCooldownTimeInMs,
+ ActiveCooldowns = activeCooldowns,
+ MaxUses = cdMaxUses,
+ Id = id,
+ Id2 = id2,
+ SkillBarSlot = slot,
+ });
+ }
+
+ Skills = result;
+ return true;
+ }
+
+ protected override void Clear()
+ {
+ Skills = null;
+ PsdPtr = 0;
+ _nameCache.Clear();
+ _lastActorComp = 0;
+ }
+
+ private (ushort Id, ushort Id2)[]? ReadSkillBarIds(nint psdPtr)
+ {
+ var offset = Ctx.Offsets.SkillBarIdsOffset;
+ if (offset <= 0 || psdPtr == 0) return null;
+
+ const int slotCount = 13;
+ const int bufferSize = slotCount * 4;
+ var data = Ctx.Memory.ReadBytes(psdPtr + offset, bufferSize);
+ if (data is null) return null;
+
+ var slots = new (ushort Id, ushort Id2)[slotCount];
+ for (var i = 0; i < slotCount; i++)
+ {
+ var off = i * 4;
+ slots[i] = (
+ BitConverter.ToUInt16(data, off),
+ BitConverter.ToUInt16(data, off + 2)
+ );
+ }
+ return slots;
+ }
+
+ private List<(ActorSkillCooldown Cd, nint FirstPtr)>? ReadCooldowns(nint actorComp)
+ {
+ var mem = Ctx.Memory;
+ var cdFirst = mem.ReadPointer(actorComp + ActorOffsets.CooldownsVector);
+ var cdLast = mem.ReadPointer(actorComp + ActorOffsets.CooldownsVector + 8);
+ if (cdFirst == 0 || cdLast <= cdFirst) return null;
+
+ var totalBytes = (int)(cdLast - cdFirst);
+ const int cdEntrySize = 0x48;
+ var count = totalBytes / cdEntrySize;
+ if (count <= 0 || count > 64) return null;
+
+ var result = new List<(ActorSkillCooldown, nint)>(count);
+ for (var i = 0; i < count; i++)
+ {
+ var cd = mem.Read(cdFirst + i * cdEntrySize);
+ result.Add((cd, cd.CooldownsList.First));
+ }
+
+ return result;
+ }
+
+ private string? ResolveSkillName(nint activeSkillPtr, ActorSkillDetails details)
+ {
+ if (_nameCache.TryGetValue(activeSkillPtr, out var cached))
+ return cached;
+
+ var mem = Ctx.Memory;
+ string? name = null;
+
+ var asDatDirect = details.ActiveSkillsDatPtr;
+ if (asDatDirect != 0 && ((ulong)asDatDirect >> 32) is > 0 and < 0x7FFF)
+ name = _strings.ReadNullTermWString(asDatDirect);
+
+ if (name is null)
+ {
+ var geplPtr = details.GrantedEffectsPerLevelDatRow;
+ if (geplPtr != 0)
+ {
+ var geFk = mem.ReadPointer(geplPtr);
+ if (geFk != 0 && ((ulong)geFk >> 32) is > 0 and < 0x7FFF)
+ {
+ var geData = mem.ReadBytes(geFk, 0xB0);
+ if (geData is not null)
+ {
+ var asDatPtr = (nint)BitConverter.ToInt64(geData, 0x00);
+ if (asDatPtr != 0 && ((ulong)asDatPtr >> 32) is > 0 and < 0x7FFF)
+ name = _strings.ReadNullTermWString(asDatPtr);
+
+ if (name is null && 0xA8 + 8 <= geData.Length)
+ {
+ var nameObjPtr = (nint)BitConverter.ToInt64(geData, 0xA8);
+ if (nameObjPtr != 0 && ((ulong)nameObjPtr >> 32) is > 0 and < 0x7FFF)
+ {
+ var namePtr = mem.ReadPointer(nameObjPtr);
+ if (namePtr != 0)
+ name = _strings.ReadNullTermWString(namePtr);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ _nameCache[activeSkillPtr] = name;
+ return name;
+ }
+}
diff --git a/src/Roboto.Memory/Objects/QuestFlags.cs b/src/Roboto.Memory/Objects/QuestFlags.cs
new file mode 100644
index 0000000..2ac928e
--- /dev/null
+++ b/src/Roboto.Memory/Objects/QuestFlags.cs
@@ -0,0 +1,266 @@
+using Roboto.Memory;
+using Serilog;
+
+namespace Roboto.Memory.Objects;
+
+///
+/// Reads quest flags from ServerData → PlayerServerData → QuestFlags.
+/// RemoteObject wrapping the previous QuestReader logic.
+/// Update(serverDataPtr) to read quests.
+///
+public sealed class QuestFlags : RemoteObject
+{
+ private readonly MsvcStringReader _strings;
+ private readonly QuestNameLookup? _nameLookup;
+
+ private readonly Dictionary _nameCache = new();
+ private nint _lastPsd;
+
+ public List? Quests { get; private set; }
+
+ public QuestFlags(MemoryContext ctx, MsvcStringReader strings, QuestNameLookup? nameLookup = null)
+ : base(ctx)
+ {
+ _strings = strings;
+ _nameLookup = nameLookup;
+ }
+
+ protected override bool ReadData()
+ {
+ var offsets = Ctx.Offsets;
+ if (offsets.QuestFlagEntrySize <= 0) { Quests = null; return true; }
+
+ var mem = Ctx.Memory;
+
+ var psdVecBegin = mem.ReadPointer(Address + offsets.PlayerServerDataOffset);
+ if (psdVecBegin == 0) { Quests = null; return true; }
+
+ var playerServerData = mem.ReadPointer(psdVecBegin);
+ if (playerServerData == 0) { Quests = null; return true; }
+
+ if (playerServerData != _lastPsd)
+ {
+ _nameCache.Clear();
+ _lastPsd = playerServerData;
+ }
+
+ var questFlagsAddr = playerServerData + offsets.QuestFlagsOffset;
+
+ if (offsets.QuestFlagsContainerType == "int_vector")
+ Quests = ReadIntVectorQuests(questFlagsAddr, offsets);
+ else if (offsets.QuestFlagsContainerType == "vector")
+ Quests = ReadStructVectorQuests(questFlagsAddr, offsets);
+ else
+ Quests = null;
+
+ return true;
+ }
+
+ protected override void Clear()
+ {
+ Quests = null;
+ _nameCache.Clear();
+ _lastPsd = 0;
+ }
+
+ private List? ReadIntVectorQuests(nint questFlagsAddr, GameOffsets offsets)
+ {
+ var mem = Ctx.Memory;
+
+ var vecBegin = mem.ReadPointer(questFlagsAddr);
+ var vecEnd = mem.ReadPointer(questFlagsAddr + 8);
+ if (vecBegin == 0 || vecEnd <= vecBegin) return null;
+
+ var totalBytes = (int)(vecEnd - vecBegin);
+ var entryCount = totalBytes / 4;
+ if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null;
+
+ var vecData = mem.ReadBytes(vecBegin, totalBytes);
+ if (vecData is null) return null;
+
+ byte[]? compData = null;
+ var compEntryCount = 0;
+ if (offsets.QuestCompanionOffset > 0 && offsets.QuestCompanionEntrySize > 0)
+ {
+ var compBegin = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset);
+ var compEnd = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset + 8);
+ if (compBegin != 0 && compEnd > compBegin)
+ {
+ var compBytes = (int)(compEnd - compBegin);
+ compEntryCount = compBytes / offsets.QuestCompanionEntrySize;
+ if (compEntryCount > 0 && compBytes < 0x100000)
+ compData = mem.ReadBytes(compBegin, compBytes);
+ }
+ }
+
+ var datTableBase = FindDatTableBase(offsets);
+
+ var result = new List(entryCount);
+
+ for (var i = 0; i < entryCount; i++)
+ {
+ var idx = BitConverter.ToInt32(vecData, i * 4);
+ string? questName = null;
+ string? internalId = null;
+ byte stateId = 0;
+ bool isTracked = false;
+ nint questObjPtr = 0;
+
+ if (compData is not null && i < compEntryCount)
+ {
+ var compOff = i * offsets.QuestCompanionEntrySize;
+
+ if (offsets.QuestCompanionTrackedOffset > 0 &&
+ compOff + offsets.QuestCompanionTrackedOffset + 4 <= compData.Length)
+ {
+ var trackedVal = BitConverter.ToUInt32(compData, compOff + offsets.QuestCompanionTrackedOffset);
+ isTracked = trackedVal == offsets.QuestTrackedMarker;
+ }
+
+ if (compOff + offsets.QuestCompanionObjPtrOffset + 8 <= compData.Length)
+ {
+ questObjPtr = (nint)BitConverter.ToInt64(compData, compOff + offsets.QuestCompanionObjPtrOffset);
+
+ if (questObjPtr != 0 && ((ulong)questObjPtr >> 32) is > 0 and < 0x7FFF
+ && offsets.QuestObjEncounterStateOffset > 0)
+ {
+ var stateByte = mem.ReadBytes(questObjPtr + offsets.QuestObjEncounterStateOffset, 1);
+ if (stateByte is { Length: 1 })
+ stateId = stateByte[0];
+ }
+ }
+ }
+
+ if (datTableBase != 0 && offsets.QuestDatRowSize > 0)
+ {
+ var rowAddr = datTableBase + idx * offsets.QuestDatRowSize;
+ questName = ResolveDatString(rowAddr + offsets.QuestDatNameOffset);
+ internalId = ResolveDatString(rowAddr + offsets.QuestDatInternalIdOffset);
+ }
+ else if (_nameLookup is not null && _nameLookup.TryGet(idx, out var entry))
+ {
+ questName = entry?.Name;
+ internalId = entry?.InternalId;
+ }
+
+ result.Add(new QuestSnapshot
+ {
+ QuestStateIndex = idx,
+ QuestDatPtr = questObjPtr,
+ QuestName = questName,
+ InternalId = internalId,
+ StateId = stateId,
+ IsTracked = isTracked,
+ });
+ }
+
+ return result;
+ }
+
+ private nint FindDatTableBase(GameOffsets offsets)
+ {
+ if (offsets.QuestDatRowSize <= 0) return 0;
+ return 0;
+ }
+
+ private string? ResolveDatString(nint fieldAddr)
+ {
+ if (_nameCache.TryGetValue(fieldAddr, out var cached))
+ return cached;
+
+ var mem = Ctx.Memory;
+ var strPtr = mem.ReadPointer(fieldAddr);
+ string? result = null;
+
+ if (strPtr != 0 && ((ulong)strPtr >> 32) is > 0 and < 0x7FFF)
+ result = _strings.ReadNullTermWString(strPtr);
+
+ _nameCache[fieldAddr] = result;
+ return result;
+ }
+
+ private List? ReadStructVectorQuests(nint questFlagsAddr, GameOffsets offsets)
+ {
+ var mem = Ctx.Memory;
+
+ var vecBegin = mem.ReadPointer(questFlagsAddr);
+ var vecEnd = mem.ReadPointer(questFlagsAddr + 8);
+ if (vecBegin == 0 || vecEnd <= vecBegin) return null;
+
+ var totalBytes = (int)(vecEnd - vecBegin);
+ var entrySize = offsets.QuestFlagEntrySize;
+ var entryCount = totalBytes / entrySize;
+ if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null;
+
+ var vecData = mem.ReadBytes(vecBegin, totalBytes);
+ if (vecData is null) return null;
+
+ var result = new List(entryCount);
+
+ for (var i = 0; i < entryCount; i++)
+ {
+ var entryOffset = i * entrySize;
+
+ nint questDatPtr = 0;
+ if (entryOffset + offsets.QuestEntryQuestPtrOffset + 8 <= vecData.Length)
+ questDatPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryQuestPtrOffset);
+
+ byte stateId = 0;
+ if (entryOffset + offsets.QuestEntryStateIdOffset < vecData.Length)
+ stateId = vecData[entryOffset + offsets.QuestEntryStateIdOffset];
+
+ var questName = ResolveQuestName(questDatPtr);
+
+ string? stateText = null;
+ if (offsets.QuestEntryStateTextOffset > 0 &&
+ entryOffset + offsets.QuestEntryStateTextOffset + 8 <= vecData.Length)
+ {
+ var stateTextPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryStateTextOffset);
+ if (stateTextPtr != 0 && ((ulong)stateTextPtr >> 32) is > 0 and < 0x7FFF)
+ stateText = _strings.ReadNullTermWString(stateTextPtr);
+ }
+
+ string? progressText = null;
+ if (offsets.QuestEntryProgressTextOffset > 0 &&
+ entryOffset + offsets.QuestEntryProgressTextOffset + 8 <= vecData.Length)
+ {
+ var progressTextPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryProgressTextOffset);
+ if (progressTextPtr != 0 && ((ulong)progressTextPtr >> 32) is > 0 and < 0x7FFF)
+ progressText = _strings.ReadNullTermWString(progressTextPtr);
+ }
+
+ result.Add(new QuestSnapshot
+ {
+ QuestDatPtr = questDatPtr,
+ QuestName = questName,
+ StateId = stateId,
+ StateText = stateText,
+ ProgressText = progressText,
+ });
+ }
+
+ return result;
+ }
+
+ private string? ResolveQuestName(nint questDatPtr)
+ {
+ if (questDatPtr == 0) return null;
+
+ if (_nameCache.TryGetValue(questDatPtr, out var cached))
+ return cached;
+
+ var mem = Ctx.Memory;
+ string? name = null;
+
+ var high = (ulong)questDatPtr >> 32;
+ if (high is > 0 and < 0x7FFF)
+ {
+ var namePtr = mem.ReadPointer(questDatPtr);
+ if (namePtr != 0)
+ name = _strings.ReadNullTermWString(namePtr);
+ }
+
+ _nameCache[questDatPtr] = name;
+ return name;
+ }
+}
diff --git a/src/Roboto.Memory/Objects/Terrain.cs b/src/Roboto.Memory/Objects/Terrain.cs
new file mode 100644
index 0000000..1e8c454
--- /dev/null
+++ b/src/Roboto.Memory/Objects/Terrain.cs
@@ -0,0 +1,176 @@
+using Roboto.Memory;
+using Serilog;
+using TerrainStruct = Roboto.GameOffsets.States.Terrain;
+
+namespace Roboto.Memory.Objects;
+
+///
+/// Reads terrain walkability grid from AreaInstance.
+/// RemoteObject wrapping the previous TerrainReader logic.
+/// Update(areaInstanceAddr) to read terrain.
+///
+public sealed class Terrain : RemoteObject
+{
+ private uint _cachedTerrainAreaHash;
+ private WalkabilityGrid? _cachedTerrain;
+ private bool _wasLoading;
+
+ public WalkabilityGrid? Grid { get; private set; }
+ public int TerrainWidth { get; private set; }
+ public int TerrainHeight { get; private set; }
+ public int TerrainCols { get; private set; }
+ public int TerrainRows { get; private set; }
+ public int WalkablePercent { get; private set; }
+
+ /// Set before Update() to indicate current loading/area state.
+ public bool IsLoading { get; set; }
+ public uint AreaHash { get; set; }
+
+ public Terrain(MemoryContext ctx) : base(ctx) { }
+
+ public void InvalidateCache()
+ {
+ _cachedTerrain = null;
+ _cachedTerrainAreaHash = 0;
+ }
+
+ protected override bool ReadData()
+ {
+ var mem = Ctx.Memory;
+ var offsets = Ctx.Offsets;
+
+ if (!offsets.TerrainInline)
+ {
+ var terrainListPtr = mem.ReadPointer(Address + offsets.TerrainListOffset);
+ if (terrainListPtr == 0) return true;
+
+ var terrainPtr = mem.ReadPointer(terrainListPtr);
+ if (terrainPtr == 0) return true;
+
+ var dimsPtr = mem.ReadPointer(terrainPtr + offsets.TerrainDimensionsOffset);
+ if (dimsPtr == 0) return true;
+
+ TerrainCols = mem.Read(dimsPtr);
+ TerrainRows = mem.Read(dimsPtr + 4);
+ if (TerrainCols > 0 && TerrainCols < 1000 &&
+ TerrainRows > 0 && TerrainRows < 1000)
+ {
+ TerrainWidth = TerrainCols * offsets.SubTilesPerCell;
+ TerrainHeight = TerrainRows * offsets.SubTilesPerCell;
+ }
+ else
+ {
+ TerrainCols = 0;
+ TerrainRows = 0;
+ }
+ return true;
+ }
+
+ // Inline mode
+ var terrainBase = Address + offsets.TerrainListOffset;
+ var t = mem.Read(terrainBase);
+
+ var cols = (int)t.Dimensions.X;
+ var rows = (int)t.Dimensions.Y;
+
+ if (cols <= 0 || cols >= 1000 || rows <= 0 || rows >= 1000)
+ return true;
+
+ TerrainCols = cols;
+ TerrainRows = rows;
+ TerrainWidth = cols * offsets.SubTilesPerCell;
+ TerrainHeight = rows * offsets.SubTilesPerCell;
+
+ if (IsLoading)
+ {
+ _cachedTerrain = null;
+ _cachedTerrainAreaHash = 0;
+ Grid = null;
+ return true;
+ }
+
+ if (_wasLoading)
+ {
+ _cachedTerrain = null;
+ _cachedTerrainAreaHash = 0;
+ }
+
+ if (_cachedTerrain != null && _cachedTerrainAreaHash == AreaHash)
+ {
+ Grid = _cachedTerrain;
+ WalkablePercent = CalcWalkablePercent(_cachedTerrain);
+ return true;
+ }
+
+ var gridBegin = t.WalkableGrid.First;
+ var gridEnd = t.WalkableGrid.Last;
+ if (gridBegin == 0 || gridEnd <= gridBegin)
+ return true;
+
+ var gridDataSize = (int)(gridEnd - gridBegin);
+ if (gridDataSize <= 0 || gridDataSize > 16 * 1024 * 1024)
+ return true;
+
+ var bytesPerRow = t.BytesPerRow;
+ if (bytesPerRow <= 0 || bytesPerRow > 0x10000)
+ return true;
+
+ var gridWidth = cols * offsets.SubTilesPerCell;
+ var gridHeight = rows * offsets.SubTilesPerCell;
+
+ var rawData = mem.ReadBytes(gridBegin, gridDataSize);
+ if (rawData is null)
+ return true;
+
+ var data = new byte[gridWidth * gridHeight];
+ for (var row = 0; row < gridHeight; row++)
+ {
+ var rowStart = row * bytesPerRow;
+ for (var col = 0; col < gridWidth; col++)
+ {
+ var byteIndex = rowStart + col / 2;
+ if (byteIndex >= rawData.Length) break;
+
+ data[row * gridWidth + col] = (col % 2 == 0)
+ ? (byte)(rawData[byteIndex] & 0x0F)
+ : (byte)((rawData[byteIndex] >> 4) & 0x0F);
+ }
+ }
+
+ var grid = new WalkabilityGrid(gridWidth, gridHeight, data);
+ Grid = grid;
+ WalkablePercent = CalcWalkablePercent(grid);
+
+ _cachedTerrain = grid;
+ _cachedTerrainAreaHash = AreaHash;
+
+ Log.Information("Terrain grid read: {W}x{H} ({Cols}x{Rows} cells), {Pct}% walkable",
+ gridWidth, gridHeight, cols, rows, WalkablePercent);
+
+ return true;
+ }
+
+ /// Update loading edge state. Call after ReadData().
+ public void UpdateLoadingEdge(bool isLoading)
+ {
+ _wasLoading = isLoading;
+ }
+
+ protected override void Clear()
+ {
+ Grid = null;
+ TerrainWidth = 0;
+ TerrainHeight = 0;
+ TerrainCols = 0;
+ TerrainRows = 0;
+ WalkablePercent = 0;
+ }
+
+ 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;
+ }
+}
diff --git a/src/Roboto.Memory/Objects/WorldData.cs b/src/Roboto.Memory/Objects/WorldData.cs
index 5293d7f..a5f724c 100644
--- a/src/Roboto.Memory/Objects/WorldData.cs
+++ b/src/Roboto.Memory/Objects/WorldData.cs
@@ -1,17 +1,19 @@
using System.Numerics;
-using Roboto.GameOffsets.States;
+using Roboto.Memory;
+using WdStruct = Roboto.GameOffsets.States.WorldData;
-namespace Roboto.Memory.States;
+namespace Roboto.Memory.Objects;
///
/// Reads WorldData struct (168B, 1 RPM) and resolves the camera matrix.
/// Primary camera source: WorldData.CameraPtr. Fallback: InGameState.CameraPtr (set via FallbackCameraPtr).
+/// Owns AreaTemplate child for area metadata.
///
-public sealed class WorldDataState : RemoteObject
+public sealed class WorldData : RemoteObject
{
- private WorldData _data;
+ private WdStruct _data;
- /// Camera pointer from InGameState, set by InGameStateReader before Update() is called.
+ /// Camera pointer from InGameState, set by InGameState before Update() is called.
public nint FallbackCameraPtr { get; set; }
public Matrix4x4? CameraMatrix { get; private set; }
@@ -19,7 +21,12 @@ public sealed class WorldDataState : RemoteObject
/// Resolved address of the camera matrix for hot-path caching.
public nint CameraMatrixAddress { get; private set; }
- public WorldDataState(MemoryContext ctx) : base(ctx) { }
+ public AreaTemplate AreaTemplate { get; }
+
+ public WorldData(MemoryContext ctx, MsvcStringReader strings) : base(ctx)
+ {
+ AreaTemplate = new AreaTemplate(ctx, strings);
+ }
protected override bool ReadData()
{
@@ -27,7 +34,13 @@ public sealed class WorldDataState : RemoteObject
var offsets = Ctx.Offsets;
// Read the full WorldData struct (0xA8 = 168 bytes, 1 RPM)
- _data = mem.Read(Address);
+ _data = mem.Read(Address);
+
+ // Cascade to AreaTemplate
+ if (_data.WorldAreaDetailsPtr != 0)
+ AreaTemplate.Update(_data.WorldAreaDetailsPtr);
+ else
+ AreaTemplate.Reset();
// Resolve camera: primary from WorldData, fallback from InGameState
if (offsets.CameraMatrixOffset <= 0)
@@ -56,5 +69,6 @@ public sealed class WorldDataState : RemoteObject
FallbackCameraPtr = 0;
CameraMatrix = null;
CameraMatrixAddress = 0;
+ AreaTemplate.Reset();
}
}
diff --git a/src/Roboto.Memory/RemoteObject.cs b/src/Roboto.Memory/RemoteObject.cs
index fb6796e..6afc901 100644
--- a/src/Roboto.Memory/RemoteObject.cs
+++ b/src/Roboto.Memory/RemoteObject.cs
@@ -1,4 +1,4 @@
-namespace Roboto.Memory.States;
+namespace Roboto.Memory;
///
/// Base class for state objects that read a section of game memory.
diff --git a/src/Roboto.Memory/Snapshots/GameStateSnapshot.cs b/src/Roboto.Memory/Snapshots/GameStateSnapshot.cs
index 4f58a29..1083f35 100644
--- a/src/Roboto.Memory/Snapshots/GameStateSnapshot.cs
+++ b/src/Roboto.Memory/Snapshots/GameStateSnapshot.cs
@@ -1,5 +1,5 @@
using System.Numerics;
-using Roboto.Memory.States;
+using Roboto.Memory.Objects;
namespace Roboto.Memory;
@@ -29,6 +29,15 @@ public class GameStateSnapshot
public int AreaLevel;
public uint AreaHash;
+ // Area template (from WorldData → AreaTemplate)
+ public string? AreaRawName;
+ public string? AreaName;
+ public int AreaAct;
+ public bool AreaIsTown;
+ public bool AreaHasWaypoint;
+ public int AreaMonsterLevel;
+ public int WorldAreaId;
+
// Player
public string? CharacterName;
diff --git a/src/Roboto.Memory/Snapshots/QuestSnapshot.cs b/src/Roboto.Memory/Snapshots/QuestSnapshot.cs
new file mode 100644
index 0000000..3d902f2
--- /dev/null
+++ b/src/Roboto.Memory/Snapshots/QuestSnapshot.cs
@@ -0,0 +1,21 @@
+namespace Roboto.Memory;
+
+///
+/// Lightweight quest data from ServerData quest flags.
+/// Stored in GameStateSnapshot; mapped to Roboto.Core.QuestProgress in the Data layer.
+///
+public sealed class QuestSnapshot
+{
+ /// QuestState.dat row index (int_vector mode) or 0 (pointer mode).
+ public int QuestStateIndex { get; init; }
+ public nint QuestDatPtr { get; init; }
+ public string? QuestName { get; init; }
+ /// Internal quest ID from dat row (e.g. "TreeOfSouls2", "IncursionQuest1_Act1").
+ public string? InternalId { get; init; }
+ /// Encounter state from quest state object: 1=locked/not encountered, 2=available/started.
+ public byte StateId { get; init; }
+ /// True if this quest is the currently tracked/active quest in the UI.
+ public bool IsTracked { get; init; }
+ public string? StateText { get; init; }
+ public string? ProgressText { get; init; }
+}
diff --git a/src/Roboto.Memory/Snapshots/SkillSnapshot.cs b/src/Roboto.Memory/Snapshots/SkillSnapshot.cs
new file mode 100644
index 0000000..851e54d
--- /dev/null
+++ b/src/Roboto.Memory/Snapshots/SkillSnapshot.cs
@@ -0,0 +1,32 @@
+namespace Roboto.Memory;
+
+///
+/// Lightweight skill data from the Actor component's ActiveSkills vector.
+/// Stored in GameStateSnapshot; mapped to Roboto.Core.SkillState in the Data layer.
+///
+public sealed class SkillSnapshot
+{
+ public string? Name { get; init; }
+ public string? InternalName { get; init; }
+ /// Address of ActiveSkillPtr in game memory (for CE inspection).
+ public nint Address { get; init; }
+ /// Raw bytes at ActiveSkillPtr for offset discovery.
+ public byte[]? RawBytes { get; init; }
+ public bool CanBeUsed { get; init; }
+ public int UseStage { get; init; }
+ public int CastType { get; init; }
+ public int TotalUses { get; init; }
+ public int CooldownTimeMs { get; init; }
+
+ /// From Cooldowns vector — number of active cooldown entries.
+ public int ActiveCooldowns { get; init; }
+ /// From Cooldowns vector — max uses (charges) for the skill.
+ public int MaxUses { get; init; }
+
+ /// Low 16 bits of UnknownIdAndEquipmentInfo — skill ID used for SkillBarIds matching.
+ public ushort Id { get; init; }
+ /// High 16 bits of UnknownIdAndEquipmentInfo — equipment slot / secondary ID.
+ public ushort Id2 { get; init; }
+ /// Skill bar slot index (0-12) from SkillBarIds, or -1 if not on the skill bar.
+ public int SkillBarSlot { get; init; } = -1;
+}