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

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