quests and queststate work
This commit is contained in:
parent
94b460bbc8
commit
445ae1387c
27 changed files with 3815 additions and 179 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
24
src/Roboto.Memory/Snapshots/QuestLinkedEntry.cs
Normal file
24
src/Roboto.Memory/Snapshots/QuestLinkedEntry.cs
Normal 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; }
|
||||
}
|
||||
13
src/Roboto.Memory/Snapshots/QuestStateEntry.cs
Normal file
13
src/Roboto.Memory/Snapshots/QuestStateEntry.cs
Normal 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; }
|
||||
}
|
||||
17
src/Roboto.Memory/Snapshots/UIElementNode.cs
Normal file
17
src/Roboto.Memory/Snapshots/UIElementNode.cs
Normal 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; }
|
||||
}
|
||||
22
src/Roboto.Memory/Snapshots/UiQuestEntry.cs
Normal file
22
src/Roboto.Memory/Snapshots/UiQuestEntry.cs
Normal 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; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue