questStates done

This commit is contained in:
Boki 2026-03-05 16:44:30 -05:00
parent 445ae1387c
commit 568df4c7fe
17 changed files with 1012 additions and 40 deletions

View file

@ -43,6 +43,7 @@
"Metadata/Effects/Effect",
"Metadata/Effects/Microtransactions/Town_Portals/PersonSplitPortal/_PersonSplitPortalPrespawnDummy",
"Metadata/Effects/Microtransactions/Town_Portals/PersonSplitPortal/_PersonSplitPortalPrespawnDummyMarble",
"Metadata/Effects/Microtransactions/foot_prints/blight/footprints_blight",
"Metadata/Effects/Microtransactions/foot_prints/delirium/footprints_delirium",
"Metadata/Effects/Microtransactions/foot_prints/harvest02/footprints_harvest",
"Metadata/Effects/PermanentEffect",

View file

@ -528,7 +528,7 @@ public partial class MemoryViewModel : ObservableObject
{
var q = sorted[i];
var prefix = q.IsTracked ? "[T] " : "";
var stateLabel = $"step {q.StateId}";
var stateLabel = q.StateText ?? $"step {q.StateId}";
var label = $"{prefix}{q.DisplayName ?? q.InternalId ?? $"[{i}]"}";
var value = $"Act{q.Act} {stateLabel}";
@ -559,8 +559,8 @@ public partial class MemoryViewModel : ObservableObject
("StateId:", q.StateId.ToString()),
("IsTracked:", q.IsTracked.ToString()),
};
if (q.ObjectiveText is not null)
details.Add(("Objective:", q.ObjectiveText));
if (q.StateText is not null)
details.Add(("StateText:", q.StateText));
if (q.QuestDatPtr != 0)
details.Add(("QuestDatPtr:", $"0x{q.QuestDatPtr:X}"));

View file

@ -0,0 +1,113 @@
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Generic wrapper for a single .dat table. Bulk-reads all rows in one RPM call,
/// parses them via <see cref="IDatRowParser{T}"/>, and caches the results.
/// Supports O(1) FK resolution via <see cref="GetByAddress"/>.
/// </summary>
public sealed class DatFile<TRow> where TRow : class
{
private readonly string _fileName;
private readonly IDatRowParser<TRow> _parser;
private List<TRow>? _rows;
private Dictionary<nint, TRow>? _byAddress;
private bool _loadAttempted;
public DatFile(string fileName, IDatRowParser<TRow> parser)
{
_fileName = fileName;
_parser = parser;
}
/// <summary>All parsed rows, in order. Empty if not loaded or load failed.</summary>
public IReadOnlyList<TRow> Rows => _rows ?? (IReadOnlyList<TRow>)[];
/// <summary>Number of successfully parsed rows.</summary>
public int Count => _rows?.Count ?? 0;
/// <summary>True if Load() has been called and produced at least one row.</summary>
public bool IsLoaded => _rows is not null && _rows.Count > 0;
/// <summary>
/// Loads the .dat table from memory. Idempotent — second call is a no-op.
/// Returns true if rows were loaded successfully.
/// </summary>
public bool Load(FileRootScanner scanner, MemoryContext ctx, MsvcStringReader strings)
{
if (_loadAttempted) return IsLoaded;
_loadAttempted = true;
try
{
var tableInfo = scanner.FindDatFile(_fileName);
if (tableInfo is null)
{
Log.Debug("DatFile<{Type}>: {File} not found in FileRoot", typeof(TRow).Name, _fileName);
return false;
}
var info = tableInfo.Value;
var totalBytes = info.RowSize * info.RowCount;
if (totalBytes <= 0 || totalBytes > 10_000_000)
{
Log.Warning("DatFile<{Type}>: {File} has invalid size ({Bytes} bytes)", typeof(TRow).Name, _fileName, totalBytes);
return false;
}
var bulkData = ctx.Memory.ReadBytes(info.FirstRecord, totalBytes);
if (bulkData is null)
{
Log.Warning("DatFile<{Type}>: failed to bulk-read {File}", typeof(TRow).Name, _fileName);
return false;
}
var rows = new List<TRow>(info.RowCount);
var byAddr = new Dictionary<nint, TRow>(info.RowCount);
for (var i = 0; i < info.RowCount; i++)
{
var rowAddr = info.FirstRecord + i * info.RowSize;
var offset = i * info.RowSize;
var row = _parser.Parse(bulkData, offset, rowAddr, ctx, strings);
if (row is null) continue;
rows.Add(row);
byAddr[rowAddr] = row;
}
_rows = rows;
_byAddress = byAddr;
Log.Information("DatFile<{Type}>: loaded {Count} rows from {File}", typeof(TRow).Name, rows.Count, _fileName);
return true;
}
catch (Exception ex)
{
Log.Error(ex, "DatFile<{Type}>: error loading {File}", typeof(TRow).Name, _fileName);
return false;
}
}
/// <summary>
/// O(1) lookup by row's process-memory address. Used for FK resolution.
/// Returns null if address is not found or table not loaded.
/// </summary>
public TRow? GetByAddress(nint address)
{
if (_byAddress is null || address == 0) return null;
return _byAddress.GetValueOrDefault(address);
}
/// <summary>
/// Clears cached data so the next Load() call re-reads from memory.
/// </summary>
public void InvalidateCache()
{
_rows = null;
_byAddress = null;
_loadAttempted = false;
}
}

View file

@ -0,0 +1,537 @@
using System.Text;
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Info about a discovered .dat file in memory: first record address, row size, and row count.
/// </summary>
public readonly record struct DatTableInfo(nint FirstRecord, int RowSize, int RowCount);
/// <summary>
/// Discovers in-memory .dat files by scanning the FileRoot structure.
/// POE2 uses a StdBucket-per-block approach (GameOverlay2 layout):
/// FileRootBase → deref → 16 StdBucket blocks → StdVector of file pointers → FileInfoValueStruct.
/// FileInfoValueStruct: +0x00 = records ptr, +0x08 = StdWString Name, +0x40 = AreaChangeCount.
/// </summary>
public sealed class FileRootScanner
{
private readonly MemoryContext _ctx;
private readonly MsvcStringReader _strings;
private Dictionary<string, nint>? _fileCache;
private bool _scanAttempted;
public MemoryContext Context => _ctx;
public MsvcStringReader Strings => _strings;
public FileRootScanner(MemoryContext ctx, MsvcStringReader strings)
{
_ctx = ctx;
_strings = strings;
}
/// <summary>
/// Finds a .dat file by name (e.g. "Data/QuestStates.dat") and returns its table info.
/// Returns null if the file is not found or the header is invalid.
/// </summary>
public DatTableInfo? FindDatFile(string filename)
{
if (_ctx.FileRootBase == 0) return null;
EnsureFileCache();
if (_fileCache is null || _fileCache.Count == 0) return null;
if (!_fileCache.TryGetValue(filename, out var fileObjAddr))
{
// Try case-insensitive match
foreach (var (key, value) in _fileCache)
{
if (string.Equals(key, filename, StringComparison.OrdinalIgnoreCase))
{
fileObjAddr = value;
break;
}
}
if (fileObjAddr == 0) return null;
}
return ReadDatHeader(fileObjAddr);
}
/// <summary>Number of cached file entries discovered from FileRoot.</summary>
public int CachedFileCount => _fileCache?.Count ?? 0;
private void EnsureFileCache()
{
if (_scanAttempted) return;
_scanAttempted = true;
try
{
_fileCache = ScanFileRoot();
if (_fileCache is not null)
{
Log.Information("FileRootScanner: discovered {Count} files from FileRoot", _fileCache.Count);
var dataFiles = _fileCache.Keys.Where(k => k.StartsWith("Data/", StringComparison.OrdinalIgnoreCase)).OrderBy(k => k).ToList();
Log.Information("FileRootScanner: {Count} Data/ files found", dataFiles.Count);
foreach (var name in dataFiles)
Log.Information(" FileRoot: {Name}", name);
}
else
Log.Warning("FileRootScanner: FileRoot scan returned no files");
}
catch (Exception ex)
{
Log.Error(ex, "FileRootScanner: error scanning FileRoot");
}
}
/// <summary>
/// Scans FileRoot using two strategies:
/// 1. GameOverlay2: deref → 16 StdBucket blocks (56B each) → StdVector per block
/// 2. ExileCore: direct read → 16 flat hash blocks (40B each) → 512×8 slot buckets
/// </summary>
private Dictionary<string, nint>? ScanFileRoot()
{
var result = ScanViaStdBuckets();
if (result is not null) return result;
result = ScanViaFlatHashMap();
return result;
}
// ─── Strategy 1: GameOverlay2 StdBucket approach ───────────────────────
/// <summary>
/// GameOverlay2 layout:
/// FileRootBase → deref → array of 16 LoadedFilesRootObject (StdBucket, 56B each).
/// Each StdBucket.Data = StdVector{First, Last, End} of FilesPointerStructure (24B each).
/// FilesPointerStructure: Useless0(8) + FilesPointer(8) + Useless1(8).
/// FilesPointer → FileInfoValueStruct with StdWString Name at +0x08.
/// </summary>
private Dictionary<string, nint>? ScanViaStdBuckets()
{
var mem = _ctx.Memory;
var rootAddr = _ctx.FileRootBase;
// Step 1: Dereference FileRootBase to get the table address
var tableAddr = mem.ReadPointer(rootAddr);
if (tableAddr == 0 || !_ctx.IsValidHeapPtr(tableAddr))
{
Log.Information("FileRootScanner: StdBucket approach — deref 0x{Addr:X} → 0x{Table:X} (invalid)",
rootAddr, tableAddr);
return null;
}
Log.Information("FileRootScanner: StdBucket approach — deref 0x{Addr:X} → table at 0x{Table:X}",
rootAddr, tableAddr);
// Step 2: Read 16 StdBucket blocks (56 bytes each = 896 bytes total)
const int blockCount = 16;
const int blockSize = 56; // StdVector(24) + IntPtr(8) + Capacity(4) + pad(4) + unk(4×4)
const int totalSize = blockCount * blockSize;
var tableData = mem.ReadBytes(tableAddr, totalSize);
if (tableData is null)
{
Log.Warning("FileRootScanner: StdBucket — failed to read {Size} bytes from 0x{Addr:X}",
totalSize, tableAddr);
return null;
}
var files = new Dictionary<string, nint>(StringComparer.OrdinalIgnoreCase);
var totalElements = 0;
var totalNamed = 0;
var validBlocks = 0;
var diagDumped = 0;
for (var blockIdx = 0; blockIdx < blockCount; blockIdx++)
{
var blockOff = blockIdx * blockSize;
// StdVector: First(8) + Last(8) + End(8)
var first = (nint)BitConverter.ToInt64(tableData, blockOff);
var last = (nint)BitConverter.ToInt64(tableData, blockOff + 8);
if (first == 0 || last == 0 || last <= first) continue;
if (!_ctx.IsValidHeapPtr(first) || !_ctx.IsValidHeapPtr(last)) continue;
var vecBytes = (int)(last - first);
if (vecBytes <= 0 || vecBytes > 10_000_000) continue;
validBlocks++;
// Each element is FilesPointerStructure (24 bytes)
const int elemSize = 24;
var elemCount = vecBytes / elemSize;
if (elemCount == 0)
{
// Try as raw pointer array (8 bytes per element) in case layout differs
elemCount = vecBytes / 8;
if (elemCount == 0) continue;
Log.Information("FileRootScanner: block[{Idx}] vecBytes={Bytes} — not divisible by 24, trying as raw ptrs ({Count})",
blockIdx, vecBytes, elemCount);
var rawData = mem.ReadBytes(first, vecBytes);
if (rawData is null) continue;
for (var i = 0; i < elemCount; i++)
{
totalElements++;
var ptr = (nint)BitConverter.ToInt64(rawData, i * 8);
if (ptr == 0 || !_ctx.IsValidHeapPtr(ptr)) continue;
var name = ReadFileInfoName(ptr, ref diagDumped);
if (name is not null)
{
totalNamed++;
files.TryAdd(name, ptr);
}
}
continue;
}
Log.Information("FileRootScanner: block[{Idx}] vector: {Count} elements (first=0x{First:X})",
blockIdx, elemCount, first);
var vecData = mem.ReadBytes(first, vecBytes);
if (vecData is null) continue;
for (var i = 0; i < elemCount; i++)
{
totalElements++;
var elemOff = i * elemSize;
// FilesPointerStructure: Useless0(8) + FilesPointer(8) + Useless1(8)
var filesPtr = (nint)BitConverter.ToInt64(vecData, elemOff + 8);
if (filesPtr == 0 || !_ctx.IsValidHeapPtr(filesPtr)) continue;
var name = ReadFileInfoName(filesPtr, ref diagDumped);
if (name is not null)
{
totalNamed++;
files.TryAdd(name, filesPtr);
}
}
}
Log.Information("FileRootScanner: StdBucket — {ValidBlocks}/{Total} valid blocks, {Elements} elements, {Named} named",
validBlocks, blockCount, totalElements, totalNamed);
return files.Count > 0 ? files : null;
}
/// <summary>
/// Reads the filename from a FileInfoValueStruct.
/// Layout: +0x00 = records ptr (8B), +0x08 = StdWString Name (32B), +0x40 = AreaChangeCount (4B).
/// </summary>
private string? ReadFileInfoName(nint fileInfoAddr, ref int diagDumped)
{
var mem = _ctx.Memory;
// Read 0x28 bytes: skip first 8 (records ptr), then 32 bytes for StdWString
var data = mem.ReadBytes(fileInfoAddr, 0x28);
if (data is null) return null;
// StdWString at +0x08:
// +0x00: Buffer/SSO (16 bytes)
// +0x10: Length (int32)
// +0x18: Capacity (int32)
var length = BitConverter.ToInt32(data, 0x08 + 0x10); // file+0x18
var capacity = BitConverter.ToInt32(data, 0x08 + 0x18); // file+0x20
// Diagnostic: dump first few for debugging
if (diagDumped < 3)
{
diagDumped++;
var hex = string.Join(" ", data.Select(b => $"{b:X2}"));
Log.Information("FileRootScanner DIAG: fileInfo=0x{Addr:X} raw=[{Hex}] len={Len} cap={Cap}",
fileInfoAddr, hex, length, capacity);
}
if (length <= 0 || length > 512 || capacity < length) return null;
string name;
if (capacity <= 8) // SSO: 16 bytes / 2 = 8 wchar_t max
{
var byteLen = Math.Min(length * 2, 16);
name = Encoding.Unicode.GetString(data, 0x08, byteLen);
}
else
{
// Heap: Buffer[0:8] at +0x08 = pointer to wchar_t array
var heapPtr = (nint)BitConverter.ToInt64(data, 0x08);
if (heapPtr == 0 || !_ctx.IsValidHeapPtr(heapPtr)) return null;
var charData = mem.ReadBytes(heapPtr, Math.Min(length * 2, 1024));
if (charData is null) return null;
name = Encoding.Unicode.GetString(charData, 0, Math.Min(charData.Length, length * 2));
}
return IsValidFileName(name) ? name : null;
}
// ─── Strategy 2: ExileCore flat hash map (fallback) ────────────────────
/// <summary>
/// ExileCore layout (POE1):
/// FileRootBase → direct read → 16 blocks × 40B → bucket arrays (512 entries × 8 slots).
/// </summary>
private Dictionary<string, nint>? ScanViaFlatHashMap()
{
var mem = _ctx.Memory;
var rootAddr = _ctx.FileRootBase;
const int blockCount = 16;
const int blockSize = 40;
const int totalSize = blockCount * blockSize;
var rootData = mem.ReadBytes(rootAddr, totalSize);
if (rootData is null) return null;
// Count how many blocks have valid data pointers
var validCount = 0;
for (var i = 0; i < blockCount; i++)
{
var ptr = (nint)BitConverter.ToInt64(rootData, i * blockSize + 8);
if (ptr != 0 && _ctx.IsValidHeapPtr(ptr)) validCount++;
}
// Need at least 4/16 valid blocks to consider this a real hash table
if (validCount < 4)
{
Log.Information("FileRootScanner: FlatHashMap — only {Valid}/16 valid blocks, skipping", validCount);
return null;
}
Log.Information("FileRootScanner: FlatHashMap — {Valid}/16 valid blocks", validCount);
var files = new Dictionary<string, nint>(StringComparer.OrdinalIgnoreCase);
var diagDumped = 0;
for (var blockIdx = 0; blockIdx < blockCount; blockIdx++)
{
var dataPtr = (nint)BitConverter.ToInt64(rootData, blockIdx * blockSize + 8);
if (dataPtr == 0 || !_ctx.IsValidHeapPtr(dataPtr)) continue;
var bucketData = mem.ReadBytes(dataPtr, 100 * 1024);
if (bucketData is null) continue;
const int entriesPerBlock = 512;
const int slotsPerEntry = 8;
const int entrySize = 8 + slotsPerEntry * 24; // 200
for (var entryIdx = 0; entryIdx < entriesPerBlock; entryIdx++)
{
var entryOffset = entryIdx * entrySize;
if (entryOffset + entrySize > bucketData.Length) break;
for (var slot = 0; slot < slotsPerEntry; slot++)
{
if (bucketData[entryOffset + slot] == 0xFF) continue;
var fileObjPtrOffset = entryOffset + 8 + slot * 24 + 16;
if (fileObjPtrOffset + 8 > bucketData.Length) break;
var fileObjPtr = (nint)BitConverter.ToInt64(bucketData, fileObjPtrOffset);
if (fileObjPtr == 0 || !_ctx.IsValidHeapPtr(fileObjPtr)) continue;
var name = ReadFileInfoName(fileObjPtr, ref diagDumped);
if (name is not null)
files.TryAdd(name, fileObjPtr);
}
}
}
Log.Information("FileRootScanner: FlatHashMap — {Count} named files", files.Count);
return files.Count > 0 ? files : null;
}
// ─── Shared utilities ──────────────────────────────────────────────────
/// <summary>
/// Validates that a discovered filename looks reasonable (printable ASCII path characters).
/// </summary>
private static bool IsValidFileName(string name)
{
if (name.Length < 3) return false;
foreach (var c in name)
{
if (c < 0x20 || c > 0x7E) return false;
}
return name.Contains('/') || name.Contains('.');
}
/// <summary>
/// Reads DAT table header from a file object (FileInfoValueStruct) address.
/// Probes for the records pointer at various offsets since the layout may differ between versions.
/// Records struct: +0x00 FirstRecord (long), +0x08 LastRecord (long), +0x40 NumberOfRecords (int).
/// </summary>
private DatTableInfo? ReadDatHeader(nint fileObjAddr)
{
var mem = _ctx.Memory;
// Read a large chunk of the file object to probe for the records pointer
var objData = mem.ReadBytes(fileObjAddr, 0x80);
if (objData is null) return null;
// POE2 layout: fileObj+0x28 → buffer struct with StdVectors for row + variable data.
// Standard .dat format: [4B rowCount] [rows...] [8B 0xBB separator] [variable data...]
var bufStructPtr = (nint)BitConverter.ToInt64(objData, 0x28);
if (bufStructPtr == 0 || !_ctx.IsValidHeapPtr(bufStructPtr))
return null;
var bufStruct = mem.ReadBytes(bufStructPtr, 0x18);
if (bufStruct is null || bufStruct.Length < 0x18)
return null;
var dataStart = (nint)BitConverter.ToInt64(bufStruct, 0x00);
var dataEnd = (nint)BitConverter.ToInt64(bufStruct, 0x08);
if (dataStart == 0 || dataEnd <= dataStart || !_ctx.IsValidHeapPtr(dataStart))
return null;
var totalSize = (int)(dataEnd - dataStart);
if (totalSize <= 12 || totalSize > 100_000_000) return null;
// Bulk-read the entire data buffer to scan for the 0xBBBB separator
var data = mem.ReadBytes(dataStart, totalSize);
if (data is null) return null;
// Strategy A: Standard .dat format with separator
// Scan for 0xBBBBBBBBBBBBBBBB (8 bytes of 0xBB) — marks end of fixed rows
var separatorIdx = FindBBSeparator(data);
if (separatorIdx >= 4)
{
var rowCount = (int)BitConverter.ToUInt32(data, 0);
if (rowCount > 0 && rowCount < 100_000)
{
var rowDataBytes = separatorIdx - 4; // rows between header and separator
var rowSize = rowDataBytes / rowCount;
if (rowSize > 0 && rowSize <= 4096 && rowSize * rowCount == rowDataBytes)
{
var firstRecord = dataStart + 4;
Log.Information("FileRootScanner: table (separator@{Sep}) — {Rows} rows × {Size}B, first=0x{First:X}",
separatorIdx, rowCount, rowSize, firstRecord);
return new DatTableInfo(firstRecord, rowSize, rowCount);
}
else
{
Log.Information("FileRootScanner: separator@{Sep} but rowCount={RC} doesn't divide rowData={RD}",
separatorIdx, rowCount, rowDataBytes);
}
}
else
{
Log.Information("FileRootScanner: separator@{Sep} but header uint32={Val} (invalid row count)",
separatorIdx, BitConverter.ToUInt32(data, 0));
}
}
// Strategy B: No separator found — raw row data with no header
// Try to detect row size by finding repeating pointer pattern
var info = TryDetectRowSize(data, dataStart, totalSize);
if (info is not null) return info;
// Diagnostic
var probeHex = string.Join(" ", data.Take(32).Select(b => $"{b:X2}"));
Log.Warning("FileRootScanner: ReadDatHeader failed for 0x{Addr:X}. size={Size} first32=[{Hex}]",
fileObjAddr, totalSize, probeHex);
return null;
}
/// <summary>
/// Scans byte array for the 0xBBBBBBBBBBBBBBBB separator (8 consecutive 0xBB bytes).
/// Returns the byte offset of the separator, or -1 if not found.
/// </summary>
private static int FindBBSeparator(byte[] data)
{
if (data.Length < 8) return -1;
for (var i = 0; i <= data.Length - 8; i++)
{
if (data[i] == 0xBB && data[i + 1] == 0xBB && data[i + 2] == 0xBB && data[i + 3] == 0xBB
&& data[i + 4] == 0xBB && data[i + 5] == 0xBB && data[i + 6] == 0xBB && data[i + 7] == 0xBB)
{
return i;
}
}
return -1;
}
/// <summary>
/// Attempts to detect row size from raw row data (no header, no separator).
/// Assumes the first 8 bytes of each row are a .dat FK pointer.
/// Tests candidate row sizes by checking if bytes at each row boundary look like valid pointers.
/// </summary>
private DatTableInfo? TryDetectRowSize(byte[] data, nint dataStart, int totalSize)
{
// Get candidate row sizes from factorization of totalSize, smallest first.
// Smallest-first is critical: multiples of the true row size also score well,
// so we want the smallest passing candidate.
var candidates = new List<int>();
for (var divisor = 2; divisor <= totalSize / 2; divisor++)
{
if (totalSize % divisor != 0) continue;
var rowSize = totalSize / divisor;
if (rowSize >= 32 && rowSize <= 1024)
candidates.Add(rowSize);
}
candidates.Sort(); // smallest row size first
if (candidates.Count == 0) return null;
// Read first pointer to use as a reference for validation
if (data.Length < 8) return null;
var firstPtr = (nint)BitConverter.ToInt64(data, 0);
if (firstPtr == 0 || !_ctx.IsDatPtr(firstPtr)) return null;
// Find the smallest row size where ≥80% of sampled rows start with a valid .dat pointer.
// Return the first one that passes — it's the true row size, not a multiple.
var bestRowSize = 0;
foreach (var rowSize in candidates)
{
var rowCount = totalSize / rowSize;
if (rowCount < 2) continue;
var validPtrs = 0;
var sampleCount = Math.Min(rowCount, 50);
for (var r = 0; r < sampleCount; r++)
{
var off = r * rowSize;
if (off + 8 > data.Length) break;
var ptr = (nint)BitConverter.ToInt64(data, off);
if (ptr != 0 && _ctx.IsDatPtr(ptr))
validPtrs++;
}
if (validPtrs >= sampleCount * 80 / 100)
{
bestRowSize = rowSize;
break; // smallest passing candidate = true row size
}
}
if (bestRowSize == 0) return null;
var bestRowCount = totalSize / bestRowSize;
Log.Information("FileRootScanner: detected row layout — {Rows} rows × {Size}B, first=0x{First:X}",
bestRowCount, bestRowSize, dataStart);
return new DatTableInfo(dataStart, bestRowSize, bestRowCount);
}
/// <summary>
/// Invalidates the cached file list so next FindDatFile re-scans.
/// Call after re-attaching to a process.
/// </summary>
public void InvalidateCache()
{
_fileCache = null;
_scanAttempted = false;
}
}

View file

@ -0,0 +1,47 @@
namespace Roboto.Memory;
/// <summary>
/// Facade for all in-memory .dat file access. Owns the <see cref="FileRootScanner"/>
/// and provides lazy typed access to specific .dat tables.
/// Adding a new .dat file = add 1 record + 1 parser + 1 lazy property here.
/// </summary>
public sealed class FilesContainer
{
private readonly MemoryContext _ctx;
private readonly MsvcStringReader _strings;
public FileRootScanner Scanner { get; }
private DatFile<QuestStateDatRow>? _questStates;
public FilesContainer(MemoryContext ctx, MsvcStringReader strings)
{
_ctx = ctx;
_strings = strings;
Scanner = new FileRootScanner(ctx, strings);
}
/// <summary>QuestStates.dat — lazy-loaded on first access.</summary>
public DatFile<QuestStateDatRow> QuestStates
{
get
{
if (_questStates is null)
{
_questStates = new DatFile<QuestStateDatRow>("Data/Balance/QuestStates.dat", new QuestStateDatRowParser());
_questStates.Load(Scanner, _ctx, _strings);
}
return _questStates;
}
}
/// <summary>
/// Clears all cached data. Call after re-attaching to a process.
/// </summary>
public void InvalidateCache()
{
Scanner.InvalidateCache();
_questStates?.InvalidateCache();
_questStates = null;
}
}

View file

@ -0,0 +1,19 @@
namespace Roboto.Memory;
/// <summary>
/// Stateless parser for a single .dat row schema. One implementation per .dat file type.
/// </summary>
/// <typeparam name="T">The record type produced from each row.</typeparam>
public interface IDatRowParser<T>
{
/// <summary>
/// Parses a single row from a bulk-read buffer.
/// </summary>
/// <param name="rowData">The bulk buffer containing all rows.</param>
/// <param name="offset">Byte offset into rowData where this row starts.</param>
/// <param name="rowAddr">The process-memory address of this row (for FK resolution).</param>
/// <param name="ctx">Memory context for following pointers.</param>
/// <param name="strings">String reader for wchar* pointers.</param>
/// <returns>Parsed record, or null if the row is invalid/corrupt.</returns>
T? Parse(byte[] rowData, int offset, nint rowAddr, MemoryContext ctx, MsvcStringReader strings);
}

View file

@ -0,0 +1,55 @@
namespace Roboto.Memory;
/// <summary>
/// Parsed row from Quest.dat. Fields mirror the in-memory Quest object layout.
/// </summary>
public sealed record QuestDatRow
{
public nint RowAddress { get; init; }
public string? InternalId { get; init; }
public int Act { get; init; }
public string? DisplayName { get; init; }
public string? IconPath { get; init; }
}
/// <summary>
/// Parses Quest.dat rows using offsets from GameOffsets:
/// +0x00 ptr→wchar* InternalId, +0x08 int32 Act, +0x0C ptr→wchar* DisplayName, +0x14 ptr→wchar* IconPath.
/// </summary>
public sealed class QuestDatRowParser : IDatRowParser<QuestDatRow>
{
public QuestDatRow? Parse(byte[] rowData, int offset, nint rowAddr, MemoryContext ctx, MsvcStringReader strings)
{
var offsets = ctx.Offsets;
var mem = ctx.Memory;
// +0x00: ptr → wchar* internal ID (dat pointers can be 2-byte aligned)
var idPtrPtr = (nint)BitConverter.ToInt64(rowData, offset + offsets.QuestObjNamePtrOffset);
if (idPtrPtr == 0 || !ctx.IsDatPtr(idPtrPtr)) return null;
var internalId = strings.ReadNullTermWString(idPtrPtr);
// +0x08: int32 act
var act = BitConverter.ToInt32(rowData, offset + offsets.QuestObjActOffset);
// +0x0C: ptr → wchar* display name (NOT 8-byte aligned)
string? displayName = null;
var namePtr = (nint)BitConverter.ToInt64(rowData, offset + offsets.QuestObjDisplayNameOffset);
if (namePtr != 0 && ctx.IsValidHeapPtr(namePtr))
displayName = strings.ReadNullTermWString(namePtr);
// +0x14: ptr → wchar* icon path
string? iconPath = null;
var iconPtr = (nint)BitConverter.ToInt64(rowData, offset + offsets.QuestObjIconOffset);
if (iconPtr != 0 && ctx.IsValidHeapPtr(iconPtr))
iconPath = strings.ReadNullTermWString(iconPtr);
return new QuestDatRow
{
RowAddress = rowAddr,
InternalId = internalId,
Act = act,
DisplayName = displayName,
IconPath = iconPath,
};
}
}

View file

@ -0,0 +1,63 @@
namespace Roboto.Memory;
/// <summary>
/// Parsed row from QuestStates.dat.
/// +0x00: Quest.dat FK (TableReference ptr), +0x10: Order/StateId, +0x34: Text ptr, +0x3D: Message ptr.
/// </summary>
public sealed record QuestStateDatRow
{
public nint RowAddress { get; init; }
/// <summary>Pointer to the Quest.dat row — used as FK key in QuestStateLookup.</summary>
public nint QuestDatRowPtr { get; init; }
public int StateId { get; init; }
public string? Text { get; init; }
public string? Message { get; init; }
}
/// <summary>
/// Parses QuestStates.dat rows using offsets from GameOffsets.
/// </summary>
public sealed class QuestStateDatRowParser : IDatRowParser<QuestStateDatRow>
{
public QuestStateDatRow? Parse(byte[] rowData, int offset, nint rowAddr, MemoryContext ctx, MsvcStringReader strings)
{
var offsets = ctx.Offsets;
// +0x00: Quest.dat row pointer (TableReference — first 8 bytes)
// .dat row pointers can be 2-byte aligned — use IsDatPtr instead of IsValidHeapPtr
var questRowPtr = (nint)BitConverter.ToInt64(rowData, offset + offsets.QuestDatNameOffset);
if (questRowPtr == 0 || !ctx.IsDatPtr(questRowPtr)) return null;
// +0x10: Order int32 = stateId
var stateId = BitConverter.ToInt32(rowData, offset + offsets.QuestDatOrderOffset);
// +0x34: text wchar* pointer (points into .dat variable data — use IsDatPtr + ReadDatString)
string? text = null;
var textPtrOffset = offset + offsets.QuestDatTextOffset;
if (textPtrOffset + 8 <= rowData.Length)
{
var textPtr = (nint)BitConverter.ToInt64(rowData, textPtrOffset);
if (textPtr != 0 && ctx.IsDatPtr(textPtr))
text = strings.ReadDatString(textPtr);
}
// +0x3D: message wchar* pointer (same — .dat variable data)
string? message = null;
var msgPtrOffset = offset + offsets.QuestDatMessageOffset;
if (msgPtrOffset + 8 <= rowData.Length)
{
var msgPtr = (nint)BitConverter.ToInt64(rowData, msgPtrOffset);
if (msgPtr != 0 && ctx.IsDatPtr(msgPtr))
message = strings.ReadDatString(msgPtr);
}
return new QuestStateDatRow
{
RowAddress = rowAddr,
QuestDatRowPtr = questRowPtr,
StateId = stateId,
Text = text,
Message = message,
};
}
}

View file

@ -38,6 +38,8 @@ public class GameMemoryReader : IDisposable
private MsvcStringReader? _strings;
private RttiResolver? _rtti;
private QuestNameLookup? _questNames;
private FilesContainer? _filesContainer;
private QuestStateLookup? _questStateLookup;
public ObjectRegistry Registry => _registry;
public MemoryDiagnostics? Diagnostics { get; private set; }
@ -71,9 +73,10 @@ public class GameMemoryReader : IDisposable
}
// Try pattern scan first
PatternScanner? scanner = null;
if (!string.IsNullOrWhiteSpace(_offsets.GameStatePattern))
{
var scanner = new PatternScanner(memory);
scanner = new PatternScanner(memory);
_ctx.GameStateBase = scanner.FindPatternRip(_offsets.GameStatePattern);
if (_ctx.GameStateBase != 0)
{
@ -89,15 +92,32 @@ public class GameMemoryReader : IDisposable
Log.Information("GameState base (manual): 0x{Address:X}", _ctx.GameStateBase);
}
// FileRoot pattern scan
if (!string.IsNullOrWhiteSpace(_offsets.FileRootPattern))
{
scanner ??= new PatternScanner(memory);
_ctx.FileRootBase = scanner.FindPatternRip(_offsets.FileRootPattern);
if (_ctx.FileRootBase == 0 && !string.IsNullOrWhiteSpace(_offsets.FileRootPatternAlt))
_ctx.FileRootBase = scanner.FindPatternRip(_offsets.FileRootPatternAlt);
}
if (_ctx.FileRootBase == 0 && _offsets.FileRootGlobalOffset > 0)
_ctx.FileRootBase = _ctx.ModuleBase + _offsets.FileRootGlobalOffset;
if (_ctx.FileRootBase != 0)
Log.Information("FileRoot base: 0x{Address:X}", _ctx.FileRootBase);
else
Log.Warning("FileRoot base not resolved — QuestStates.dat lookup will be unavailable");
// Create infrastructure
_strings = new MsvcStringReader(_ctx);
_rtti = new RttiResolver(_ctx);
_stateReader = new GameStateReader(_ctx);
_components = new ComponentReader(_ctx, _strings);
_questNames ??= LoadQuestNames();
_filesContainer = new FilesContainer(_ctx, _strings);
_questStateLookup = new QuestStateLookup(_filesContainer);
// Hierarchical state tree — owns EntityList, PlayerSkills, QuestFlags, Terrain
_gameStates = new GameStates(_ctx, _components, _strings, _questNames);
_gameStates = new GameStates(_ctx, _components, _strings, _questNames, _questStateLookup);
// Diagnostics uses the EntityList from the hierarchy
var entityList = _gameStates.InGame.AreaInstance.EntityList;
@ -116,6 +136,8 @@ public class GameMemoryReader : IDisposable
_strings = null;
_rtti = null;
// _questNames intentionally kept — reloaded only once
_filesContainer = null;
_questStateLookup = null;
Diagnostics = null;
}

View file

@ -320,6 +320,8 @@ public sealed class GameOffsets
public int QuestLinkedListNodeSize { get; set; } = 40;
/// <summary>Offset within linked list node to the Quest object pointer. 0x10.</summary>
public int QuestNodeQuestPtrOffset { get; set; } = 0x10;
/// <summary>Offset within linked list node to the shared pointer (runtime quest state object with vtable + QuestStates.dat row array). 0x18.</summary>
public int QuestNodeSharedPtrOffset { get; set; } = 0x18;
/// <summary>Offset within linked list node to the QuestStateId byte. 0x20.</summary>
public int QuestNodeStateIdOffset { get; set; } = 0x20;
/// <summary>Offset from Quest.dat row to ptr→wchar* internal ID. 0x00.</summary>
@ -332,9 +334,6 @@ public sealed class GameOffsets
public int QuestObjIconOffset { get; set; } = 0x14;
/// <summary>Maximum nodes to traverse (sanity limit).</summary>
public int QuestLinkedListMaxNodes { get; set; } = 256;
/// <summary>Offset within the tracked quest's runtime state object to the objective text (std::wstring). 0x34.</summary>
public int QuestStateObjTextOffset { get; set; } = 0x34;
/// <summary>GameUi child index for the quest panel parent element (child[6]).</summary>
public int TrackedQuestPanelChildIndex { get; set; } = 6;
/// <summary>Sub-child index within quest panel parent (child[6][1]).</summary>
@ -342,6 +341,14 @@ public sealed class GameOffsets
/// <summary>Offset from the [6][1] element to the tracked/active quest linked list. Same node layout. 0x318.</summary>
public int TrackedQuestLinkedListOffset { get; set; } = 0x318;
// ── FileRoot (in-memory .dat file system) ──
/// <summary>Primary pattern for FileRoot global pointer. ^ marks the RIP displacement.</summary>
public string FileRootPattern { get; set; } = "48 8B 0D ^ ?? ?? ?? ?? E8 ?? ?? ?? ?? E8";
/// <summary>Alternate pattern for FileRoot (fallback if primary fails).</summary>
public string FileRootPatternAlt { get; set; } = "48 ?? ?? ^ ?? ?? ?? ?? 48 ?? ?? ?? ?? ?? ?? e8 ?? ?? ?? ?? 48 ?? ?? ?? ?? ?? ?? e8 ?? ?? ?? ?? 48 8b ?? ?? ?? ?? ?? 48 ?? ?? ?? c3";
/// <summary>Manual offset from module base to FileRoot global (hex). 0 = disabled.</summary>
public int FileRootGlobalOffset { get; set; } = 0;
// ── Terrain (inline in AreaInstance) ──
/// <summary>Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).</summary>
public int TerrainListOffset { get; set; } = 0xCC0;

View file

@ -12,6 +12,7 @@ public sealed class MemoryContext
public nint ModuleBase { get; set; }
public int ModuleSize { get; set; }
public nint GameStateBase { get; set; }
public nint FileRootBase { get; set; }
public MemoryContext(ProcessMemory memory, GameOffsets offsets, ObjectRegistry registry)
{
@ -32,4 +33,15 @@ public sealed class MemoryContext
var high = (ulong)ptr >> 32;
return high > 0 && high < 0x7FFF && (ptr & 0x3) == 0;
}
/// <summary>
/// Relaxed pointer check for .dat row addresses which can be 2-byte aligned.
/// Use for FK pointers between .dat tables.
/// </summary>
public bool IsDatPtr(nint ptr)
{
if (ptr == 0) return false;
var high = (ulong)ptr >> 32;
return high > 0 && high < 0x7FFF && (ptr & 0x1) == 0;
}
}

View file

@ -128,6 +128,31 @@ public sealed class MsvcStringReader
return str;
}
/// <summary>
/// Reads a null-terminated wchar_t* (UTF-16) string from a .dat variable data section.
/// Unlike ReadNullTermWString, allows full Unicode (not restricted to ASCII).
/// Use for game text that may contain localized/special characters.
/// </summary>
public string? ReadDatString(nint ptr, int maxBytes = 512)
{
if (ptr == 0) return null;
var data = _ctx.Memory.ReadBytes(ptr, maxBytes);
if (data is null) return null;
var byteLen = -1;
for (var i = 0; i + 1 < data.Length; i += 2)
{
if (data[i] == 0 && data[i + 1] == 0)
{
byteLen = i;
break;
}
}
if (byteLen <= 0) return null;
return Encoding.Unicode.GetString(data, 0, byteLen);
}
/// <summary>
/// Reads a null-terminated UTF-8 string (up to 256 bytes).
/// </summary>

View file

@ -24,11 +24,11 @@ public sealed class GameStates
/// <summary>Raw qwords from controller 0x00-0x48 (before state slots), for UI diagnostics.</summary>
public (int Offset, nint Value, string? Match, bool Changed, string? DerefInfo)[] ControllerPreSlots { get; private set; } = [];
public GameStates(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
public GameStates(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames, QuestStateLookup? questStateLookup = null)
{
_ctx = ctx;
AreaLoading = new AreaLoading(ctx);
InGame = new InGameState(ctx, components, strings, questNames);
InGame = new InGameState(ctx, components, strings, questNames, questStateLookup);
}
/// <summary>

View file

@ -16,12 +16,12 @@ public sealed class InGameState : RemoteObject
public WorldData WorldData { get; }
public UIElements UIElements { get; }
public InGameState(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
public InGameState(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames, QuestStateLookup? questStateLookup = null)
: base(ctx)
{
AreaInstance = new AreaInstance(ctx, components, strings, questNames);
WorldData = new WorldData(ctx);
UIElements = new UIElements(ctx, strings);
UIElements = new UIElements(ctx, strings) { QuestStateLookup = questStateLookup };
}
protected override bool ReadData()

View file

@ -20,6 +20,9 @@ public sealed class UIElements : RemoteObject
private readonly MsvcStringReader _strings;
/// <summary>Optional lookup for resolving quest state IDs to human-readable text.</summary>
public QuestStateLookup? QuestStateLookup { get; set; }
public UIElements(MemoryContext ctx, MsvcStringReader strings) : base(ctx)
{
_strings = strings;
@ -382,7 +385,11 @@ public sealed class UIElements : RemoteObject
displayName = _strings.ReadNullTermWString(namePtr);
}
var isTracked = trackedMap.TryGetValue(questPtr, out var objectiveText);
var isTracked = trackedMap.ContainsKey(questPtr);
string? stateText = null;
if (QuestStateLookup is not null && questPtr != 0 && stateId > 0)
QuestStateLookup.TryGetStateText(questPtr, stateId, out stateText);
result.Add(new QuestLinkedEntry
{
@ -390,8 +397,8 @@ public sealed class UIElements : RemoteObject
DisplayName = displayName,
Act = act,
StateId = stateId,
StateText = stateText,
IsTracked = isTracked,
ObjectiveText = objectiveText,
QuestDatPtr = questPtr,
});
@ -402,8 +409,9 @@ public sealed class UIElements : RemoteObject
}
/// <summary>
/// Walks the tracked-quests linked list. Builds a dict of QuestDatPtr → ObjectiveText.
/// Node+0x20 is a pointer to a runtime state object; text is at stateObj+QuestStateObjTextOffset (std::wstring).
/// Walks the tracked-quests linked list. Builds a dict of QuestDatPtr → null.
/// Tracked status is used; objective text is not available from runtime memory
/// (stateObj contains quest flag arrays, not display text).
/// </summary>
private void TraverseTrackedQuests(nint headPtr, Dictionary<nint, string?> trackedMap)
{
@ -434,35 +442,15 @@ public sealed class UIElements : RemoteObject
}
var questPtr = (nint)BitConverter.ToInt64(nodeData, offsets.QuestNodeQuestPtrOffset);
string? objectiveText = null;
// +0x20 in tracked list is a pointer to the quest state runtime object
var stateObjPtr = (nint)BitConverter.ToInt64(nodeData, 0x20);
if (stateObjPtr != 0 && ((ulong)stateObjPtr >> 32) is > 0 and < 0x7FFF)
{
// Read std::wstring at stateObj + QuestStateObjTextOffset
objectiveText = ParseWStringFromMemory(stateObjPtr + offsets.QuestStateObjTextOffset);
}
if (questPtr != 0)
trackedMap[questPtr] = objectiveText;
trackedMap[questPtr] = null;
count++;
walk = next;
}
}
/// <summary>
/// Reads an inline MSVC std::wstring from a process memory address.
/// Same layout as UIElement strings: Buffer(8) + Reserved(8) + Length(4) + pad(4) + Capacity(4) + pad(4).
/// </summary>
private string? ParseWStringFromMemory(nint addr)
{
var strData = Ctx.Memory.ReadBytes(addr, 32);
if (strData is null || strData.Length < 28) return null;
return ParseWStringFromBuffer(strData, 0);
}
private void EnqueueChildren(Queue<nint> queue, HashSet<nint> visited, nint parentAddr)
{
var mem = Ctx.Memory;

View file

@ -0,0 +1,82 @@
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Builds a (questDatRowPtr, stateId) → text lookup from QuestStates.dat.
/// No Quest.dat needed — uses the Quest FK pointer directly as the key,
/// which matches the questPtr from the quest linked list.
/// </summary>
public sealed class QuestStateLookup
{
private readonly FilesContainer _files;
private Dictionary<(nint questPtr, int stateId), string>? _lookup;
private bool _attempted;
public QuestStateLookup(FilesContainer files)
{
_files = files;
}
/// <summary>Number of entries in the lookup table. 0 if not yet built or failed.</summary>
public int Count => _lookup?.Count ?? 0;
/// <summary>
/// Tries to resolve a quest state to human-readable text.
/// Uses the Quest.dat row pointer (same as questPtr from linked list) + stateId as key.
/// </summary>
public bool TryGetStateText(nint questDatRowPtr, int stateId, out string? text)
{
text = null;
EnsureLookup();
if (_lookup is null) return false;
return _lookup.TryGetValue((questDatRowPtr, stateId), out text);
}
private void EnsureLookup()
{
if (_attempted) return;
_attempted = true;
try
{
_lookup = BuildLookup();
if (_lookup is not null)
Log.Information("QuestStateLookup: built {Count} entries from QuestStates.dat", _lookup.Count);
else
Log.Warning("QuestStateLookup: failed to build lookup (QuestStates.dat not loaded)");
}
catch (Exception ex)
{
Log.Error(ex, "QuestStateLookup: error building lookup");
}
}
private Dictionary<(nint, int), string>? BuildLookup()
{
if (!_files.QuestStates.IsLoaded)
return null;
var result = new Dictionary<(nint, int), string>();
foreach (var row in _files.QuestStates.Rows)
{
if (row.Text is null || row.QuestDatRowPtr == 0)
continue;
result.TryAdd((row.QuestDatRowPtr, row.StateId), row.Text);
}
return result.Count > 0 ? result : null;
}
/// <summary>
/// Invalidates the lookup so it will be rebuilt on next access.
/// </summary>
public void InvalidateCache()
{
_lookup = null;
_attempted = false;
}
}

View file

@ -3,7 +3,8 @@ namespace Roboto.Memory;
/// <summary>
/// A quest entry from the GameUi linked lists.
/// All-quests list (GameUi+0x358) provides Id/Name/Act/StateId.
/// Tracked-quests list ([6][1]+0x318) adds ObjectiveText.
/// Tracked-quests list ([6][1]+0x318) marks which quests are pinned.
/// StateText resolved from QuestStates.dat via QuestStateLookup.
/// </summary>
public sealed class QuestLinkedEntry
{
@ -15,10 +16,10 @@ public sealed class QuestLinkedEntry
public int Act { get; init; }
/// <summary>State: 0=done, -1(0xFFFFFFFF)=locked, positive=in-progress step.</summary>
public int StateId { get; init; }
/// <summary>Human-readable state text from QuestStates.dat (e.g. "To release the Hooded One...").</summary>
public string? StateText { get; init; }
/// <summary>True if this quest appears in the tracked-quests list.</summary>
public bool IsTracked { get; init; }
/// <summary>Objective text from the tracked quest's runtime state object (std::wstring at +0x34).</summary>
public string? ObjectiveText { get; init; }
/// <summary>Raw Quest.dat row pointer — used as key for merging tracked info.</summary>
public nint QuestDatPtr { get; init; }
}