This commit is contained in:
Boki 2026-03-03 12:54:30 -05:00
parent a8341e8232
commit a8c43ba7e2
43 changed files with 2618 additions and 48 deletions

View file

@ -291,6 +291,19 @@ public sealed class ComponentReader
return true;
}
/// <summary>
/// Reads the player character name from the Player component.
/// </summary>
public string? ReadPlayerName(nint localPlayerEntity)
{
if (localPlayerEntity == 0) return null;
var playerComp = GetComponentAddress(localPlayerEntity, "Player");
if (playerComp == 0) return null;
return _strings.ReadMsvcWString(playerComp + 0x1B0);
}
/// <summary>
/// Resolves EntityDetails pointer for an entity, handling ECS inner entity wrapper.
/// </summary>

View file

@ -62,6 +62,7 @@ public class Entity
// AreaTransition destination (raw area ID, e.g. "G1_4")
public string? TransitionName { get; internal set; }
public int TransitionState { get; internal set; } = -1;
// Action state (from Actor component)
public short ActionId { get; internal set; }

View file

@ -80,6 +80,17 @@ public sealed class EntityReader
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
// Read Targetable for any entity that has it
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<Targetable>(targetComp);
entity.IsTargetable = targetable.IsTargetable != 0;
}
}
// Read HP/Actor/Mods for monsters
if (entity.Components.Contains("Monster"))
{
@ -119,7 +130,7 @@ public sealed class EntityReader
}
}
// Read AreaTransition destination name
// Read AreaTransition destination + Transitionable state
if (entity.Components.Contains("AreaTransition") &&
lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compCount)
{
@ -127,6 +138,17 @@ public sealed class EntityReader
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<Transitionable>(trComp);
entity.TransitionState = tr.CurrentStateEnum;
}
}
}
}

View file

@ -40,6 +40,7 @@ public class GameMemoryReader : IDisposable
private MsvcStringReader? _strings;
private RttiResolver? _rtti;
private SkillReader? _skills;
private QuestReader? _quests;
public ObjectRegistry Registry => _registry;
public MemoryDiagnostics? Diagnostics { get; private set; }
@ -99,6 +100,7 @@ public class GameMemoryReader : IDisposable
_entities = new EntityReader(_ctx, _components, _strings);
_terrain = new TerrainReader(_ctx);
_skills = new SkillReader(_ctx, _components, _strings);
_quests = new QuestReader(_ctx, _strings);
Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
return true;
@ -116,6 +118,7 @@ public class GameMemoryReader : IDisposable
_strings = null;
_rtti = null;
_skills = null;
_quests = null;
Diagnostics = null;
}
@ -219,7 +222,9 @@ public class GameMemoryReader : IDisposable
_components.InvalidateCaches(snap.LocalPlayerPtr);
_components.ReadPlayerVitals(snap);
_components.ReadPlayerPosition(snap);
snap.CharacterName = _components.ReadPlayerName(snap.LocalPlayerPtr);
snap.PlayerSkills = _skills!.ReadPlayerSkills(snap.LocalPlayerPtr);
snap.QuestFlags = _quests!.ReadQuestFlags(snap.ServerDataPtr);
}
// Read state flag bytes

View file

@ -90,6 +90,24 @@ public sealed class GameOffsets
// ServerData → fields
/// <summary>ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).</summary>
public int LocalPlayerOffset { get; set; } = 0x20;
/// <summary>ServerData → PlayerServerData pointer (PerPlayerServerData struct).</summary>
public int PlayerServerDataOffset { get; set; } = 0x50;
/// <summary>PlayerServerData → QuestFlags container offset (PerPlayerServerDataOffsets: 0x230 = 560).</summary>
public int QuestFlagsOffset { get; set; } = 0x230;
/// <summary>Size of each quest flag entry in bytes. 0 = disabled (offsets not yet discovered via CE).</summary>
public int QuestFlagEntrySize { get; set; } = 0;
/// <summary>Offset within each quest entry to the Quest.dat row pointer.</summary>
public int QuestEntryQuestPtrOffset { get; set; } = 0;
/// <summary>Offset within each quest entry to the byte state ID.</summary>
public int QuestEntryStateIdOffset { get; set; } = 0;
/// <summary>Offset within each quest entry to the wchar* state text pointer.</summary>
public int QuestEntryStateTextOffset { get; set; } = 0;
/// <summary>Offset within each quest entry to the wchar* progress text pointer.</summary>
public int QuestEntryProgressTextOffset { get; set; } = 0;
/// <summary>Container type for quest flags: "vector" or "map".</summary>
public string QuestFlagsContainerType { get; set; } = "vector";
/// <summary>Maximum number of quest entries to read (sanity limit).</summary>
public int QuestFlagsMaxEntries { get; set; } = 128;
// ── Entity / Component ──
public int ComponentListOffset { get; set; } = 0x10;

View file

@ -29,6 +29,9 @@ public class GameStateSnapshot
public int AreaLevel;
public uint AreaHash;
// Player
public string? CharacterName;
// Player position (Render component)
public bool HasPosition;
public float PlayerX, PlayerY, PlayerZ;
@ -63,6 +66,9 @@ public class GameStateSnapshot
// Player skills (from Actor component)
public List<SkillSnapshot>? PlayerSkills;
// Quest flags (from ServerData → PlayerServerData)
public List<QuestSnapshot>? QuestFlags;
// Camera
public Matrix4x4? CameraMatrix;

View file

@ -3834,4 +3834,288 @@ public sealed class MemoryDiagnostics
result.AppendLine("Click again to capture a new baseline.");
return result.ToString();
}
/// <summary>
/// CE discovery diagnostic for quest flags. Follows the pointer chain:
/// AreaInstance → ServerData → PlayerServerData (StdVector) → QuestFlags.
/// When ServerData is null, dumps hex around the configured offset to help
/// discover the correct one. PlayerServerDataPtr is a StdVector of pointers,
/// so an extra dereference is needed.
/// </summary>
public string ScanQuestFlags()
{
if (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return "Error: InGameState not resolved";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: AreaInstance not resolved";
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var sb = new StringBuilder();
sb.AppendLine($"AreaInstance: 0x{ingameData:X}");
// Verify AreaInstance is valid by checking LocalPlayer
var localPlayer = mem.ReadPointer(ingameData + offsets.LocalPlayerDirectOffset);
sb.AppendLine($"LocalPlayer (+0x{offsets.LocalPlayerDirectOffset:X}): 0x{localPlayer:X} {(localPlayer != 0 ? "OK" : "NULL")}");
// ServerData pointer
var serverData = mem.ReadPointer(ingameData + offsets.ServerDataOffset);
sb.AppendLine($"ServerData (+0x{offsets.ServerDataOffset:X}): 0x{serverData:X}");
if (serverData == 0)
{
sb.AppendLine();
sb.AppendLine("ServerData is null at configured offset.");
if (localPlayer != 0)
sb.AppendLine("LocalPlayer IS valid — offset 0x9F0 may have shifted.");
// Scan AreaInstance around the expected region for heap pointers
sb.AppendLine();
sb.AppendLine("Scanning AreaInstance 0x980..0xA30 for heap pointers:");
sb.AppendLine(new string('─', 80));
const int scanStart = 0x980;
const int scanLen = 0xB0;
var scanData = mem.ReadBytes(ingameData + scanStart, scanLen);
if (scanData is not null)
{
for (var off = 0; off + 8 <= scanData.Length; off += 8)
{
var val = (nint)BitConverter.ToInt64(scanData, off);
var absOff = scanStart + off;
var hexBytes = BitConverter.ToString(scanData, off, 8).Replace("-", " ");
var annotation = "";
if (val != 0)
{
var high = (ulong)val >> 32;
if (high is > 0 and < 0x7FFF && (val & 0x3) == 0)
{
annotation = " [ptr]";
// Check if target has a vtable
var targetVtable = mem.ReadPointer(val);
if (targetVtable != 0 && _ctx.IsModuleAddress(targetVtable))
{
var rtti = _rtti.ResolveRttiName(targetVtable);
annotation = rtti is not null ? $" → vtable: {rtti}" : " → vtable (no RTTI)";
}
else if (targetVtable != 0)
{
// Check if target+0x50 looks like a StdVector (ServerData candidate)
var t50_0 = mem.ReadPointer(val + 0x50);
var t50_1 = mem.ReadPointer(val + 0x58);
if (t50_0 != 0 && t50_1 > t50_0)
annotation += $" → has StdVector at +0x50 (begin=0x{t50_0:X}, size={(int)(t50_1-t50_0)})";
}
}
else if (val == localPlayer)
{
annotation = " = LocalPlayer";
}
}
var marker = absOff == offsets.ServerDataOffset ? " ◄ configured" : "";
sb.AppendLine($" +0x{absOff:X3}: {hexBytes} (0x{val:X16}){annotation}{marker}");
}
}
sb.AppendLine();
sb.AppendLine("Look for a heap pointer with a StdVector at +0x50 — that's likely ServerData.");
sb.AppendLine("Update ServerDataOffset in offsets.json and re-run.");
return sb.ToString();
}
sb.AppendLine(new string('═', 80));
// PlayerServerData — ExileCore: ServerData+0x50 is StdVector (begin/end/cap)
// The vector contains pointers to PerPlayerServerData structs
var psdVecBegin = mem.ReadPointer(serverData + offsets.PlayerServerDataOffset);
var psdVecEnd = mem.ReadPointer(serverData + offsets.PlayerServerDataOffset + 8);
sb.AppendLine($"PlayerServerData StdVector (+0x{offsets.PlayerServerDataOffset:X}):");
sb.AppendLine($" begin: 0x{psdVecBegin:X}");
sb.AppendLine($" end: 0x{psdVecEnd:X}");
if (psdVecBegin == 0)
{
sb.AppendLine("Error: PlayerServerData vector begin is null");
// Dump hex around ServerData+0x50 for discovery
sb.AppendLine();
sb.AppendLine("Hex dump at ServerData+0x40..0x70:");
DumpHexRegion(sb, mem, serverData + 0x40, 0x30, 0x40);
return sb.ToString();
}
// Dereference: vector entry is a pointer to PerPlayerServerData
var playerServerData = mem.ReadPointer(psdVecBegin);
var vecEntries = psdVecEnd > psdVecBegin ? (int)(psdVecEnd - psdVecBegin) / 8 : 0;
sb.AppendLine($" entries: {vecEntries} (vector of pointers, 8 bytes each)");
sb.AppendLine($" [0] → PlayerServerData: 0x{playerServerData:X}");
if (playerServerData == 0)
{
sb.AppendLine("Error: PlayerServerData[0] pointer is null");
return sb.ToString();
}
// QuestFlags region at PlayerServerData + 0x230
var questFlagsAddr = playerServerData + offsets.QuestFlagsOffset;
sb.AppendLine($"QuestFlags addr (+0x{offsets.QuestFlagsOffset:X}): 0x{questFlagsAddr:X}");
sb.AppendLine(new string('═', 80));
// Dump 256 bytes at QuestFlags address
const int dumpSize = 256;
var regionData = mem.ReadBytes(questFlagsAddr, dumpSize);
if (regionData is null)
{
sb.AppendLine("Error: failed to read QuestFlags region");
return sb.ToString();
}
sb.AppendLine($"\nHex dump at QuestFlags (0x{questFlagsAddr:X}), {dumpSize} bytes:");
sb.AppendLine(new string('─', 80));
// Check for StdVector pattern (three ascending pointers at +0x00/+0x08/+0x10)
nint vecBegin = 0, vecEnd = 0, vecCap = 0;
bool isVector = false;
if (regionData.Length >= 24)
{
vecBegin = (nint)BitConverter.ToInt64(regionData, 0);
vecEnd = (nint)BitConverter.ToInt64(regionData, 8);
vecCap = (nint)BitConverter.ToInt64(regionData, 16);
if (vecBegin != 0 && vecEnd > vecBegin && vecCap >= vecEnd)
{
var high1 = (ulong)vecBegin >> 32;
var high2 = (ulong)vecEnd >> 32;
if (high1 is > 0 and < 0x7FFF && high2 is > 0 and < 0x7FFF)
isVector = true;
}
}
// Annotate each qword
for (var off = 0; off + 8 <= regionData.Length; off += 8)
{
var val = (nint)BitConverter.ToInt64(regionData, off);
var hexBytes = BitConverter.ToString(regionData, off, 8).Replace("-", " ");
var annotation = "";
if (off == 0 && isVector) annotation = " ← vector.begin";
else if (off == 8 && isVector) annotation = " ← vector.end";
else if (off == 16 && isVector) annotation = " ← vector.capacity";
else if (val != 0)
{
var high = (ulong)val >> 32;
if (high is > 0 and < 0x7FFF && (val & 0x3) == 0)
{
annotation = " [heap ptr]";
var targetVal = mem.ReadPointer(val);
if (targetVal != 0 && _ctx.IsModuleAddress(targetVal))
annotation += " → vtable";
else
{
var str = _strings.ReadNullTermWString(val);
if (str is not null)
annotation += $" → \"{str}\"";
}
}
}
sb.AppendLine($" +0x{off:X3}: {hexBytes} (0x{val:X16}){annotation}");
}
// If vector pattern found, dump vector content
if (isVector)
{
var vecSize = (int)(vecEnd - vecBegin);
sb.AppendLine();
sb.AppendLine(new string('═', 80));
sb.AppendLine($"StdVector detected: begin=0x{vecBegin:X} end=0x{vecEnd:X} size={vecSize} bytes");
// Try common entry sizes
foreach (var trySize in new[] { 8, 16, 24, 32, 40, 48, 56, 64 })
{
if (vecSize % trySize == 0)
sb.AppendLine($" Divides evenly by {trySize}: {vecSize / trySize} entries");
}
// Dump first 1024 bytes of vector content
var contentSize = Math.Min(vecSize, 1024);
var content = mem.ReadBytes(vecBegin, contentSize);
if (content is not null)
{
sb.AppendLine();
sb.AppendLine($"Vector content (first {contentSize} bytes):");
sb.AppendLine(new string('─', 80));
for (var off = 0; off + 8 <= content.Length; off += 8)
{
var val = (nint)BitConverter.ToInt64(content, off);
var hexBytes = BitConverter.ToString(content, off, 8).Replace("-", " ");
var annotation = "";
if (val != 0)
{
var high = (ulong)val >> 32;
if (high is > 0 and < 0x7FFF && (val & 0x3) == 0)
{
annotation = " [ptr]";
var str = _strings.ReadNullTermWString(val);
if (str is not null)
annotation = $" → \"{str}\"";
else
{
var namePtr = mem.ReadPointer(val);
if (namePtr != 0)
{
var name = _strings.ReadNullTermWString(namePtr);
if (name is not null)
annotation = $" → dat? → \"{name}\"";
}
}
}
else if (val > 0 && val < 256)
{
annotation = $" [byte-range: {(byte)val}]";
}
}
sb.AppendLine($" vec+0x{off:X3}: {hexBytes} (0x{val:X16}){annotation}");
}
}
}
else
{
sb.AppendLine();
sb.AppendLine("No StdVector pattern detected at +0x00. Container may be a map or different layout.");
sb.AppendLine("Try adjusting QuestFlagsOffset in offsets.json and re-running.");
}
sb.AppendLine();
sb.AppendLine(new string('═', 80));
sb.AppendLine("Next steps:");
sb.AppendLine("1. Accept a quest in-game, re-run this scan, compare vector size → derive entry size");
sb.AppendLine("2. Look for heap pointers (Quest.dat row), byte values (state ID), wchar* (text)");
sb.AppendLine("3. Update QuestFlagEntrySize and other quest offsets in offsets.json");
return sb.ToString();
}
private void DumpHexRegion(StringBuilder sb, ProcessMemory mem, nint addr, int size, int baseOffset)
{
var data = mem.ReadBytes(addr, size);
if (data is null) { sb.AppendLine(" (read failed)"); return; }
for (var off = 0; off + 8 <= data.Length; off += 8)
{
var val = (nint)BitConverter.ToInt64(data, off);
var hexBytes = BitConverter.ToString(data, off, 8).Replace("-", " ");
sb.AppendLine($" +0x{baseOffset + off:X3}: {hexBytes} (0x{val:X16})");
}
}
}

View file

@ -0,0 +1,183 @@
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Lightweight quest data from ServerData quest flags.
/// Stored in GameStateSnapshot; mapped to Roboto.Core.QuestProgress in the Data layer.
/// </summary>
public sealed class QuestSnapshot
{
public nint QuestDatPtr { get; init; }
public string? QuestName { get; init; }
public byte StateId { get; init; }
public string? StateText { get; init; }
public string? ProgressText { get; init; }
}
/// <summary>
/// Reads quest flags from ServerData → PlayerServerData → QuestFlags vector.
/// Follows the same pattern as SkillReader: bulk-reads vector data, resolves
/// quest names by following dat row pointers, caches results.
/// When QuestFlagEntrySize == 0 (offsets not yet discovered), gracefully returns null.
/// </summary>
public sealed class QuestReader
{
private readonly MemoryContext _ctx;
private readonly MsvcStringReader _strings;
// Name cache — quest names are static, only refresh on ServerData change
private readonly Dictionary<nint, string?> _nameCache = new();
private nint _lastServerData;
public QuestReader(MemoryContext ctx, MsvcStringReader strings)
{
_ctx = ctx;
_strings = strings;
}
/// <summary>
/// Reads quest flags from the ServerData pointer chain.
/// Returns null if offsets are not configured (EntrySize == 0) or data is unavailable.
/// </summary>
public List<QuestSnapshot>? ReadQuestFlags(nint serverDataPtr)
{
if (serverDataPtr == 0) return null;
var offsets = _ctx.Offsets;
// Guard: entry size 0 means offsets not yet discovered via CE
if (offsets.QuestFlagEntrySize <= 0) return null;
var mem = _ctx.Memory;
// ServerData+0x50 is a StdVector of pointers to PerPlayerServerData structs.
// Read vector begin, then dereference to get the first entry.
var psdVecBegin = mem.ReadPointer(serverDataPtr + offsets.PlayerServerDataOffset);
if (psdVecBegin == 0) return null;
// Dereference: vector[0] is a pointer to the actual PerPlayerServerData struct
var playerServerData = mem.ReadPointer(psdVecBegin);
if (playerServerData == 0) return null;
// Invalidate name cache on ServerData change (area transition)
if (playerServerData != _lastServerData)
{
_nameCache.Clear();
_lastServerData = playerServerData;
}
// PerPlayerServerData → QuestFlags (+0x230)
var questFlagsAddr = playerServerData + offsets.QuestFlagsOffset;
if (offsets.QuestFlagsContainerType == "vector")
return ReadVectorQuests(questFlagsAddr, offsets);
// Future: "map" container type
return null;
}
private List<QuestSnapshot>? ReadVectorQuests(nint questFlagsAddr, GameOffsets offsets)
{
var mem = _ctx.Memory;
// StdVector: begin, end, capacity (3 pointers)
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;
// Bulk read all entries
var vecData = mem.ReadBytes(vecBegin, totalBytes);
if (vecData is null) return null;
var result = new List<QuestSnapshot>(entryCount);
for (var i = 0; i < entryCount; i++)
{
var entryOffset = i * entrySize;
// Read quest dat pointer
nint questDatPtr = 0;
if (entryOffset + offsets.QuestEntryQuestPtrOffset + 8 <= vecData.Length)
questDatPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryQuestPtrOffset);
// Read state ID byte
byte stateId = 0;
if (entryOffset + offsets.QuestEntryStateIdOffset < vecData.Length)
stateId = vecData[entryOffset + offsets.QuestEntryStateIdOffset];
// Resolve quest name from dat pointer (cached)
var questName = ResolveQuestName(questDatPtr);
// Read state text pointer and resolve
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);
}
// Read progress text pointer and resolve
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;
}
/// <summary>
/// Resolves quest name by following QuestDatPtr → dat row → wchar* name.
/// Results are cached since quest names don't change.
/// </summary>
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;
// Follow the dat row pointer — first field is typically a wchar* name
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;
}
/// <summary>Clears cached names (call on area change).</summary>
public void InvalidateCache()
{
_nameCache.Clear();
_lastServerData = 0;
}
}