quests and queststate work

This commit is contained in:
Boki 2026-03-05 11:26:30 -05:00
parent 94b460bbc8
commit 445ae1387c
27 changed files with 3815 additions and 179 deletions

File diff suppressed because it is too large Load diff

View file

@ -44,6 +44,7 @@ public class GameMemoryReader : IDisposable
public MemoryContext? Context => _ctx;
public ComponentReader? Components => _components;
public GameStateReader? StateReader => _stateReader;
public UIElements? UIElements => _gameStates?.InGame.UIElements;
public GameMemoryReader()
{
@ -166,7 +167,15 @@ public class GameMemoryReader : IDisposable
var gs = _gameStates!;
// Set loading state on terrain before cascade
gs.InGame.AreaInstance.SetLoadingState(gs.AreaLoading.IsLoading);
// Use controller-based check (controller+IsLoadingOffset == InGameState → not loading)
// instead of AreaLoading slot which has a stale hardcoded offset (0x660)
var isLoadingForTerrain = false;
if (_lastController != 0 && _lastInGameState != 0 && _ctx.Offsets.IsLoadingOffset > 0)
{
var loadPtr = _ctx.Memory.ReadPointer(_lastController + _ctx.Offsets.IsLoadingOffset);
isLoadingForTerrain = loadPtr != 0 && loadPtr != _lastInGameState;
}
gs.InGame.AreaInstance.SetLoadingState(isLoadingForTerrain);
if (!gs.Update())
return snap;
@ -179,7 +188,7 @@ public class GameMemoryReader : IDisposable
snap.CurrentGameState = gs.CurrentState;
snap.ControllerPreSlots = gs.ControllerPreSlots;
snap.InGameStatePtr = gs.InGame.Address;
snap.IsLoading = gs.AreaLoading.IsLoading;
snap.IsLoading = isLoadingForTerrain; // use controller-based check, not broken AreaLoading.IsLoading
snap.IsEscapeOpen = gs.InGame.IsEscapeOpen;
snap.AreaInstancePtr = ai.Address;
snap.ServerDataPtr = ai.ServerDataPtr;
@ -249,6 +258,7 @@ public class GameMemoryReader : IDisposable
// Skills & quests — read from hierarchy
snap.PlayerSkills = ai.PlayerSkills.Skills;
snap.QuestFlags = ai.QuestFlags.Quests;
snap.QuestStates = ai.QuestStates;
// Read state flag bytes
if (snap.InGameStatePtr != 0)
@ -261,6 +271,15 @@ public class GameMemoryReader : IDisposable
snap.TerrainHeight = ai.Terrain.TerrainHeight;
snap.Terrain = ai.Terrain.Grid;
snap.TerrainWalkablePercent = ai.Terrain.WalkablePercent;
// UI tree — root pointer only; tree is read lazily on-demand
snap.GameUiPtr = gs.InGame.UIElements.GameUiPtr;
// Quest linked lists (all quests + tracked merged)
snap.QuestLinkedList = gs.InGame.UIElements.ReadQuestLinkedLists();
// Quest groups from UI element tree
snap.UiQuestGroups = gs.InGame.UIElements.ReadQuestGroups();
}
}
catch (Exception ex)

View file

@ -183,15 +183,41 @@ public sealed class GameOffsets
public uint QuestTrackedMarker { get; set; } = 0x43020000;
/// <summary>Offset within the quest state object to the encounter state byte (1=locked, 2=started). 0x08.</summary>
public int QuestObjEncounterStateOffset { get; set; } = 0x08;
/// <summary>Offset within quest state object to QuestStateId int32. 0 = disabled (use ScanQuestStateOffsets to discover).</summary>
public int QuestObjStateIdOffset { get; set; } = 0;
/// <summary>Offset within quest state object to state text. Interpretation depends on QuestObjStateTextType.</summary>
public int QuestObjStateTextOffset { get; set; } = 0;
/// <summary>Offset within quest state object to progress text. Interpretation depends on QuestObjStateTextType.</summary>
public int QuestObjProgressTextOffset { get; set; } = 0;
/// <summary>Offset within quest state object to Quest pointer (follow → +0x00 for quest name wchar*). 0 = disabled.</summary>
public int QuestObjQuestPtrOffset { get; set; } = 0;
/// <summary>How to read state/progress text: "wchar_ptr" = direct pointer to wchar*, "std_wstring" = inline MSVC std::wstring (32 bytes).</summary>
public string QuestObjStateTextType { get; set; } = "wchar_ptr";
// ── Quest state container (InGameState → WorldData-like object → vector of 12-byte entries) ──
/// <summary>AreaInstance → quest state sub-object pointer. Discovered via ScanQuestStateContainers: 0x900.</summary>
public int QuestStateObjectOffset { get; set; } = 0x900;
/// <summary>Quest state container → StdVector of 12-byte {questId, state, flags} entries.</summary>
public int QuestStateVectorOffset { get; set; } = 0x240;
/// <summary>Size of each quest state entry in bytes.</summary>
public int QuestStateEntrySize { get; set; } = 12;
/// <summary>Maximum number of quest state entries to read (sanity limit).</summary>
public int QuestStateMaxEntries { get; set; } = 256;
// ── QuestStates.dat row layout (119 bytes, non-aligned fields) ──
/// <summary>Size of each .dat row in bytes. 0x77 = 119. 0 = name resolution disabled.</summary>
public int QuestDatRowSize { get; set; } = 0x77;
/// <summary>Dat row → Quest display name wchar* pointer.</summary>
/// <summary>Size of each .dat row in bytes. 0x68 = 104 (confirmed via CE imul stride). 0 = name resolution disabled.</summary>
public int QuestDatRowSize { get; set; } = 0x68;
/// <summary>Dat row → Quest TableReference (16 bytes: pointer to Quest.dat row at +0x00). Follow Quest.dat row → +0x00 for name wchar*.</summary>
public int QuestDatNameOffset { get; set; } = 0x00;
/// <summary>Dat row → Internal quest ID wchar* pointer (e.g. "TreeOfSouls2").</summary>
/// <summary>Dat row → Order int32 (at offset 16 / 0x10).</summary>
public int QuestDatOrderOffset { get; set; } = 0x10;
/// <summary>Dat row → Text StringReference (quest state text). Offset 52 / 0x34.</summary>
public int QuestDatTextOffset { get; set; } = 0x34;
/// <summary>Dat row → Message StringReference. Offset 61 / 0x3D.</summary>
public int QuestDatMessageOffset { get; set; } = 0x3D;
/// <summary>Dat row → Internal quest ID wchar* pointer (legacy, may need update).</summary>
public int QuestDatInternalIdOffset { get; set; } = 0x6B;
/// <summary>Dat row → Act/phase number int32.</summary>
/// <summary>Dat row → Act/phase number int32 (legacy, may need update).</summary>
public int QuestDatActOffset { get; set; } = 0x73;
// ── Entity / Component ──
@ -283,6 +309,39 @@ public sealed class GameOffsets
/// <summary>How many bytes to scan from InGameState for UIElement pointers (0x1000 = 4KB).</summary>
public int UiElementScanRange { get; set; } = 0x1000;
// ── Quest Linked Lists (ExileCore-style, on GameUi UIElement tree) ──
// Node layout: Next(8) + Prev(8) + QuestPtr(8) + Unused(8) + QuestStateId(1) = 33 bytes
// QuestPtr → +0x00 → wchar* internal quest ID (e.g. "TreeOfSouls2")
// QuestStateId: 0=completed, 255=not started/locked, other=in progress
/// <summary>Offset from GameUi UIElement to the full quest linked list head pointer. All quests (117 entries). 0x358.</summary>
public int QuestLinkedListOffset { get; set; } = 0x358;
/// <summary>Size of each linked list node in bytes. At least 40: Next(8)+Prev(8)+QuestPtr(8)+SharedPtr(8)+StateId(4)+extra(4).</summary>
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 QuestStateId byte. 0x20.</summary>
public int QuestNodeStateIdOffset { get; set; } = 0x20;
/// <summary>Offset from Quest.dat row to ptr→wchar* internal ID. 0x00.</summary>
public int QuestObjNamePtrOffset { get; set; } = 0x00;
/// <summary>Offset from Quest.dat row to int32 Act number. 0x08.</summary>
public int QuestObjActOffset { get; set; } = 0x08;
/// <summary>Offset from Quest.dat row to ptr→wchar* display name. 0x0C (NOT 8-byte aligned). ExileCore confirmed.</summary>
public int QuestObjDisplayNameOffset { get; set; } = 0x0C;
/// <summary>Offset from Quest.dat row to ptr→wchar* icon path. 0x14.</summary>
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>
public int TrackedQuestPanelSubChildIndex { get; set; } = 1;
/// <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;
// ── Terrain (inline in AreaInstance) ──
/// <summary>Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).</summary>
public int TerrainListOffset { get; set; } = 0xCC0;

View file

@ -15,12 +15,12 @@ public sealed class AreaInstance : RemoteObject
public nint ServerDataPtr { get; private set; }
public nint LocalPlayerPtr { get; private set; }
public int EntityCount { get; private set; }
public EntityList EntityList { get; }
public PlayerSkills PlayerSkills { get; }
public QuestFlags QuestFlags { get; }
public Terrain Terrain { get; }
public AreaTemplate AreaTemplate { get; }
public List<QuestStateEntry>? QuestStates { get; private set; }
public AreaInstance(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
: base(ctx)
@ -101,6 +101,9 @@ public sealed class AreaInstance : RemoteObject
else
QuestFlags.Reset();
// Quest state container (AI+0x900 → obj → +0x240 vector)
QuestStates = ReadQuestStates(mem, offsets);
// AreaTemplate — pointer at AreaInstance + AreaTemplateOffset
var areaTemplatePtr = mem.ReadPointer(Address + offsets.AreaTemplateOffset);
if (areaTemplatePtr != 0)
@ -131,6 +134,45 @@ public sealed class AreaInstance : RemoteObject
Terrain.InvalidateCache();
}
private List<QuestStateEntry>? ReadQuestStates(ProcessMemory mem, GameOffsets offsets)
{
if (offsets.QuestStateObjectOffset <= 0 || offsets.QuestStateVectorOffset <= 0)
return null;
var objPtr = mem.ReadPointer(Address + offsets.QuestStateObjectOffset);
if (objPtr == 0 || ((ulong)objPtr >> 32) is 0 or >= 0x7FFF)
return null;
var vecAddr = objPtr + offsets.QuestStateVectorOffset;
var vecBegin = mem.ReadPointer(vecAddr);
var vecEnd = mem.ReadPointer(vecAddr + 8);
if (vecBegin == 0 || vecEnd <= vecBegin) return null;
var totalBytes = (int)(vecEnd - vecBegin);
var entrySize = offsets.QuestStateEntrySize;
if (totalBytes % entrySize != 0) return null;
var entryCount = totalBytes / entrySize;
if (entryCount <= 0 || entryCount > offsets.QuestStateMaxEntries) return null;
var data = mem.ReadBytes(vecBegin, totalBytes);
if (data is null) return null;
var result = new List<QuestStateEntry>(entryCount);
for (var i = 0; i < entryCount; i++)
{
var off = i * entrySize;
result.Add(new QuestStateEntry
{
QuestId = BitConverter.ToInt32(data, off),
State = BitConverter.ToInt32(data, off + 4),
Flags = BitConverter.ToInt32(data, off + 8),
});
}
return result;
}
protected override void Clear()
{
AreaLevel = 0;
@ -138,6 +180,7 @@ public sealed class AreaInstance : RemoteObject
ServerDataPtr = 0;
LocalPlayerPtr = 0;
EntityCount = 0;
QuestStates = null;
EntityList.Reset();
PlayerSkills.Reset();
QuestFlags.Reset();

View file

@ -14,17 +14,20 @@ public sealed class InGameState : RemoteObject
public bool IsEscapeOpen { get; private set; }
public AreaInstance AreaInstance { get; }
public WorldData WorldData { get; }
public UIElements UIElements { get; }
public InGameState(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
: base(ctx)
{
AreaInstance = new AreaInstance(ctx, components, strings, questNames);
WorldData = new WorldData(ctx);
UIElements = new UIElements(ctx, strings);
}
protected override bool ReadData()
{
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
// Read the full InGameState struct (0x310 = 784 bytes, 1 RPM)
_data = mem.Read<IgsStruct>(Address);
@ -39,6 +42,9 @@ public sealed class InGameState : RemoteObject
WorldData.FallbackCameraPtr = _data.CameraPtr;
WorldData.Update(_data.WorldDataPtr);
// Cascade to UIElements — pass InGameState address for UiRootStruct chain
UIElements.Update(Address);
return true;
}
@ -48,5 +54,6 @@ public sealed class InGameState : RemoteObject
IsEscapeOpen = false;
AreaInstance.Reset();
WorldData.Reset();
UIElements.Reset();
}
}

View file

@ -94,6 +94,7 @@ public sealed class QuestFlags : RemoteObject
}
var datTableBase = FindDatTableBase(offsets);
var useStdWString = offsets.QuestObjStateTextType == "std_wstring";
var result = new List<QuestSnapshot>(entryCount);
@ -105,6 +106,8 @@ public sealed class QuestFlags : RemoteObject
byte stateId = 0;
bool isTracked = false;
nint questObjPtr = 0;
string? stateText = null;
string? progressText = null;
if (compData is not null && i < compEntryCount)
{
@ -121,26 +124,51 @@ public sealed class QuestFlags : RemoteObject
{
questObjPtr = (nint)BitConverter.ToInt64(compData, compOff + offsets.QuestCompanionObjPtrOffset);
if (questObjPtr != 0 && ((ulong)questObjPtr >> 32) is > 0 and < 0x7FFF
&& offsets.QuestObjEncounterStateOffset > 0)
if (questObjPtr != 0 && ((ulong)questObjPtr >> 32) is > 0 and < 0x7FFF)
{
var stateByte = mem.ReadBytes(questObjPtr + offsets.QuestObjEncounterStateOffset, 1);
if (stateByte is { Length: 1 })
stateId = stateByte[0];
if (offsets.QuestObjEncounterStateOffset > 0)
{
var stateByte = mem.ReadBytes(questObjPtr + offsets.QuestObjEncounterStateOffset, 1);
if (stateByte is { Length: 1 })
stateId = stateByte[0];
}
// Read state text from quest state object
if (offsets.QuestObjStateTextOffset > 0)
stateText = ReadQuestObjString(questObjPtr + offsets.QuestObjStateTextOffset, useStdWString);
// Read progress text from quest state object
if (offsets.QuestObjProgressTextOffset > 0)
progressText = ReadQuestObjString(questObjPtr + offsets.QuestObjProgressTextOffset, useStdWString);
// Read quest name via QuestPtr → +0x00 wchar*
if (offsets.QuestObjQuestPtrOffset > 0 && questName is null)
{
var questPtr = mem.ReadPointer(questObjPtr + offsets.QuestObjQuestPtrOffset);
if (questPtr != 0 && ((ulong)questPtr >> 32) is > 0 and < 0x7FFF)
{
var namePtr = mem.ReadPointer(questPtr);
if (namePtr != 0)
questName = _strings.ReadNullTermWString(namePtr);
}
}
}
}
}
if (datTableBase != 0 && offsets.QuestDatRowSize > 0)
if (questName is null)
{
var rowAddr = datTableBase + idx * offsets.QuestDatRowSize;
questName = ResolveDatString(rowAddr + offsets.QuestDatNameOffset);
internalId = ResolveDatString(rowAddr + offsets.QuestDatInternalIdOffset);
}
else if (_nameLookup is not null && _nameLookup.TryGet(idx, out var entry))
{
questName = entry?.Name;
internalId = entry?.InternalId;
if (datTableBase != 0 && offsets.QuestDatRowSize > 0)
{
var rowAddr = datTableBase + idx * offsets.QuestDatRowSize;
questName = ResolveDatString(rowAddr + offsets.QuestDatNameOffset);
internalId = ResolveDatString(rowAddr + offsets.QuestDatInternalIdOffset);
}
else if (_nameLookup is not null && _nameLookup.TryGet(idx, out var entry))
{
questName = entry?.Name;
internalId = entry?.InternalId;
}
}
result.Add(new QuestSnapshot
@ -151,12 +179,26 @@ public sealed class QuestFlags : RemoteObject
InternalId = internalId,
StateId = stateId,
IsTracked = isTracked,
StateText = stateText,
ProgressText = progressText,
});
}
return result;
}
/// <summary>Reads a string from a quest state object field, either as wchar* pointer or MSVC std::wstring.</summary>
private string? ReadQuestObjString(nint fieldAddr, bool stdWString)
{
var mem = Ctx.Memory;
if (stdWString)
return _strings.ReadMsvcWString(fieldAddr);
var strPtr = mem.ReadPointer(fieldAddr);
if (strPtr == 0 || ((ulong)strPtr >> 32) is 0 or >= 0x7FFF) return null;
return _strings.ReadNullTermWString(strPtr);
}
private nint FindDatTableBase(GameOffsets offsets)
{
if (offsets.QuestDatRowSize <= 0) return 0;

View file

@ -0,0 +1,528 @@
using System.Text;
namespace Roboto.Memory.Objects;
/// <summary>
/// Reads the UIElement tree from InGameState → UiRootStruct → GameUi.
/// Fully lazy: ReadData() only resolves root pointers (2 RPM).
/// Tree nodes are read on-demand via ReadNode/ReadChildren from the UI layer.
/// </summary>
public sealed class UIElements : RemoteObject
{
// Bulk-read covers offsets 0x00 through 0x468 (Text std::wstring at 0x448 + 32 bytes).
private const int BulkReadSize = 0x468;
/// <summary>Maximum children to read per node (safety limit).</summary>
public int MaxChildrenPerNode { get; set; } = 200;
public nint UiRootPtr { get; private set; }
public nint GameUiPtr { get; private set; }
private readonly MsvcStringReader _strings;
public UIElements(MemoryContext ctx, MsvcStringReader strings) : base(ctx)
{
_strings = strings;
}
protected override bool ReadData()
{
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
// Address = InGameState pointer
// Follow: InGameState+UiRootStructOffset → UiRootStruct ptr
var uiRootStructPtr = mem.ReadPointer(Address + offsets.UiRootStructOffset);
if (uiRootStructPtr == 0 || (ulong)uiRootStructPtr >> 32 is 0 or >= 0x7FFF)
{
UiRootPtr = 0;
GameUiPtr = 0;
return true;
}
// UiRootStruct → UiRoot and GameUi element pointers
UiRootPtr = mem.ReadPointer(uiRootStructPtr + offsets.UiRootPtrOffset);
GameUiPtr = mem.ReadPointer(uiRootStructPtr + offsets.GameUiPtrOffset);
return true;
}
/// <summary>
/// Reads a single UIElement node (1 bulk RPM). Returns null if address is invalid.
/// Does NOT read children — call ReadChildren separately.
/// </summary>
public UIElementNode? ReadNode(nint addr)
{
if (addr == 0 || (ulong)addr >> 32 is 0 or >= 0x7FFF)
return null;
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
var buf = mem.ReadBytes(addr, BulkReadSize);
if (buf is null || buf.Length < BulkReadSize)
return null;
// Self-check validation
var selfPtr = (nint)BitConverter.ToInt64(buf, offsets.UiElementSelfOffset);
if (selfPtr != addr)
return null;
var stringId = ParseWStringFromBuffer(buf, offsets.UiElementStringIdOffset);
var text = ParseWStringFromBuffer(buf, offsets.UiElementTextOffset);
var flags = BitConverter.ToUInt32(buf, offsets.UiElementFlagsOffset);
var isVisible = (flags & (1u << offsets.UiElementVisibleBit)) != 0;
var width = BitConverter.ToSingle(buf, offsets.UiElementSizeOffset);
var height = BitConverter.ToSingle(buf, offsets.UiElementSizeOffset + 4);
// Children count (don't read children themselves)
var childCount = 0;
var vecBegin = (nint)BitConverter.ToInt64(buf, offsets.UiElementChildrenOffset);
var vecEnd = (nint)BitConverter.ToInt64(buf, offsets.UiElementChildrenOffset + 8);
if (vecBegin != 0 && vecEnd > vecBegin)
{
childCount = (int)(vecEnd - vecBegin) / 8;
if (childCount > 10000) childCount = 0;
}
return new UIElementNode
{
Address = addr,
StringId = stringId,
Text = text,
IsVisible = isVisible,
Width = width,
Height = height,
ChildCount = childCount,
Children = null,
};
}
/// <summary>
/// On-demand: reads immediate children of a node address.
/// Each child is a shallow UIElementNode (no grandchildren).
/// </summary>
public List<UIElementNode>? ReadChildren(nint nodeAddr)
{
if (nodeAddr == 0) return null;
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
var vecBegin = mem.ReadPointer(nodeAddr + offsets.UiElementChildrenOffset);
var vecEnd = mem.ReadPointer(nodeAddr + offsets.UiElementChildrenOffset + 8);
if (vecBegin == 0 || vecEnd <= vecBegin) return null;
var childCount = (int)(vecEnd - vecBegin) / 8;
if (childCount <= 0 || childCount > 10000) return null;
var count = Math.Min(childCount, MaxChildrenPerNode);
var ptrData = mem.ReadBytes(vecBegin, count * 8);
if (ptrData is null) return null;
var result = new List<UIElementNode>(count);
for (var i = 0; i < count; i++)
{
var childPtr = (nint)BitConverter.ToInt64(ptrData, i * 8);
var child = ReadNode(childPtr);
if (child is not null)
result.Add(child);
}
return result;
}
/// <summary>
/// On-demand text read for a specific node address (1-2 RPM).
/// </summary>
public string? ReadNodeText(nint nodeAddr)
{
if (nodeAddr == 0) return null;
return ReadUiWString(nodeAddr + Ctx.Offsets.UiElementTextOffset);
}
/// <summary>
/// Reads the Nth child of a node (0-indexed). Returns null if out of range or invalid.
/// </summary>
public UIElementNode? ReadChildAtIndex(nint nodeAddr, int index)
{
if (nodeAddr == 0 || index < 0) return null;
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
var vecBegin = mem.ReadPointer(nodeAddr + offsets.UiElementChildrenOffset);
var vecEnd = mem.ReadPointer(nodeAddr + offsets.UiElementChildrenOffset + 8);
if (vecBegin == 0 || vecEnd <= vecBegin) return null;
var childCount = (int)(vecEnd - vecBegin) / 8;
if (index >= childCount) return null;
var childPtr = mem.ReadPointer(vecBegin + index * 8);
return ReadNode(childPtr);
}
/// <summary>
/// Navigates a path of child indices from a starting node address.
/// e.g. NavigatePath(gameUiPtr, [6, 1, 0, 0, 0, 2]) → root[6][1][0][0][0][2]
/// </summary>
public UIElementNode? NavigatePath(nint startAddr, ReadOnlySpan<int> path)
{
var current = startAddr;
UIElementNode? node = null;
foreach (var idx in path)
{
node = ReadChildAtIndex(current, idx);
if (node is null) return null;
current = node.Address;
}
return node;
}
/// <summary>
/// Reads quest groups from the UI tree.
/// Path: GameUi[6][1][0][0][0] → quest_display → [0] → title_layout/quest_info_layout
/// </summary>
public List<UiQuestGroup>? ReadQuestGroups()
{
if (GameUiPtr == 0) return null;
// Navigate to the parent that holds quest_display nodes
var questParent = NavigatePath(GameUiPtr, [6, 1, 0, 0, 0]);
if (questParent is null) return null;
var questDisplays = ReadChildren(questParent.Address);
if (questDisplays is null) return null;
var groups = new List<UiQuestGroup>();
foreach (var qd in questDisplays)
{
if (!string.Equals(qd.StringId, "quest_display", StringComparison.Ordinal))
continue;
// quest_display → [0] (unnamed child with title_layout + quest_info_layout)
var qdChildren = ReadChildren(qd.Address);
if (qdChildren is null || qdChildren.Count == 0) continue;
var innerChildren = ReadChildren(qdChildren[0].Address);
if (innerChildren is null) continue;
var group = new UiQuestGroup();
foreach (var child in innerChildren)
{
if (string.Equals(child.StringId, "title_layout", StringComparison.Ordinal))
{
// title_layout → title_label (has quest name text)
var titleChildren = ReadChildren(child.Address);
if (titleChildren is not null)
{
foreach (var tc in titleChildren)
{
if (string.Equals(tc.StringId, "title_label", StringComparison.Ordinal))
group.Title = tc.Text;
}
}
}
else if (string.Equals(child.StringId, "quest_info_layout", StringComparison.Ordinal))
{
ReadQuestSteps(child.Address, group.Steps);
}
}
if (group.Title is not null || group.Steps.Count > 0)
groups.Add(group);
}
return groups.Count > 0 ? groups : null;
}
private void ReadQuestSteps(nint layoutAddr, List<UiQuestStep> steps)
{
var layoutChildren = ReadChildren(layoutAddr);
if (layoutChildren is null) return;
foreach (var entryNode in layoutChildren)
{
if (!string.Equals(entryNode.StringId, "quest_info_entry", StringComparison.Ordinal))
continue;
var entryChildren = ReadChildren(entryNode.Address);
if (entryChildren is null) continue;
var step = new UiQuestStep();
foreach (var part in entryChildren)
{
if (string.Equals(part.StringId, "quest_info", StringComparison.Ordinal))
step.Text = part.Text;
}
if (step.Text is not null)
steps.Add(step);
}
}
/// <summary>
/// BFS search: reads nodes on-demand until a matching StringId is found.
/// Walks the live game memory — use sparingly.
/// </summary>
public UIElementNode? FindByStringId(string id)
{
if (GameUiPtr == 0) return null;
var root = ReadNode(GameUiPtr);
if (root is null) return null;
if (string.Equals(root.StringId, id, StringComparison.Ordinal))
return root;
var queue = new Queue<nint>();
var visited = new HashSet<nint> { GameUiPtr };
EnqueueChildren(queue, visited, GameUiPtr);
while (queue.Count > 0)
{
var addr = queue.Dequeue();
var node = ReadNode(addr);
if (node is null) continue;
if (string.Equals(node.StringId, id, StringComparison.Ordinal))
return node;
EnqueueChildren(queue, visited, addr);
}
return null;
}
/// <summary>
/// Reads both quest linked lists (all-quests + tracked) and merges them.
/// Returns null if GameUi is not available.
/// </summary>
public List<QuestLinkedEntry>? ReadQuestLinkedLists()
{
if (GameUiPtr == 0) return null;
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
// ── Tracked quests: [6][1]+0x318 — collect into dict keyed by QuestDatPtr ──
var trackedMap = new Dictionary<nint, string?>();
var elem6 = ReadChildAtIndex(GameUiPtr, offsets.TrackedQuestPanelChildIndex);
if (elem6 is not null)
{
var elem61 = ReadChildAtIndex(elem6.Address, offsets.TrackedQuestPanelSubChildIndex);
if (elem61 is not null)
{
var trackedHead = mem.ReadPointer(elem61.Address + offsets.TrackedQuestLinkedListOffset);
if (trackedHead != 0)
TraverseTrackedQuests(trackedHead, trackedMap);
}
}
// ── All quests: GameUi+0x358 ──
var allHead = mem.ReadPointer(GameUiPtr + offsets.QuestLinkedListOffset);
if (allHead == 0) return null;
return TraverseAllQuests(allHead, trackedMap);
}
/// <summary>
/// Walks the all-quests linked list. Reads Quest.dat row fields + stateId per node.
/// Merges tracked info from the trackedMap.
/// </summary>
private List<QuestLinkedEntry>? TraverseAllQuests(nint headPtr, Dictionary<nint, string?> trackedMap)
{
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
var maxNodes = offsets.QuestLinkedListMaxNodes;
var readSize = Math.Max(offsets.QuestLinkedListNodeSize, 48);
var visited = new HashSet<nint>();
var walk = headPtr;
var isSentinel = true;
var result = new List<QuestLinkedEntry>();
while (result.Count < maxNodes)
{
if (walk == 0 || !visited.Add(walk)) break;
var nodeData = mem.ReadBytes(walk, readSize);
if (nodeData is null) break;
var next = (nint)BitConverter.ToInt64(nodeData, 0);
if (isSentinel)
{
isSentinel = false;
walk = next;
continue;
}
var questPtr = (nint)BitConverter.ToInt64(nodeData, offsets.QuestNodeQuestPtrOffset);
var stateId = BitConverter.ToInt32(nodeData, offsets.QuestNodeStateIdOffset);
string? internalId = null;
string? displayName = null;
var act = -1;
if (questPtr != 0 && ((ulong)questPtr >> 32) is > 0 and < 0x7FFF)
{
var idPtr = mem.ReadPointer(questPtr + offsets.QuestObjNamePtrOffset);
if (idPtr != 0 && ((ulong)idPtr >> 32) is > 0 and < 0x7FFF)
internalId = _strings.ReadNullTermWString(idPtr);
act = mem.Read<int>(questPtr + offsets.QuestObjActOffset);
var namePtr = mem.ReadPointer(questPtr + offsets.QuestObjDisplayNameOffset);
if (namePtr != 0 && ((ulong)namePtr >> 32) is > 0 and < 0x7FFF)
displayName = _strings.ReadNullTermWString(namePtr);
}
var isTracked = trackedMap.TryGetValue(questPtr, out var objectiveText);
result.Add(new QuestLinkedEntry
{
InternalId = internalId,
DisplayName = displayName,
Act = act,
StateId = stateId,
IsTracked = isTracked,
ObjectiveText = objectiveText,
QuestDatPtr = questPtr,
});
walk = next;
}
return result.Count > 0 ? result : null;
}
/// <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).
/// </summary>
private void TraverseTrackedQuests(nint headPtr, Dictionary<nint, string?> trackedMap)
{
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
var maxNodes = offsets.QuestLinkedListMaxNodes;
var readSize = Math.Max(offsets.QuestLinkedListNodeSize, 48);
var visited = new HashSet<nint>();
var walk = headPtr;
var isSentinel = true;
var count = 0;
while (count < maxNodes)
{
if (walk == 0 || !visited.Add(walk)) break;
var nodeData = mem.ReadBytes(walk, readSize);
if (nodeData is null) break;
var next = (nint)BitConverter.ToInt64(nodeData, 0);
if (isSentinel)
{
isSentinel = false;
walk = next;
continue;
}
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;
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;
var offsets = Ctx.Offsets;
var vecBegin = mem.ReadPointer(parentAddr + offsets.UiElementChildrenOffset);
var vecEnd = mem.ReadPointer(parentAddr + offsets.UiElementChildrenOffset + 8);
if (vecBegin == 0 || vecEnd <= vecBegin) return;
var count = Math.Min((int)(vecEnd - vecBegin) / 8, MaxChildrenPerNode);
if (count <= 0) return;
var data = mem.ReadBytes(vecBegin, count * 8);
if (data is null) return;
for (var i = 0; i < count; i++)
{
var ptr = (nint)BitConverter.ToInt64(data, i * 8);
if (ptr != 0 && visited.Add(ptr))
queue.Enqueue(ptr);
}
}
private string? ParseWStringFromBuffer(byte[] buf, int offset)
{
if (offset + 32 > buf.Length) return null;
var length = BitConverter.ToInt32(buf, offset + 0x10);
var capacity = BitConverter.ToInt32(buf, offset + 0x18);
if (length <= 0 || length > 4096 || capacity < length) return null;
try
{
if (capacity <= 8)
{
var byteLen = Math.Min(length * 2, 16);
return Encoding.Unicode.GetString(buf, offset, byteLen).TrimEnd('\0');
}
else
{
var ptr = (nint)BitConverter.ToInt64(buf, offset);
if (ptr == 0 || (ulong)ptr >> 32 is 0 or >= 0x7FFF) return null;
var charData = Ctx.Memory.ReadBytes(ptr, Math.Min(length * 2, 512));
if (charData is null) return null;
return Encoding.Unicode.GetString(charData).TrimEnd('\0');
}
}
catch { return null; }
}
private string? ReadUiWString(nint addr)
{
var strData = Ctx.Memory.ReadBytes(addr, 32);
if (strData is null || strData.Length < 28) return null;
return ParseWStringFromBuffer(strData, 0);
}
protected override void Clear()
{
UiRootPtr = 0;
GameUiPtr = 0;
}
}

View file

@ -78,6 +78,18 @@ public class GameStateSnapshot
// Quest flags (from ServerData → PlayerServerData)
public List<QuestSnapshot>? QuestFlags;
// Quest states (from AreaInstance → sub-object → vector)
public List<QuestStateEntry>? QuestStates;
// UI tree — root pointer only; tree is read on-demand
public nint GameUiPtr;
// Quest linked lists (all-quests + tracked merged)
public List<QuestLinkedEntry>? QuestLinkedList;
// Quest groups from UI element tree
public List<UiQuestGroup>? UiQuestGroups;
// Camera
public Matrix4x4? CameraMatrix;

View file

@ -0,0 +1,24 @@
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.
/// </summary>
public sealed class QuestLinkedEntry
{
/// <summary>Internal quest ID from Quest.dat row, e.g. "TreeOfSouls".</summary>
public string? InternalId { get; init; }
/// <summary>Display name from Quest.dat row, e.g. "Secrets in the Dark".</summary>
public string? DisplayName { get; init; }
/// <summary>Act number from Quest.dat row.</summary>
public int Act { get; init; }
/// <summary>State: 0=done, -1(0xFFFFFFFF)=locked, positive=in-progress step.</summary>
public int StateId { 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; }
}

View file

@ -0,0 +1,13 @@
namespace Roboto.Memory;
/// <summary>
/// A quest state entry from the AreaInstance quest state container.
/// 12-byte struct: {int QuestId, int State, int Flags}.
/// Discovered via ScanQuestStateContainers at AI+0x900 → obj → +0x240 vector.
/// </summary>
public sealed class QuestStateEntry
{
public int QuestId { get; init; }
public int State { get; init; }
public int Flags { get; init; }
}

View file

@ -0,0 +1,17 @@
namespace Roboto.Memory;
/// <summary>
/// Lightweight snapshot of a single UIElement from the game's UI tree.
/// Built by UIElements RemoteObject during cold tick reads.
/// </summary>
public sealed class UIElementNode
{
public nint Address { get; init; }
public string? StringId { get; init; }
public string? Text { get; init; }
public bool IsVisible { get; init; }
public float Width { get; init; }
public float Height { get; init; }
public int ChildCount { get; init; }
public List<UIElementNode>? Children { get; init; }
}

View file

@ -0,0 +1,22 @@
namespace Roboto.Memory;
/// <summary>
/// A quest group from the UI element tree (one per quest_display).
/// Contains the quest title and current objective steps.
/// </summary>
public sealed class UiQuestGroup
{
/// <summary>Quest title from title_layout → title_label (e.g. "Treacherous Ground").</summary>
public string? Title { get; set; }
/// <summary>Current quest objective steps.</summary>
public List<UiQuestStep> Steps { get; set; } = [];
}
/// <summary>
/// A single quest objective from quest_info_entry → quest_info.
/// </summary>
public sealed class UiQuestStep
{
/// <summary>Objective text (e.g. "Search Clearfell for the entrance to the Mud Burrow").</summary>
public string? Text { get; set; }
}