started entities
This commit is contained in:
parent
6e9d89b045
commit
69a8eaea62
5 changed files with 352 additions and 3 deletions
|
|
@ -17,7 +17,7 @@
|
|||
"AreaHashOffset": 236,
|
||||
"ServerDataOffset": 2544,
|
||||
"LocalPlayerDirectOffset": 2576,
|
||||
"EntityListOffset": 2808,
|
||||
"EntityListOffset": 2896,
|
||||
"EntityCountInternalOffset": 8,
|
||||
"LocalPlayerOffset": 32,
|
||||
"ComponentListOffset": 16,
|
||||
|
|
|
|||
|
|
@ -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 <= 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue