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; +}