diff --git a/offsets.json b/offsets.json index 1e1411d..5fbd7bb 100644 --- a/offsets.json +++ b/offsets.json @@ -17,7 +17,7 @@ "AreaHashOffset": 236, "ServerDataOffset": 2544, "LocalPlayerDirectOffset": 2576, - "EntityListOffset": 2808, + "EntityListOffset": 2896, "EntityCountInternalOffset": 8, "LocalPlayerOffset": 32, "ComponentListOffset": 16, diff --git a/src/Automata.Memory/GameMemoryReader.cs b/src/Automata.Memory/GameMemoryReader.cs index 669f4f5..e51b017 100644 --- a/src/Automata.Memory/GameMemoryReader.cs +++ b/src/Automata.Memory/GameMemoryReader.cs @@ -4,6 +4,8 @@ using Serilog; namespace Automata.Memory; +public record EntityInfo(uint Id, string? Path, string? Type, float X, float Y, float Z, bool HasPosition); + public class GameStateSnapshot { // Process @@ -41,6 +43,7 @@ public class GameStateSnapshot // Entities public int EntityCount; + public List? Entities; // Terrain public int TerrainWidth, TerrainHeight; @@ -183,7 +186,10 @@ public class GameMemoryReader : IDisposable // Entity count — dump: EntityListStruct contains StdMap, count at StdMap+0x08 (_Mysize) var entityCount = (int)_memory.Read(ingameData + _offsets.EntityListOffset + _offsets.EntityCountInternalOffset); if (entityCount > 0 && entityCount < 50000) + { snap.EntityCount = entityCount; + ReadEntities(snap, ingameData); + } // Player vitals & position — ECS: LocalPlayer → ComponentList → Life/Render components if (snap.LocalPlayerPtr != 0) @@ -560,6 +566,273 @@ public class GameMemoryReader : IDisposable } } + /// + /// Diagnostic: walks the entity std::map (red-black tree) from AreaInstance, reports RTTI types and positions. + /// + public string ScanEntities() + { + if (_memory is null) return "Error: not attached"; + if (_gameStateBase == 0) return "Error: GameState base not resolved"; + + var snap = new GameStateSnapshot(); + var inGameState = ResolveInGameState(snap); + if (inGameState == 0) return "Error: InGameState not resolved"; + + var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset); + if (ingameData == 0) return "Error: AreaInstance not resolved"; + + var sb = new StringBuilder(); + sb.AppendLine($"AreaInstance: 0x{ingameData:X}"); + + // Read sentinel (tree head node) pointer + var sentinel = _memory.ReadPointer(ingameData + _offsets.EntityListOffset); + if (sentinel == 0) + { + sb.AppendLine("Entity tree sentinel is null"); + return sb.ToString(); + } + + var entityCount = (int)_memory.Read(ingameData + _offsets.EntityListOffset + _offsets.EntityCountInternalOffset); + + // Sentinel layout: _Left = min node, _Parent = root, _Right = max node + var root = _memory.ReadPointer(sentinel + _offsets.EntityNodeParentOffset); + sb.AppendLine($"Sentinel: 0x{sentinel:X}"); + sb.AppendLine($"Root: 0x{root:X}"); + sb.AppendLine($"Expected count: {entityCount}"); + sb.AppendLine(new string('═', 90)); + + // In-order tree traversal + var entities = new List<(uint id, string? path, float x, float y, float z, bool hasPos)>(); + var pathCounts = new Dictionary(); + var maxNodes = Math.Min(entityCount + 10, 500); + + WalkTreeInOrder(sentinel, root, maxNodes, node => + { + var entityPtr = _memory.ReadPointer(node + _offsets.EntityNodeValueOffset); + uint entityId = 0; + string? path = null; + float x = 0, y = 0, z = 0; + var hasPos = false; + + if (entityPtr != 0) + { + var high = (ulong)entityPtr >> 32; + if (high > 0 && high < 0x7FFF && (entityPtr & 0x3) == 0) + { + entityId = _memory.Read(entityPtr + _offsets.EntityIdOffset); + path = TryReadEntityPath(entityPtr); + hasPos = TryReadEntityPosition(entityPtr, out x, out y, out z); + } + } + + // Derive short type from path: "Metadata/Monsters/Foo/Bar" → "Monsters" + var shortType = "?"; + if (path is not null) + { + var parts = path.Split('/'); + if (parts.Length >= 2) + shortType = parts[1]; // e.g. "Monsters", "Effects", "NPC", "MiscellaneousObjects" + } + + pathCounts[shortType] = pathCounts.GetValueOrDefault(shortType) + 1; + entities.Add((entityId, path, x, y, z, hasPos)); + + if (entities.Count <= 50) + { + var posStr = hasPos ? $"({x:F1}, {y:F1}, {z:F1})" : "no pos"; + var displayPath = path ?? "?"; + if (displayPath.Length > 60) + displayPath = "..." + displayPath[^57..]; + sb.AppendLine($"[{entities.Count - 1,3}] ID={entityId,-10} {displayPath}"); + sb.AppendLine($" {posStr}"); + } + }); + + // Summary + sb.AppendLine(new string('─', 90)); + sb.AppendLine($"Total entities walked: {entities.Count}"); + sb.AppendLine($"With position: {entities.Count(e => e.hasPos)}"); + sb.AppendLine($"With path: {entities.Count(e => e.path is not null)}"); + sb.AppendLine(); + sb.AppendLine("Type counts:"); + foreach (var (type, count) in pathCounts.OrderByDescending(kv => kv.Value)) + sb.AppendLine($" {type}: {count}"); + + return sb.ToString(); + } + + /// + /// Reads entity list into the snapshot for continuous display. + /// Called from ReadSnapshot() when entity count > 0. + /// + private void ReadEntities(GameStateSnapshot snap, nint areaInstance) + { + var sentinel = _memory!.ReadPointer(areaInstance + _offsets.EntityListOffset); + if (sentinel == 0) return; + + var root = _memory.ReadPointer(sentinel + _offsets.EntityNodeParentOffset); + var entities = new List(); + var maxNodes = Math.Min(snap.EntityCount + 10, 500); + + WalkTreeInOrder(sentinel, root, maxNodes, node => + { + var entityPtr = _memory.ReadPointer(node + _offsets.EntityNodeValueOffset); + if (entityPtr == 0) return; + + var high = (ulong)entityPtr >> 32; + if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return; + + var entityId = _memory.Read(entityPtr + _offsets.EntityIdOffset); + var path = TryReadEntityPath(entityPtr); + + var hasPos = TryReadEntityPosition(entityPtr, out var x, out var y, out var z); + entities.Add(new EntityInfo(entityId, path, null, x, y, z, hasPos)); + }); + + snap.Entities = entities; + } + + /// + /// Iterative in-order traversal of an MSVC std::map red-black tree. + /// Sentinel node is the "nil" node — left/right children equal to sentinel mean "no child". + /// + private void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action visitor) + { + if (root == 0 || root == sentinel) return; + + var stack = new Stack(); + var current = root; + var count = 0; + var visited = new HashSet { sentinel }; + + while ((current != sentinel && current != 0) || stack.Count > 0) + { + // Go left as far as possible + while (current != sentinel && current != 0) + { + if (!visited.Add(current)) + { + current = sentinel; // break out of cycle + break; + } + stack.Push(current); + current = _memory!.ReadPointer(current + _offsets.EntityNodeLeftOffset); + } + + if (stack.Count == 0) break; + + current = stack.Pop(); + visitor(current); + count++; + if (count >= maxNodes) break; + + // Move to right subtree + current = _memory!.ReadPointer(current + _offsets.EntityNodeRightOffset); + } + } + + /// + /// Tries to read position from an entity by scanning its component list for the Render component. + /// + private bool TryReadEntityPosition(nint entity, out float x, out float y, out float z) + { + x = y = z = 0; + var (compFirst, count) = FindComponentList(entity); + if (count <= 0) return false; + + // If we know the Render component index, try it directly + if (_offsets.RenderComponentIndex >= 0 && _offsets.RenderComponentIndex < count) + { + var renderComp = _memory!.ReadPointer(compFirst + _offsets.RenderComponentIndex * 8); + if (renderComp != 0 && TryReadPositionRaw(renderComp, out x, out y, out z)) + return true; + } + + // Scan components (limit to avoid performance issues with many entities) + var scanLimit = Math.Min(count, 20); + for (var i = 0; i < scanLimit; i++) + { + var compPtr = _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 (TryReadPositionRaw(compPtr, out x, out y, out z)) + return true; + } + + return false; + } + + /// + /// Reads position floats from a component pointer and validates them as world coordinates. + /// + private bool TryReadPositionRaw(nint comp, out float x, out float y, out float z) + { + x = _memory!.Read(comp + _offsets.PositionXOffset); + y = _memory.Read(comp + _offsets.PositionYOffset); + z = _memory.Read(comp + _offsets.PositionZOffset); + + if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) return false; + if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) return false; + if (x < 50 || x > 50000 || y < 50 || y > 50000) return false; + if (MathF.Abs(z) > 5000) return false; + return true; + } + + /// + /// Reads entity path string via EntityDetailsPtr → std::string. + /// Path looks like "Metadata/Monsters/...", "Metadata/Effects/...", etc. + /// + private string? TryReadEntityPath(nint entity) + { + var detailsPtr = _memory!.ReadPointer(entity + _offsets.EntityDetailsOffset); + if (detailsPtr == 0) return null; + + var high = (ulong)detailsPtr >> 32; + if (high == 0 || high >= 0x7FFF) return null; + + return ReadMsvcWString(detailsPtr + _offsets.EntityPathStringOffset); + } + + /// + /// Reads an MSVC std::wstring (UTF-16) from the given address. + /// Layout: _Bx (16 bytes: SSO buffer or heap ptr), _Mysize (8), _Myres (8). + /// wchar_t is 2 bytes on Windows. SSO threshold: capacity <= 7 (16 bytes / 2 = 8 wchars, minus null). + /// + private string? ReadMsvcWString(nint stringAddr) + { + var size = _memory!.Read(stringAddr + 0x10); // count of wchar_t + var capacity = _memory.Read(stringAddr + 0x18); // capacity in wchar_t + + if (size <= 0 || size > 512 || capacity < size) return null; + + nint dataAddr; + if (capacity <= 7) + { + // SSO: data is inline in the 16-byte _Bx buffer + dataAddr = stringAddr; + } + else + { + // Heap-allocated: _Bx contains pointer to data + dataAddr = _memory.ReadPointer(stringAddr); + if (dataAddr == 0) return null; + } + + var bytes = _memory.ReadBytes(dataAddr, (int)size * 2); + if (bytes is null) return null; + + var str = Encoding.Unicode.GetString(bytes); + + // Sanity check: should be printable ASCII-range characters + if (str.Length > 0 && str[0] >= 0x20 && str[0] <= 0x7E) + return str; + + return null; + } + /// /// Raw explorer: parse hex address, follow offset chain, read as specified type. /// diff --git a/src/Automata.Memory/TerrainOffsets.cs b/src/Automata.Memory/TerrainOffsets.cs index 3c1581d..85cb1f1 100644 --- a/src/Automata.Memory/TerrainOffsets.cs +++ b/src/Automata.Memory/TerrainOffsets.cs @@ -67,11 +67,31 @@ public sealed class TerrainOffsets public int ServerDataOffset { get; set; } = 0x9F0; /// AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr). public int LocalPlayerDirectOffset { get; set; } = 0xA10; - /// AreaInstance → EntityListStruct offset (dump: 0xAF8). Contains StdMap AwakeEntities then SleepingEntities. - public int EntityListOffset { get; set; } = 0xAF8; + /// AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50). Contains StdMap AwakeEntities then SleepingEntities. + public int EntityListOffset { get; set; } = 0xB50; /// Offset within StdMap to _Mysize (entity count). MSVC std::map: head(8) + size(8). public int EntityCountInternalOffset { get; set; } = 0x08; + // ── Entity list node layout (MSVC std::map red-black tree) ── + // Node: _Left(+0x00), _Parent(+0x08), _Right(+0x10), _Color(+0x18), _Myval(+0x20) + // _Myval = pair + /// Tree node → left child pointer. + public int EntityNodeLeftOffset { get; set; } = 0x00; + /// Tree node → parent pointer. + public int EntityNodeParentOffset { get; set; } = 0x08; + /// Tree node → right child pointer. + public int EntityNodeRightOffset { get; set; } = 0x10; + /// Tree node → entity pointer (pair value at +0x28). + public int EntityNodeValueOffset { get; set; } = 0x28; + /// Entity → uint ID offset (EntityOffsets: +0x80). + public int EntityIdOffset { get; set; } = 0x80; + /// Entity → IsValid byte offset (EntityOffsets: +0x84). + public int EntityFlagsOffset { get; set; } = 0x84; + /// Entity → EntityDetailsPtr (Head/MainObject pointer, +0x08). + public int EntityDetailsOffset { get; set; } = 0x08; + /// EntityDetails → std::string path (MSVC layout: ptr/SSO at +0, size at +0x10, capacity at +0x18). Offset within EntityDetails struct. + public int EntityPathStringOffset { get; set; } = 0x08; + // ServerData → fields /// ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0). public int LocalPlayerOffset { get; set; } = 0x20; diff --git a/src/Automata.Ui/ViewModels/MemoryViewModel.cs b/src/Automata.Ui/ViewModels/MemoryViewModel.cs index ef5cc0e..ae48332 100644 --- a/src/Automata.Ui/ViewModels/MemoryViewModel.cs +++ b/src/Automata.Ui/ViewModels/MemoryViewModel.cs @@ -77,6 +77,8 @@ public partial class MemoryViewModel : ObservableObject private MemoryNodeViewModel? _playerEs; private MemoryNodeViewModel? _terrainCells; private MemoryNodeViewModel? _terrainGrid; + private MemoryNodeViewModel? _entitySummary; + private MemoryNodeViewModel? _entityTypesNode; partial void OnIsEnabledChanged(bool value) { @@ -170,6 +172,13 @@ public partial class MemoryViewModel : ObservableObject player.Children.Add(_playerMana); player.Children.Add(_playerEs); + // Entities + var entitiesGroup = new MemoryNodeViewModel("Entities"); + _entitySummary = new MemoryNodeViewModel("Summary:"); + _entityTypesNode = new MemoryNodeViewModel("Types:") { IsExpanded = false }; + entitiesGroup.Children.Add(_entitySummary); + entitiesGroup.Children.Add(_entityTypesNode); + // Terrain var terrain = new MemoryNodeViewModel("Terrain"); _terrainCells = new MemoryNodeViewModel("Cells:"); @@ -179,6 +188,7 @@ public partial class MemoryViewModel : ObservableObject inGameStateGroup.Children.Add(areaInstanceGroup); inGameStateGroup.Children.Add(player); + inGameStateGroup.Children.Add(entitiesGroup); inGameStateGroup.Children.Add(terrain); RootNodes.Add(process); @@ -279,6 +289,38 @@ public partial class MemoryViewModel : ObservableObject _playerEs!.Set("? (set LifeComponentIndex)", false); } + // Entities + if (snap.Entities is { Count: > 0 }) + { + var withPath = snap.Entities.Count(e => e.Path is not null); + var withPos = snap.Entities.Count(e => e.HasPosition); + _entitySummary!.Set($"{snap.Entities.Count} entities, {withPath} with path, {withPos} with pos"); + + // Group by path category: "Metadata/Monsters/..." → "Monsters" + var typeCounts = snap.Entities + .GroupBy(e => + { + if (e.Path is null) return "?"; + var parts = e.Path.Split('/'); + return parts.Length >= 2 ? parts[1] : "?"; + }) + .OrderByDescending(g => g.Count()) + .Take(20); + + _entityTypesNode!.Children.Clear(); + foreach (var group in typeCounts) + { + var node = new MemoryNodeViewModel($"{group.Key}:"); + node.Set(group.Count().ToString()); + _entityTypesNode.Children.Add(node); + } + } + else + { + _entitySummary!.Set("—", false); + _entityTypesNode!.Children.Clear(); + } + // Terrain if (snap.TerrainCols > 0 && snap.TerrainRows > 0) { @@ -411,6 +453,18 @@ public partial class MemoryViewModel : ObservableObject ScanResult = _reader.DiagnoseEntity(); } + [RelayCommand] + private void ScanEntitiesExecute() + { + if (_reader is null || !_reader.IsAttached) + { + ScanResult = "Error: not attached"; + return; + } + + ScanResult = _reader.ScanEntities(); + } + [RelayCommand] private void ScanStructureExecute() { diff --git a/src/Automata.Ui/Views/MainWindow.axaml b/src/Automata.Ui/Views/MainWindow.axaml index 07687ab..3b7432b 100644 --- a/src/Automata.Ui/Views/MainWindow.axaml +++ b/src/Automata.Ui/Views/MainWindow.axaml @@ -749,6 +749,8 @@ Padding="10,4" FontWeight="Bold" />