quests and queststate work
This commit is contained in:
parent
94b460bbc8
commit
445ae1387c
27 changed files with 3815 additions and 179 deletions
528
src/Roboto.Memory/Objects/UIElements.cs
Normal file
528
src/Roboto.Memory/Objects/UIElements.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue