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; } }