questStates done
This commit is contained in:
parent
445ae1387c
commit
568df4c7fe
17 changed files with 1012 additions and 40 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}"));
|
||||
|
||||
|
|
|
|||
113
src/Roboto.Memory/Files/DatFile.cs
Normal file
113
src/Roboto.Memory/Files/DatFile.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
537
src/Roboto.Memory/Files/FileRootScanner.cs
Normal file
537
src/Roboto.Memory/Files/FileRootScanner.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
47
src/Roboto.Memory/Files/FilesContainer.cs
Normal file
47
src/Roboto.Memory/Files/FilesContainer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
19
src/Roboto.Memory/Files/IDatRowParser.cs
Normal file
19
src/Roboto.Memory/Files/IDatRowParser.cs
Normal 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);
|
||||
}
|
||||
55
src/Roboto.Memory/Files/QuestRow.cs
Normal file
55
src/Roboto.Memory/Files/QuestRow.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
63
src/Roboto.Memory/Files/QuestStateRow.cs
Normal file
63
src/Roboto.Memory/Files/QuestStateRow.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
82
src/Roboto.Memory/QuestStateLookup.cs
Normal file
82
src/Roboto.Memory/QuestStateLookup.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue