started entities

This commit is contained in:
Boki 2026-03-01 14:09:00 -05:00
parent 6e9d89b045
commit 69a8eaea62
5 changed files with 352 additions and 3 deletions

View file

@ -17,7 +17,7 @@
"AreaHashOffset": 236,
"ServerDataOffset": 2544,
"LocalPlayerDirectOffset": 2576,
"EntityListOffset": 2808,
"EntityListOffset": 2896,
"EntityCountInternalOffset": 8,
"LocalPlayerOffset": 32,
"ComponentListOffset": 16,

View file

@ -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<EntityInfo>? 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<long>(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
}
}
/// <summary>
/// Diagnostic: walks the entity std::map (red-black tree) from AreaInstance, reports RTTI types and positions.
/// </summary>
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<long>(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<string, int>();
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<uint>(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();
}
/// <summary>
/// Reads entity list into the snapshot for continuous display.
/// Called from ReadSnapshot() when entity count > 0.
/// </summary>
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<EntityInfo>();
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<uint>(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;
}
/// <summary>
/// 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".
/// </summary>
private void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action<nint> visitor)
{
if (root == 0 || root == sentinel) return;
var stack = new Stack<nint>();
var current = root;
var count = 0;
var visited = new HashSet<nint> { sentinel };
while ((current != sentinel && current != 0) || stack.Count > 0)
{
// 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);
}
}
/// <summary>
/// Tries to read position from an entity by scanning its component list for the Render component.
/// </summary>
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;
}
/// <summary>
/// Reads position floats from a component pointer and validates them as world coordinates.
/// </summary>
private bool TryReadPositionRaw(nint comp, out float x, out float y, out float z)
{
x = _memory!.Read<float>(comp + _offsets.PositionXOffset);
y = _memory.Read<float>(comp + _offsets.PositionYOffset);
z = _memory.Read<float>(comp + _offsets.PositionZOffset);
if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) return false;
if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) return false;
if (x < 50 || x > 50000 || y < 50 || y > 50000) return false;
if (MathF.Abs(z) > 5000) return false;
return true;
}
/// <summary>
/// Reads entity path string via EntityDetailsPtr → std::string.
/// Path looks like "Metadata/Monsters/...", "Metadata/Effects/...", etc.
/// </summary>
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);
}
/// <summary>
/// Reads an MSVC std::wstring (UTF-16) from the given address.
/// Layout: _Bx (16 bytes: SSO buffer or heap ptr), _Mysize (8), _Myres (8).
/// wchar_t is 2 bytes on Windows. SSO threshold: capacity &lt;= 7 (16 bytes / 2 = 8 wchars, minus null).
/// </summary>
private string? ReadMsvcWString(nint stringAddr)
{
var size = _memory!.Read<long>(stringAddr + 0x10); // count of wchar_t
var capacity = _memory.Read<long>(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;
}
/// <summary>
/// Raw explorer: parse hex address, follow offset chain, read as specified type.
/// </summary>

View file

@ -67,11 +67,31 @@ public sealed class TerrainOffsets
public int ServerDataOffset { get; set; } = 0x9F0;
/// <summary>AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr).</summary>
public int LocalPlayerDirectOffset { get; set; } = 0xA10;
/// <summary>AreaInstance → EntityListStruct offset (dump: 0xAF8). Contains StdMap AwakeEntities then SleepingEntities.</summary>
public int EntityListOffset { get; set; } = 0xAF8;
/// <summary>AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50). Contains StdMap AwakeEntities then SleepingEntities.</summary>
public int EntityListOffset { get; set; } = 0xB50;
/// <summary>Offset within StdMap to _Mysize (entity count). MSVC std::map: head(8) + size(8).</summary>
public int EntityCountInternalOffset { get; set; } = 0x08;
// ── Entity list node layout (MSVC std::map red-black tree) ──
// Node: _Left(+0x00), _Parent(+0x08), _Right(+0x10), _Color(+0x18), _Myval(+0x20)
// _Myval = pair<uint32 key, Entity* value>
/// <summary>Tree node → left child pointer.</summary>
public int EntityNodeLeftOffset { get; set; } = 0x00;
/// <summary>Tree node → parent pointer.</summary>
public int EntityNodeParentOffset { get; set; } = 0x08;
/// <summary>Tree node → right child pointer.</summary>
public int EntityNodeRightOffset { get; set; } = 0x10;
/// <summary>Tree node → entity pointer (pair value at +0x28).</summary>
public int EntityNodeValueOffset { get; set; } = 0x28;
/// <summary>Entity → uint ID offset (EntityOffsets: +0x80).</summary>
public int EntityIdOffset { get; set; } = 0x80;
/// <summary>Entity → IsValid byte offset (EntityOffsets: +0x84).</summary>
public int EntityFlagsOffset { get; set; } = 0x84;
/// <summary>Entity → EntityDetailsPtr (Head/MainObject pointer, +0x08).</summary>
public int EntityDetailsOffset { get; set; } = 0x08;
/// <summary>EntityDetails → std::string path (MSVC layout: ptr/SSO at +0, size at +0x10, capacity at +0x18). Offset within EntityDetails struct.</summary>
public int EntityPathStringOffset { get; set; } = 0x08;
// ServerData → fields
/// <summary>ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).</summary>
public int LocalPlayerOffset { get; set; } = 0x20;

View file

@ -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()
{

View file

@ -749,6 +749,8 @@
Padding="10,4" FontWeight="Bold" />
<Button Content="Scan Position" Command="{Binding ScanPositionExecuteCommand}"
Padding="10,4" FontWeight="Bold" />
<Button Content="Scan Entities" Command="{Binding ScanEntitiesExecuteCommand}"
Padding="10,4" FontWeight="Bold" />
</StackPanel>
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
FontSize="10" Foreground="#e6edf3" Background="#0d1117"