diff --git a/entities.json b/entities.json
index 9a01522..f3c00fa 100644
--- a/entities.json
+++ b/entities.json
@@ -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",
diff --git a/src/Automata.Ui/ViewModels/MemoryViewModel.cs b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
index dbfba36..deadad5 100644
--- a/src/Automata.Ui/ViewModels/MemoryViewModel.cs
+++ b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
@@ -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}"));
diff --git a/src/Roboto.Memory/Files/DatFile.cs b/src/Roboto.Memory/Files/DatFile.cs
new file mode 100644
index 0000000..26d8059
--- /dev/null
+++ b/src/Roboto.Memory/Files/DatFile.cs
@@ -0,0 +1,113 @@
+using Serilog;
+
+namespace Roboto.Memory;
+
+///
+/// Generic wrapper for a single .dat table. Bulk-reads all rows in one RPM call,
+/// parses them via , and caches the results.
+/// Supports O(1) FK resolution via .
+///
+public sealed class DatFile where TRow : class
+{
+ private readonly string _fileName;
+ private readonly IDatRowParser _parser;
+ private List? _rows;
+ private Dictionary? _byAddress;
+ private bool _loadAttempted;
+
+ public DatFile(string fileName, IDatRowParser parser)
+ {
+ _fileName = fileName;
+ _parser = parser;
+ }
+
+ /// All parsed rows, in order. Empty if not loaded or load failed.
+ public IReadOnlyList Rows => _rows ?? (IReadOnlyList)[];
+
+ /// Number of successfully parsed rows.
+ public int Count => _rows?.Count ?? 0;
+
+ /// True if Load() has been called and produced at least one row.
+ public bool IsLoaded => _rows is not null && _rows.Count > 0;
+
+ ///
+ /// Loads the .dat table from memory. Idempotent — second call is a no-op.
+ /// Returns true if rows were loaded successfully.
+ ///
+ 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(info.RowCount);
+ var byAddr = new Dictionary(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;
+ }
+ }
+
+ ///
+ /// O(1) lookup by row's process-memory address. Used for FK resolution.
+ /// Returns null if address is not found or table not loaded.
+ ///
+ public TRow? GetByAddress(nint address)
+ {
+ if (_byAddress is null || address == 0) return null;
+ return _byAddress.GetValueOrDefault(address);
+ }
+
+ ///
+ /// Clears cached data so the next Load() call re-reads from memory.
+ ///
+ public void InvalidateCache()
+ {
+ _rows = null;
+ _byAddress = null;
+ _loadAttempted = false;
+ }
+}
diff --git a/src/Roboto.Memory/Files/FileRootScanner.cs b/src/Roboto.Memory/Files/FileRootScanner.cs
new file mode 100644
index 0000000..2f5dfe7
--- /dev/null
+++ b/src/Roboto.Memory/Files/FileRootScanner.cs
@@ -0,0 +1,537 @@
+using System.Text;
+using Serilog;
+
+namespace Roboto.Memory;
+
+///
+/// Info about a discovered .dat file in memory: first record address, row size, and row count.
+///
+public readonly record struct DatTableInfo(nint FirstRecord, int RowSize, int RowCount);
+
+///
+/// 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.
+///
+public sealed class FileRootScanner
+{
+ private readonly MemoryContext _ctx;
+ private readonly MsvcStringReader _strings;
+ private Dictionary? _fileCache;
+ private bool _scanAttempted;
+
+ public MemoryContext Context => _ctx;
+ public MsvcStringReader Strings => _strings;
+
+ public FileRootScanner(MemoryContext ctx, MsvcStringReader strings)
+ {
+ _ctx = ctx;
+ _strings = strings;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ /// Number of cached file entries discovered from FileRoot.
+ 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");
+ }
+ }
+
+ ///
+ /// 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
+ ///
+ private Dictionary? ScanFileRoot()
+ {
+ var result = ScanViaStdBuckets();
+ if (result is not null) return result;
+
+ result = ScanViaFlatHashMap();
+ return result;
+ }
+
+ // ─── Strategy 1: GameOverlay2 StdBucket approach ───────────────────────
+
+ ///
+ /// 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.
+ ///
+ private Dictionary? 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(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;
+ }
+
+ ///
+ /// Reads the filename from a FileInfoValueStruct.
+ /// Layout: +0x00 = records ptr (8B), +0x08 = StdWString Name (32B), +0x40 = AreaChangeCount (4B).
+ ///
+ 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) ────────────────────
+
+ ///
+ /// ExileCore layout (POE1):
+ /// FileRootBase → direct read → 16 blocks × 40B → bucket arrays (512 entries × 8 slots).
+ ///
+ private Dictionary? 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(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 ──────────────────────────────────────────────────
+
+ ///
+ /// Validates that a discovered filename looks reasonable (printable ASCII path characters).
+ ///
+ 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('.');
+ }
+
+ ///
+ /// 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).
+ ///
+ 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;
+ }
+
+ ///
+ /// Scans byte array for the 0xBBBBBBBBBBBBBBBB separator (8 consecutive 0xBB bytes).
+ /// Returns the byte offset of the separator, or -1 if not found.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+ 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);
+ }
+
+ ///
+ /// Invalidates the cached file list so next FindDatFile re-scans.
+ /// Call after re-attaching to a process.
+ ///
+ public void InvalidateCache()
+ {
+ _fileCache = null;
+ _scanAttempted = false;
+ }
+}
diff --git a/src/Roboto.Memory/Files/FilesContainer.cs b/src/Roboto.Memory/Files/FilesContainer.cs
new file mode 100644
index 0000000..0a709e8
--- /dev/null
+++ b/src/Roboto.Memory/Files/FilesContainer.cs
@@ -0,0 +1,47 @@
+namespace Roboto.Memory;
+
+///
+/// Facade for all in-memory .dat file access. Owns the
+/// and provides lazy typed access to specific .dat tables.
+/// Adding a new .dat file = add 1 record + 1 parser + 1 lazy property here.
+///
+public sealed class FilesContainer
+{
+ private readonly MemoryContext _ctx;
+ private readonly MsvcStringReader _strings;
+
+ public FileRootScanner Scanner { get; }
+
+ private DatFile? _questStates;
+
+ public FilesContainer(MemoryContext ctx, MsvcStringReader strings)
+ {
+ _ctx = ctx;
+ _strings = strings;
+ Scanner = new FileRootScanner(ctx, strings);
+ }
+
+ /// QuestStates.dat — lazy-loaded on first access.
+ public DatFile QuestStates
+ {
+ get
+ {
+ if (_questStates is null)
+ {
+ _questStates = new DatFile("Data/Balance/QuestStates.dat", new QuestStateDatRowParser());
+ _questStates.Load(Scanner, _ctx, _strings);
+ }
+ return _questStates;
+ }
+ }
+
+ ///
+ /// Clears all cached data. Call after re-attaching to a process.
+ ///
+ public void InvalidateCache()
+ {
+ Scanner.InvalidateCache();
+ _questStates?.InvalidateCache();
+ _questStates = null;
+ }
+}
diff --git a/src/Roboto.Memory/Files/IDatRowParser.cs b/src/Roboto.Memory/Files/IDatRowParser.cs
new file mode 100644
index 0000000..e72cbea
--- /dev/null
+++ b/src/Roboto.Memory/Files/IDatRowParser.cs
@@ -0,0 +1,19 @@
+namespace Roboto.Memory;
+
+///
+/// Stateless parser for a single .dat row schema. One implementation per .dat file type.
+///
+/// The record type produced from each row.
+public interface IDatRowParser
+{
+ ///
+ /// Parses a single row from a bulk-read buffer.
+ ///
+ /// The bulk buffer containing all rows.
+ /// Byte offset into rowData where this row starts.
+ /// The process-memory address of this row (for FK resolution).
+ /// Memory context for following pointers.
+ /// String reader for wchar* pointers.
+ /// Parsed record, or null if the row is invalid/corrupt.
+ T? Parse(byte[] rowData, int offset, nint rowAddr, MemoryContext ctx, MsvcStringReader strings);
+}
diff --git a/src/Roboto.Memory/Files/QuestRow.cs b/src/Roboto.Memory/Files/QuestRow.cs
new file mode 100644
index 0000000..321e9b4
--- /dev/null
+++ b/src/Roboto.Memory/Files/QuestRow.cs
@@ -0,0 +1,55 @@
+namespace Roboto.Memory;
+
+///
+/// Parsed row from Quest.dat. Fields mirror the in-memory Quest object layout.
+///
+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; }
+}
+
+///
+/// Parses Quest.dat rows using offsets from GameOffsets:
+/// +0x00 ptr→wchar* InternalId, +0x08 int32 Act, +0x0C ptr→wchar* DisplayName, +0x14 ptr→wchar* IconPath.
+///
+public sealed class QuestDatRowParser : IDatRowParser
+{
+ 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,
+ };
+ }
+}
diff --git a/src/Roboto.Memory/Files/QuestStateRow.cs b/src/Roboto.Memory/Files/QuestStateRow.cs
new file mode 100644
index 0000000..d8b4933
--- /dev/null
+++ b/src/Roboto.Memory/Files/QuestStateRow.cs
@@ -0,0 +1,63 @@
+namespace Roboto.Memory;
+
+///
+/// Parsed row from QuestStates.dat.
+/// +0x00: Quest.dat FK (TableReference ptr), +0x10: Order/StateId, +0x34: Text ptr, +0x3D: Message ptr.
+///
+public sealed record QuestStateDatRow
+{
+ public nint RowAddress { get; init; }
+ /// Pointer to the Quest.dat row — used as FK key in QuestStateLookup.
+ public nint QuestDatRowPtr { get; init; }
+ public int StateId { get; init; }
+ public string? Text { get; init; }
+ public string? Message { get; init; }
+}
+
+///
+/// Parses QuestStates.dat rows using offsets from GameOffsets.
+///
+public sealed class QuestStateDatRowParser : IDatRowParser
+{
+ 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,
+ };
+ }
+}
diff --git a/src/Roboto.Memory/GameMemoryReader.cs b/src/Roboto.Memory/GameMemoryReader.cs
index 075f109..28de338 100644
--- a/src/Roboto.Memory/GameMemoryReader.cs
+++ b/src/Roboto.Memory/GameMemoryReader.cs
@@ -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;
}
diff --git a/src/Roboto.Memory/GameOffsets.cs b/src/Roboto.Memory/GameOffsets.cs
index 4e02dc2..859dfc1 100644
--- a/src/Roboto.Memory/GameOffsets.cs
+++ b/src/Roboto.Memory/GameOffsets.cs
@@ -320,6 +320,8 @@ public sealed class GameOffsets
public int QuestLinkedListNodeSize { get; set; } = 40;
/// Offset within linked list node to the Quest object pointer. 0x10.
public int QuestNodeQuestPtrOffset { get; set; } = 0x10;
+ /// Offset within linked list node to the shared pointer (runtime quest state object with vtable + QuestStates.dat row array). 0x18.
+ public int QuestNodeSharedPtrOffset { get; set; } = 0x18;
/// Offset within linked list node to the QuestStateId byte. 0x20.
public int QuestNodeStateIdOffset { get; set; } = 0x20;
/// Offset from Quest.dat row to ptr→wchar* internal ID. 0x00.
@@ -332,9 +334,6 @@ public sealed class GameOffsets
public int QuestObjIconOffset { get; set; } = 0x14;
/// Maximum nodes to traverse (sanity limit).
public int QuestLinkedListMaxNodes { get; set; } = 256;
- /// Offset within the tracked quest's runtime state object to the objective text (std::wstring). 0x34.
- public int QuestStateObjTextOffset { get; set; } = 0x34;
-
/// GameUi child index for the quest panel parent element (child[6]).
public int TrackedQuestPanelChildIndex { get; set; } = 6;
/// Sub-child index within quest panel parent (child[6][1]).
@@ -342,6 +341,14 @@ public sealed class GameOffsets
/// Offset from the [6][1] element to the tracked/active quest linked list. Same node layout. 0x318.
public int TrackedQuestLinkedListOffset { get; set; } = 0x318;
+ // ── FileRoot (in-memory .dat file system) ──
+ /// Primary pattern for FileRoot global pointer. ^ marks the RIP displacement.
+ public string FileRootPattern { get; set; } = "48 8B 0D ^ ?? ?? ?? ?? E8 ?? ?? ?? ?? E8";
+ /// Alternate pattern for FileRoot (fallback if primary fails).
+ public string FileRootPatternAlt { get; set; } = "48 ?? ?? ^ ?? ?? ?? ?? 48 ?? ?? ?? ?? ?? ?? e8 ?? ?? ?? ?? 48 ?? ?? ?? ?? ?? ?? e8 ?? ?? ?? ?? 48 8b ?? ?? ?? ?? ?? 48 ?? ?? ?? c3";
+ /// Manual offset from module base to FileRoot global (hex). 0 = disabled.
+ public int FileRootGlobalOffset { get; set; } = 0;
+
// ── Terrain (inline in AreaInstance) ──
/// Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).
public int TerrainListOffset { get; set; } = 0xCC0;
diff --git a/src/Roboto.Memory/Infrastructure/MemoryContext.cs b/src/Roboto.Memory/Infrastructure/MemoryContext.cs
index 2149081..dc42940 100644
--- a/src/Roboto.Memory/Infrastructure/MemoryContext.cs
+++ b/src/Roboto.Memory/Infrastructure/MemoryContext.cs
@@ -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;
}
+
+ ///
+ /// Relaxed pointer check for .dat row addresses which can be 2-byte aligned.
+ /// Use for FK pointers between .dat tables.
+ ///
+ public bool IsDatPtr(nint ptr)
+ {
+ if (ptr == 0) return false;
+ var high = (ulong)ptr >> 32;
+ return high > 0 && high < 0x7FFF && (ptr & 0x1) == 0;
+ }
}
diff --git a/src/Roboto.Memory/Infrastructure/MsvcStringReader.cs b/src/Roboto.Memory/Infrastructure/MsvcStringReader.cs
index d87be68..c1b0014 100644
--- a/src/Roboto.Memory/Infrastructure/MsvcStringReader.cs
+++ b/src/Roboto.Memory/Infrastructure/MsvcStringReader.cs
@@ -128,6 +128,31 @@ public sealed class MsvcStringReader
return str;
}
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
///
/// Reads a null-terminated UTF-8 string (up to 256 bytes).
///
diff --git a/src/Roboto.Memory/Objects/GameStates.cs b/src/Roboto.Memory/Objects/GameStates.cs
index ca75288..5adf775 100644
--- a/src/Roboto.Memory/Objects/GameStates.cs
+++ b/src/Roboto.Memory/Objects/GameStates.cs
@@ -24,11 +24,11 @@ public sealed class GameStates
/// Raw qwords from controller 0x00-0x48 (before state slots), for UI diagnostics.
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);
}
///
diff --git a/src/Roboto.Memory/Objects/InGameState.cs b/src/Roboto.Memory/Objects/InGameState.cs
index 81ff307..abe66b9 100644
--- a/src/Roboto.Memory/Objects/InGameState.cs
+++ b/src/Roboto.Memory/Objects/InGameState.cs
@@ -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()
diff --git a/src/Roboto.Memory/Objects/UIElements.cs b/src/Roboto.Memory/Objects/UIElements.cs
index ce86000..ffe601a 100644
--- a/src/Roboto.Memory/Objects/UIElements.cs
+++ b/src/Roboto.Memory/Objects/UIElements.cs
@@ -20,6 +20,9 @@ public sealed class UIElements : RemoteObject
private readonly MsvcStringReader _strings;
+ /// Optional lookup for resolving quest state IDs to human-readable text.
+ 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
}
///
- /// 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).
///
private void TraverseTrackedQuests(nint headPtr, Dictionary 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;
}
}
- ///
- /// 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).
- ///
- 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 queue, HashSet visited, nint parentAddr)
{
var mem = Ctx.Memory;
diff --git a/src/Roboto.Memory/QuestStateLookup.cs b/src/Roboto.Memory/QuestStateLookup.cs
new file mode 100644
index 0000000..daf8a81
--- /dev/null
+++ b/src/Roboto.Memory/QuestStateLookup.cs
@@ -0,0 +1,82 @@
+using Serilog;
+
+namespace Roboto.Memory;
+
+///
+/// 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.
+///
+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;
+ }
+
+ /// Number of entries in the lookup table. 0 if not yet built or failed.
+ public int Count => _lookup?.Count ?? 0;
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// Invalidates the lookup so it will be rebuilt on next access.
+ ///
+ public void InvalidateCache()
+ {
+ _lookup = null;
+ _attempted = false;
+ }
+}
diff --git a/src/Roboto.Memory/Snapshots/QuestLinkedEntry.cs b/src/Roboto.Memory/Snapshots/QuestLinkedEntry.cs
index 263084f..8bb5c7e 100644
--- a/src/Roboto.Memory/Snapshots/QuestLinkedEntry.cs
+++ b/src/Roboto.Memory/Snapshots/QuestLinkedEntry.cs
@@ -3,7 +3,8 @@ namespace Roboto.Memory;
///
/// 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.
///
public sealed class QuestLinkedEntry
{
@@ -15,10 +16,10 @@ public sealed class QuestLinkedEntry
public int Act { get; init; }
/// State: 0=done, -1(0xFFFFFFFF)=locked, positive=in-progress step.
public int StateId { get; init; }
+ /// Human-readable state text from QuestStates.dat (e.g. "To release the Hooded One...").
+ public string? StateText { get; init; }
/// True if this quest appears in the tracked-quests list.
public bool IsTracked { get; init; }
- /// Objective text from the tracked quest's runtime state object (std::wstring at +0x34).
- public string? ObjectiveText { get; init; }
/// Raw Quest.dat row pointer — used as key for merging tracked info.
public nint QuestDatPtr { get; init; }
}