using System.Text; namespace Roboto.Memory.Objects; /// /// 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. /// public sealed class UIElements : RemoteObject { // Bulk-read covers offsets 0x00 through 0x468 (Text std::wstring at 0x448 + 32 bytes). private const int BulkReadSize = 0x468; /// Maximum children to read per node (safety limit). public int MaxChildrenPerNode { get; set; } = 200; public nint UiRootPtr { get; private set; } public nint GameUiPtr { get; private set; } private readonly MsvcStringReader _strings; // Cached quest parent pointers — resolved once, reused across reads private nint _cachedTrackedPanelAddr; // GameUi[6][1] private nint _cachedQuestParentAddr; // GameUi[6][1][0][0][0] private nint _cachedForGameUi; // GameUiPtr when cache was built /// Optional lookup for resolving quest state IDs to human-readable text. public QuestStateLookup? QuestStateLookup { get; set; } /// Optional files container for resolving MapPins → WorldAreas. public FilesContainer? FilesContainer { get; set; } /// Current area's WorldAreas.dat row address — set by GameMemoryReader for path resolution. public nint CurrentAreaRowAddress { get; set; } 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; } /// /// Reads a single UIElement node (1 bulk RPM). Returns null if address is invalid. /// Does NOT read children — call ReadChildren separately. /// 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, }; } /// /// On-demand: reads immediate children of a node address. /// Each child is a shallow UIElementNode (no grandchildren). /// public List? 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(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; } /// /// On-demand text read for a specific node address (1-2 RPM). /// public string? ReadNodeText(nint nodeAddr) { if (nodeAddr == 0) return null; return ReadUiWString(nodeAddr + Ctx.Offsets.UiElementTextOffset); } /// /// Reads the Nth child of a node (0-indexed). Returns null if out of range or invalid. /// 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); } /// /// 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] /// public UIElementNode? NavigatePath(nint startAddr, ReadOnlySpan 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; } /// /// Resolves and caches quest parent pointers from the UI tree. /// Called lazily; cache invalidated when GameUiPtr changes. /// private void EnsureQuestPointerCache() { if (_cachedForGameUi == GameUiPtr && _cachedTrackedPanelAddr != 0) return; // cache is valid _cachedForGameUi = GameUiPtr; _cachedTrackedPanelAddr = 0; _cachedQuestParentAddr = 0; if (GameUiPtr == 0) return; var offsets = Ctx.Offsets; // GameUi[6] → [1] for tracked quests var elem6 = ReadChildAtIndex(GameUiPtr, offsets.TrackedQuestPanelChildIndex); if (elem6 is not null) { var elem61 = ReadChildAtIndex(elem6.Address, offsets.TrackedQuestPanelSubChildIndex); if (elem61 is not null) _cachedTrackedPanelAddr = elem61.Address; } // GameUi[6][1][0][0][0] for quest groups var questParent = NavigatePath(GameUiPtr, [6, 1, 0, 0, 0]); if (questParent is not null) _cachedQuestParentAddr = questParent.Address; } /// /// Reads quest groups from the UI tree. /// Path: GameUi[6][1][0][0][0] → quest_display → [0] → title_layout/quest_info_layout /// public List? ReadQuestGroups() { if (GameUiPtr == 0) return null; EnsureQuestPointerCache(); if (_cachedQuestParentAddr == 0) return null; var questDisplays = ReadChildren(_cachedQuestParentAddr); if (questDisplays is null) return null; var groups = new List(); 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 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); } } /// /// BFS search: reads nodes on-demand until a matching StringId is found. /// Walks the live game memory — use sparingly. /// 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(); var visited = new HashSet { 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; } /// /// Reads both quest linked lists (all-quests + tracked) and merges them. /// Returns null if GameUi is not available. /// public List? ReadQuestLinkedLists() { if (GameUiPtr == 0) return null; var mem = Ctx.Memory; var offsets = Ctx.Offsets; EnsureQuestPointerCache(); // ── Tracked quests: cached [6][1]+0x318 — collect into dict keyed by QuestDatPtr ── var trackedMap = new Dictionary(); if (_cachedTrackedPanelAddr != 0) { var trackedHead = mem.ReadPointer(_cachedTrackedPanelAddr + 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); } /// /// Walks the all-quests linked list. Reads Quest.dat row fields + stateId per node. /// Merges tracked info from the trackedMap. /// private List? TraverseAllQuests(nint headPtr, Dictionary trackedMap) { var mem = Ctx.Memory; var offsets = Ctx.Offsets; var maxNodes = offsets.QuestLinkedListMaxNodes; var readSize = Math.Max(offsets.QuestLinkedListNodeSize, 48); var visited = new HashSet(); var walk = headPtr; var isSentinel = true; var result = new List(); 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(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.ContainsKey(questPtr); string? stateText = null; string? mapPinsText = null; List? targetAreas = null; List? pathToTarget = null; if (QuestStateLookup is not null && questPtr != 0 && stateId > 0) { QuestStateLookup.TryGetStateText(questPtr, stateId, out stateText); // Resolve target areas via MapPins → WorldAreas if (FilesContainer is not null) { var questStateRow = QuestStateLookup.GetQuestStateRow(questPtr, stateId); if (questStateRow is not null) { mapPinsText = questStateRow.MapPinsText; var worldAreas = FilesContainer.GetMapPinWorldAreas(questStateRow); if (worldAreas.Count > 0) { targetAreas = worldAreas.Select(wa => new ConnectedAreaInfo { Id = wa.Id, Name = wa.Name, Act = wa.Act, IsTown = wa.IsTown, HasWaypoint = wa.HasWaypoint, MonsterLevel = wa.MonsterLevel, WorldAreaId = wa.WorldAreaId, }).ToList(); // BFS path from current area to first target area if (CurrentAreaRowAddress != 0) { var currentArea = FilesContainer.WorldAreas.GetByAddress(CurrentAreaRowAddress); if (currentArea is not null) { var path = FilesContainer.FindPath(currentArea, worldAreas[0]); if (path is { Count: > 1 }) pathToTarget = path.Select(p => p.Name ?? p.Id ?? "?").ToList(); } } } } } } result.Add(new QuestLinkedEntry { InternalId = internalId, DisplayName = displayName, Act = act, StateId = stateId, StateText = stateText, IsTracked = isTracked, QuestDatPtr = questPtr, MapPinsText = mapPinsText, TargetAreas = targetAreas, PathToTarget = pathToTarget, }); walk = next; } return result.Count > 0 ? result : null; } /// /// Walks the tracked-quests linked list. Builds a dict of QuestDatPtr → null. /// Tracked status is used; objective text is not available from runtime memory /// (stateObj contains quest flag arrays, not display text). /// private void TraverseTrackedQuests(nint headPtr, Dictionary trackedMap) { var mem = Ctx.Memory; var offsets = Ctx.Offsets; var maxNodes = offsets.QuestLinkedListMaxNodes; var readSize = Math.Max(offsets.QuestLinkedListNodeSize, 48); var visited = new HashSet(); 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); if (questPtr != 0) trackedMap[questPtr] = null; count++; walk = next; } } private void EnqueueChildren(Queue queue, HashSet 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; _cachedForGameUi = 0; _cachedTrackedPanelAddr = 0; _cachedQuestParentAddr = 0; } }