skills working somewhat
This commit is contained in:
parent
a8c43ba7e2
commit
8a0e4bb481
22 changed files with 4227 additions and 161 deletions
|
|
@ -41,6 +41,7 @@ public class GameMemoryReader : IDisposable
|
|||
private RttiResolver? _rtti;
|
||||
private SkillReader? _skills;
|
||||
private QuestReader? _quests;
|
||||
private QuestNameLookup? _questNames;
|
||||
|
||||
public ObjectRegistry Registry => _registry;
|
||||
public MemoryDiagnostics? Diagnostics { get; private set; }
|
||||
|
|
@ -100,7 +101,8 @@ public class GameMemoryReader : IDisposable
|
|||
_entities = new EntityReader(_ctx, _components, _strings);
|
||||
_terrain = new TerrainReader(_ctx);
|
||||
_skills = new SkillReader(_ctx, _components, _strings);
|
||||
_quests = new QuestReader(_ctx, _strings);
|
||||
_questNames ??= LoadQuestNames();
|
||||
_quests = new QuestReader(_ctx, _strings, _questNames);
|
||||
Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
|
||||
|
||||
return true;
|
||||
|
|
@ -119,9 +121,21 @@ public class GameMemoryReader : IDisposable
|
|||
_rtti = null;
|
||||
_skills = null;
|
||||
_quests = null;
|
||||
// _questNames intentionally kept — reloaded only once
|
||||
Diagnostics = null;
|
||||
}
|
||||
|
||||
private static QuestNameLookup? LoadQuestNames()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "quest_names.json");
|
||||
if (!File.Exists(path))
|
||||
path = "quest_names.json"; // fallback to working directory
|
||||
|
||||
var lookup = new QuestNameLookup();
|
||||
lookup.Load(path);
|
||||
return lookup.IsLoaded ? lookup : null;
|
||||
}
|
||||
|
||||
public GameStateSnapshot ReadSnapshot()
|
||||
{
|
||||
var snap = new GameStateSnapshot();
|
||||
|
|
@ -223,7 +237,17 @@ public class GameMemoryReader : IDisposable
|
|||
_components.ReadPlayerVitals(snap);
|
||||
_components.ReadPlayerPosition(snap);
|
||||
snap.CharacterName = _components.ReadPlayerName(snap.LocalPlayerPtr);
|
||||
snap.PlayerSkills = _skills!.ReadPlayerSkills(snap.LocalPlayerPtr);
|
||||
|
||||
// Resolve PSD for skill bar + quest reads
|
||||
nint psdPtr = 0;
|
||||
if (snap.ServerDataPtr != 0)
|
||||
{
|
||||
var psdVecBegin = mem.ReadPointer(snap.ServerDataPtr + offsets.PlayerServerDataOffset);
|
||||
if (psdVecBegin != 0)
|
||||
psdPtr = mem.ReadPointer(psdVecBegin);
|
||||
}
|
||||
|
||||
snap.PlayerSkills = _skills!.ReadPlayerSkills(snap.LocalPlayerPtr, psdPtr);
|
||||
snap.QuestFlags = _quests!.ReadQuestFlags(snap.ServerDataPtr);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,68 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Serilog;
|
||||
|
||||
namespace Roboto.Memory;
|
||||
|
||||
/// <summary>
|
||||
/// Reads/writes int as hex strings ("0x1A8") or plain numbers (424).
|
||||
/// On write, values >= 16 are emitted as "0xHEX", smaller values as plain numbers.
|
||||
/// </summary>
|
||||
internal sealed class HexIntConverter : JsonConverter<int>
|
||||
{
|
||||
public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Number)
|
||||
return reader.GetInt32();
|
||||
|
||||
var s = reader.GetString();
|
||||
if (s is null) return 0;
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return int.Parse(s.AsSpan(2), NumberStyles.HexNumber);
|
||||
return int.Parse(s);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value >= 16)
|
||||
writer.WriteStringValue($"0x{value:X}");
|
||||
else
|
||||
writer.WriteNumberValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Same as HexIntConverter but for uint (e.g. QuestTrackedMarker).</summary>
|
||||
internal sealed class HexUintConverter : JsonConverter<uint>
|
||||
{
|
||||
public override uint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Number)
|
||||
return reader.GetUInt32();
|
||||
|
||||
var s = reader.GetString();
|
||||
if (s is null) return 0;
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return uint.Parse(s.AsSpan(2), NumberStyles.HexNumber);
|
||||
return uint.Parse(s);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, uint value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value >= 16)
|
||||
writer.WriteStringValue($"0x{value:X}");
|
||||
else
|
||||
writer.WriteNumberValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GameOffsets
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Converters = { new HexIntConverter(), new HexUintConverter() }
|
||||
};
|
||||
|
||||
public string ProcessName { get; set; } = "PathOfExileSteam";
|
||||
|
|
@ -60,9 +113,9 @@ public sealed class GameOffsets
|
|||
public int AreaLevelStaticOffset { get; set; } = 0;
|
||||
/// <summary>AreaInstance → CurrentAreaHash uint (dump: 0xEC).</summary>
|
||||
public int AreaHashOffset { get; set; } = 0xEC;
|
||||
/// <summary>AreaInstance → ServerData pointer (dump: 0x9F0 via LocalPlayerStruct.ServerDataPtr).</summary>
|
||||
public int ServerDataOffset { get; set; } = 0x9F0;
|
||||
/// <summary>AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr).</summary>
|
||||
/// <summary>AreaInstance → ServerData pointer. Heap object with vtable, StdVector at +0x50 for PlayerServerData.</summary>
|
||||
public int ServerDataOffset { get; set; } = 0xA08;
|
||||
/// <summary>AreaInstance → LocalPlayer entity pointer.</summary>
|
||||
public int LocalPlayerDirectOffset { get; set; } = 0xA10;
|
||||
/// <summary>AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50).</summary>
|
||||
public int EntityListOffset { get; set; } = 0xB50;
|
||||
|
|
@ -90,24 +143,56 @@ public sealed class GameOffsets
|
|||
// ServerData → fields
|
||||
/// <summary>ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).</summary>
|
||||
public int LocalPlayerOffset { get; set; } = 0x20;
|
||||
/// <summary>ServerData → PlayerServerData pointer (PerPlayerServerData struct).</summary>
|
||||
public int PlayerServerDataOffset { get; set; } = 0x50;
|
||||
/// <summary>PlayerServerData → QuestFlags container offset (PerPlayerServerDataOffsets: 0x230 = 560).</summary>
|
||||
public int QuestFlagsOffset { get; set; } = 0x230;
|
||||
/// <summary>Size of each quest flag entry in bytes. 0 = disabled (offsets not yet discovered via CE).</summary>
|
||||
public int QuestFlagEntrySize { get; set; } = 0;
|
||||
/// <summary>Offset within each quest entry to the Quest.dat row pointer.</summary>
|
||||
/// <summary>ServerData → PlayerServerData StdVector (begin/end/cap). ExileCore says 0x50 but current binary has it at 0x48.</summary>
|
||||
public int PlayerServerDataOffset { get; set; } = 0x48;
|
||||
/// <summary>PSD → SkillBarIds: Buffer13 of (ushort Id, ushort Id2). 13 slots × 4 bytes = 52 bytes. Discovered via skillbar_scanner.py.</summary>
|
||||
public int SkillBarIdsOffset { get; set; } = 0x71A8;
|
||||
|
||||
/// <summary>PSD → QuestState int32 index vector (StdVector of int32). CE confirmed: 0x308.</summary>
|
||||
public int QuestFlagsOffset { get; set; } = 0x308;
|
||||
/// <summary>Size of each quest flag entry in bytes. 4 = int32 QuestState.dat row index.</summary>
|
||||
public int QuestFlagEntrySize { get; set; } = 4;
|
||||
/// <summary>Offset within each quest entry to the Quest.dat row pointer (legacy pointer mode, 0 = N/A).</summary>
|
||||
public int QuestEntryQuestPtrOffset { get; set; } = 0;
|
||||
/// <summary>Offset within each quest entry to the byte state ID.</summary>
|
||||
/// <summary>Offset within each quest entry to the byte state ID (legacy pointer mode, 0 = N/A).</summary>
|
||||
public int QuestEntryStateIdOffset { get; set; } = 0;
|
||||
/// <summary>Offset within each quest entry to the wchar* state text pointer.</summary>
|
||||
/// <summary>Offset within each quest entry to the wchar* state text pointer (legacy pointer mode, 0 = N/A).</summary>
|
||||
public int QuestEntryStateTextOffset { get; set; } = 0;
|
||||
/// <summary>Offset within each quest entry to the wchar* progress text pointer.</summary>
|
||||
/// <summary>Offset within each quest entry to the wchar* progress text pointer (legacy pointer mode, 0 = N/A).</summary>
|
||||
public int QuestEntryProgressTextOffset { get; set; } = 0;
|
||||
/// <summary>Container type for quest flags: "vector" or "map".</summary>
|
||||
public string QuestFlagsContainerType { get; set; } = "vector";
|
||||
/// <summary>Container type: "int_vector" = flat int32 array of QuestState.dat indices, "vector" = struct entries with pointers.</summary>
|
||||
public string QuestFlagsContainerType { get; set; } = "int_vector";
|
||||
/// <summary>Maximum number of quest entries to read (sanity limit).</summary>
|
||||
public int QuestFlagsMaxEntries { get; set; } = 128;
|
||||
public int QuestFlagsMaxEntries { get; set; } = 256;
|
||||
/// <summary>PSD offset to quest struct count field (QF+0x020 = PSD+0x250). 0 = disabled.</summary>
|
||||
public int QuestCountOffset { get; set; } = 0x250;
|
||||
|
||||
// ── QuestFlags companion vector (QF+0x018): 24-byte structs ──
|
||||
// Layout: +0x00 int32 QuestStateId, +0x04 uint32 TrackedFlag, +0x10 ptr QuestStateObj
|
||||
/// <summary>Offset from QuestFlags to companion StdVector (begin/end/cap). 0x18 = QF+0x018.</summary>
|
||||
public int QuestCompanionOffset { get; set; } = 0x18;
|
||||
/// <summary>Size of each companion entry in bytes.</summary>
|
||||
public int QuestCompanionEntrySize { get; set; } = 24;
|
||||
/// <summary>Offset within companion entry to the QuestStates.dat row pointer (legacy, broken for POE2).</summary>
|
||||
public int QuestCompanionDatPtrOffset { get; set; } = 0x10;
|
||||
/// <summary>Offset within companion entry to the tracked flag uint32. Value == QuestTrackedMarker means tracked. 0x04.</summary>
|
||||
public int QuestCompanionTrackedOffset { get; set; } = 0x04;
|
||||
/// <summary>Offset within companion entry to the quest state object pointer. 0x10.</summary>
|
||||
public int QuestCompanionObjPtrOffset { get; set; } = 0x10;
|
||||
/// <summary>uint32 marker value indicating a quest is currently tracked in the UI. 0x43020000 = float 130.0.</summary>
|
||||
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;
|
||||
|
||||
// ── 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>
|
||||
public int QuestDatNameOffset { get; set; } = 0x00;
|
||||
/// <summary>Dat row → Internal quest ID wchar* pointer (e.g. "TreeOfSouls2").</summary>
|
||||
public int QuestDatInternalIdOffset { get; set; } = 0x6B;
|
||||
/// <summary>Dat row → Act/phase number int32.</summary>
|
||||
public int QuestDatActOffset { get; set; } = 0x73;
|
||||
|
||||
// ── Entity / Component ──
|
||||
public int ComponentListOffset { get; set; } = 0x10;
|
||||
|
|
@ -150,6 +235,36 @@ public sealed class GameOffsets
|
|||
/// <summary>Offset within Camera struct to the Matrix4x4 (64 bytes). 0 = disabled.</summary>
|
||||
public int CameraMatrixOffset { get; set; } = 0x1A0;
|
||||
|
||||
// ── UiRootStruct (InGameState → UI tree roots) ──
|
||||
/// <summary>Offset from InGameState to UiRootStruct pointer. GameOverlay2: 0x340.</summary>
|
||||
public int UiRootStructOffset { get; set; } = 0x340;
|
||||
/// <summary>Offset within UiRootStruct to UiRoot UIElement pointer. GameOffsetsNew: 0x5B8.</summary>
|
||||
public int UiRootPtrOffset { get; set; } = 0x5B8;
|
||||
/// <summary>Offset within UiRootStruct to GameUi UIElement pointer. GameOffsetsNew: 0xBE0.</summary>
|
||||
public int GameUiPtrOffset { get; set; } = 0xBE0;
|
||||
/// <summary>Offset within UiRootStruct to GameUiController pointer. GameOffsetsNew: 0xBE8.</summary>
|
||||
public int GameUiControllerPtrOffset { get; set; } = 0xBE8;
|
||||
|
||||
// ── UIElement offsets (GameOffsetsNew UiElementBaseOffset) ──
|
||||
/// <summary>UIElement → Self pointer (validation: should equal element address). 0x08.</summary>
|
||||
public int UiElementSelfOffset { get; set; } = 0x08;
|
||||
/// <summary>UIElement → StdVector of child UIElement pointers (begin/end/cap). 0x10.</summary>
|
||||
public int UiElementChildrenOffset { get; set; } = 0x10;
|
||||
/// <summary>UIElement → Parent UIElement pointer. 0xB8.</summary>
|
||||
public int UiElementParentOffset { get; set; } = 0xB8;
|
||||
/// <summary>UIElement → StdWString StringId (inline MSVC std::wstring). 0x98.</summary>
|
||||
public int UiElementStringIdOffset { get; set; } = 0x98;
|
||||
/// <summary>UIElement → Flags uint32 (visibility at bit 0x0B). 0x180.</summary>
|
||||
public int UiElementFlagsOffset { get; set; } = 0x180;
|
||||
/// <summary>Bit position for IsVisible in UIElement Flags. 0x0B = bit 11.</summary>
|
||||
public int UiElementVisibleBit { get; set; } = 0x0B;
|
||||
/// <summary>UIElement → UnscaledSize (float, float). 0x288.</summary>
|
||||
public int UiElementSizeOffset { get; set; } = 0x288;
|
||||
/// <summary>UIElement → Display text StdWString. 0x448. Not all elements have text.</summary>
|
||||
public int UiElementTextOffset { get; set; } = 0x448;
|
||||
/// <summary>How many bytes to scan from InGameState for UIElement pointers (0x1000 = 4KB).</summary>
|
||||
public int UiElementScanRange { get; set; } = 0x1000;
|
||||
|
||||
// ── Terrain (inline in AreaInstance) ──
|
||||
/// <summary>Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).</summary>
|
||||
public int TerrainListOffset { get; set; } = 0xCC0;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
67
src/Roboto.Memory/QuestNameLookup.cs
Normal file
67
src/Roboto.Memory/QuestNameLookup.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Text.Json;
|
||||
using Serilog;
|
||||
|
||||
namespace Roboto.Memory;
|
||||
|
||||
/// <summary>
|
||||
/// Loads quest name mappings from a JSON file (generated by tools/dump_quest_names.py).
|
||||
/// Provides QuestStateId → (name, internalId, act) lookup.
|
||||
/// </summary>
|
||||
public sealed class QuestNameLookup
|
||||
{
|
||||
private readonly Dictionary<int, QuestNameEntry> _entries = new();
|
||||
private bool _loaded;
|
||||
|
||||
public record QuestNameEntry(string? Name, string? InternalId, int Act);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to load quest names from the given JSON path.
|
||||
/// File format: { "0": { "name": "...", "internalId": "...", "act": 1 }, ... }
|
||||
/// </summary>
|
||||
public void Load(string path)
|
||||
{
|
||||
_entries.Clear();
|
||||
_loaded = false;
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Debug("Quest names file not found: {Path}", path);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!int.TryParse(prop.Name, out var idx))
|
||||
continue;
|
||||
|
||||
var obj = prop.Value;
|
||||
var name = obj.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String
|
||||
? n.GetString() : null;
|
||||
var internalId = obj.TryGetProperty("internalId", out var id) && id.ValueKind == JsonValueKind.String
|
||||
? id.GetString() : null;
|
||||
var act = obj.TryGetProperty("act", out var a) && a.ValueKind == JsonValueKind.Number
|
||||
? a.GetInt32() : 0;
|
||||
|
||||
_entries[idx] = new QuestNameEntry(name, internalId, act);
|
||||
}
|
||||
|
||||
_loaded = true;
|
||||
Log.Information("Loaded {Count} quest names from {Path}", _entries.Count, path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to load quest names from {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLoaded => _loaded;
|
||||
public int Count => _entries.Count;
|
||||
|
||||
public bool TryGet(int questStateIndex, out QuestNameEntry? entry)
|
||||
=> _entries.TryGetValue(questStateIndex, out entry);
|
||||
}
|
||||
|
|
@ -8,32 +8,42 @@ namespace Roboto.Memory;
|
|||
/// </summary>
|
||||
public sealed class QuestSnapshot
|
||||
{
|
||||
/// <summary>QuestState.dat row index (int_vector mode) or 0 (pointer mode).</summary>
|
||||
public int QuestStateIndex { get; init; }
|
||||
public nint QuestDatPtr { get; init; }
|
||||
public string? QuestName { get; init; }
|
||||
/// <summary>Internal quest ID from dat row (e.g. "TreeOfSouls2", "IncursionQuest1_Act1").</summary>
|
||||
public string? InternalId { get; init; }
|
||||
/// <summary>Encounter state from quest state object: 1=locked/not encountered, 2=available/started.</summary>
|
||||
public byte StateId { get; init; }
|
||||
/// <summary>True if this quest is the currently tracked/active quest in the UI.</summary>
|
||||
public bool IsTracked { get; init; }
|
||||
public string? StateText { get; init; }
|
||||
public string? ProgressText { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads quest flags from ServerData → PlayerServerData → QuestFlags vector.
|
||||
/// Follows the same pattern as SkillReader: bulk-reads vector data, resolves
|
||||
/// quest names by following dat row pointers, caches results.
|
||||
/// When QuestFlagEntrySize == 0 (offsets not yet discovered), gracefully returns null.
|
||||
/// Reads quest flags from ServerData → PlayerServerData → QuestFlags.
|
||||
/// Supports two modes:
|
||||
/// - "int_vector": flat StdVector of int32 QuestState.dat row indices (POE2)
|
||||
/// - "vector": struct entries with dat row pointers and string fields (POE1/legacy)
|
||||
/// When QuestFlagEntrySize == 0, gracefully returns null.
|
||||
/// </summary>
|
||||
public sealed class QuestReader
|
||||
{
|
||||
private readonly MemoryContext _ctx;
|
||||
private readonly MsvcStringReader _strings;
|
||||
private readonly QuestNameLookup? _nameLookup;
|
||||
|
||||
// Name cache — quest names are static, only refresh on ServerData change
|
||||
private readonly Dictionary<nint, string?> _nameCache = new();
|
||||
private nint _lastServerData;
|
||||
private nint _lastPsd;
|
||||
|
||||
public QuestReader(MemoryContext ctx, MsvcStringReader strings)
|
||||
public QuestReader(MemoryContext ctx, MsvcStringReader strings, QuestNameLookup? nameLookup = null)
|
||||
{
|
||||
_ctx = ctx;
|
||||
_strings = strings;
|
||||
_nameLookup = nameLookup;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -45,43 +55,181 @@ public sealed class QuestReader
|
|||
if (serverDataPtr == 0) return null;
|
||||
|
||||
var offsets = _ctx.Offsets;
|
||||
|
||||
// Guard: entry size 0 means offsets not yet discovered via CE
|
||||
if (offsets.QuestFlagEntrySize <= 0) return null;
|
||||
|
||||
var mem = _ctx.Memory;
|
||||
|
||||
// ServerData+0x50 is a StdVector of pointers to PerPlayerServerData structs.
|
||||
// Read vector begin, then dereference to get the first entry.
|
||||
// ServerData → PlayerServerData StdVector (vector of pointers, deref [0])
|
||||
var psdVecBegin = mem.ReadPointer(serverDataPtr + offsets.PlayerServerDataOffset);
|
||||
if (psdVecBegin == 0) return null;
|
||||
|
||||
// Dereference: vector[0] is a pointer to the actual PerPlayerServerData struct
|
||||
var playerServerData = mem.ReadPointer(psdVecBegin);
|
||||
if (playerServerData == 0) return null;
|
||||
|
||||
// Invalidate name cache on ServerData change (area transition)
|
||||
if (playerServerData != _lastServerData)
|
||||
// Invalidate cache on PSD change (area transition)
|
||||
if (playerServerData != _lastPsd)
|
||||
{
|
||||
_nameCache.Clear();
|
||||
_lastServerData = playerServerData;
|
||||
_lastPsd = playerServerData;
|
||||
}
|
||||
|
||||
// PerPlayerServerData → QuestFlags (+0x230)
|
||||
var questFlagsAddr = playerServerData + offsets.QuestFlagsOffset;
|
||||
|
||||
if (offsets.QuestFlagsContainerType == "vector")
|
||||
return ReadVectorQuests(questFlagsAddr, offsets);
|
||||
if (offsets.QuestFlagsContainerType == "int_vector")
|
||||
return ReadIntVectorQuests(questFlagsAddr, offsets);
|
||||
|
||||
if (offsets.QuestFlagsContainerType == "vector")
|
||||
return ReadStructVectorQuests(questFlagsAddr, offsets);
|
||||
|
||||
// Future: "map" container type
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<QuestSnapshot>? ReadVectorQuests(nint questFlagsAddr, GameOffsets offsets)
|
||||
/// <summary>
|
||||
/// POE2 mode: reads a StdVector of int32 QuestState.dat row indices (QF+0x000),
|
||||
/// plus a companion StdVector of 24-byte structs (QF+0x018) that contain:
|
||||
/// +0x00 int32: QuestStateId (= .dat row index, same as int32 vector value)
|
||||
/// +0x04 uint32: TrackedFlag (0x43020000 = currently tracked quest in UI, 0 = not tracked)
|
||||
/// +0x10 ptr: Quest state object (runtime object, NOT a .dat row)
|
||||
/// The quest state object has encounter state at +0x008 (byte: 1=locked, 2=started).
|
||||
/// Quest names are resolved from the .dat table base if configured.
|
||||
/// </summary>
|
||||
private List<QuestSnapshot>? ReadIntVectorQuests(nint questFlagsAddr, GameOffsets offsets)
|
||||
{
|
||||
var mem = _ctx.Memory;
|
||||
|
||||
// Read int32 index vector (QF+0x000)
|
||||
var vecBegin = mem.ReadPointer(questFlagsAddr);
|
||||
var vecEnd = mem.ReadPointer(questFlagsAddr + 8);
|
||||
if (vecBegin == 0 || vecEnd <= vecBegin) return null;
|
||||
|
||||
var totalBytes = (int)(vecEnd - vecBegin);
|
||||
var entryCount = totalBytes / 4; // int32 = 4 bytes
|
||||
if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null;
|
||||
|
||||
var vecData = mem.ReadBytes(vecBegin, totalBytes);
|
||||
if (vecData is null) return null;
|
||||
|
||||
// Read companion vector (QF+0x018) for quest state objects
|
||||
byte[]? compData = null;
|
||||
var compEntryCount = 0;
|
||||
if (offsets.QuestCompanionOffset > 0 && offsets.QuestCompanionEntrySize > 0)
|
||||
{
|
||||
var compBegin = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset);
|
||||
var compEnd = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset + 8);
|
||||
if (compBegin != 0 && compEnd > compBegin)
|
||||
{
|
||||
var compBytes = (int)(compEnd - compBegin);
|
||||
compEntryCount = compBytes / offsets.QuestCompanionEntrySize;
|
||||
if (compEntryCount > 0 && compBytes < 0x100000)
|
||||
compData = mem.ReadBytes(compBegin, compBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Find .dat table base if configured (for quest name resolution)
|
||||
var datTableBase = FindDatTableBase(offsets);
|
||||
|
||||
var result = new List<QuestSnapshot>(entryCount);
|
||||
|
||||
for (var i = 0; i < entryCount; i++)
|
||||
{
|
||||
var idx = BitConverter.ToInt32(vecData, i * 4);
|
||||
string? questName = null;
|
||||
string? internalId = null;
|
||||
byte stateId = 0;
|
||||
bool isTracked = false;
|
||||
nint questObjPtr = 0;
|
||||
|
||||
if (compData is not null && i < compEntryCount)
|
||||
{
|
||||
var compOff = i * offsets.QuestCompanionEntrySize;
|
||||
|
||||
// Read tracked flag from companion +0x04
|
||||
if (offsets.QuestCompanionTrackedOffset > 0 &&
|
||||
compOff + offsets.QuestCompanionTrackedOffset + 4 <= compData.Length)
|
||||
{
|
||||
var trackedVal = BitConverter.ToUInt32(compData, compOff + offsets.QuestCompanionTrackedOffset);
|
||||
isTracked = trackedVal == offsets.QuestTrackedMarker;
|
||||
}
|
||||
|
||||
// Read quest state object pointer from companion +0x10
|
||||
if (compOff + offsets.QuestCompanionObjPtrOffset + 8 <= compData.Length)
|
||||
{
|
||||
questObjPtr = (nint)BitConverter.ToInt64(compData, compOff + offsets.QuestCompanionObjPtrOffset);
|
||||
|
||||
// Read encounter state byte from quest state object +0x008
|
||||
if (questObjPtr != 0 && ((ulong)questObjPtr >> 32) is > 0 and < 0x7FFF
|
||||
&& offsets.QuestObjEncounterStateOffset > 0)
|
||||
{
|
||||
var stateByte = mem.ReadBytes(questObjPtr + offsets.QuestObjEncounterStateOffset, 1);
|
||||
if (stateByte is { Length: 1 })
|
||||
stateId = stateByte[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve quest name: try .dat table first, then JSON lookup fallback
|
||||
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
|
||||
{
|
||||
QuestStateIndex = idx,
|
||||
QuestDatPtr = questObjPtr,
|
||||
QuestName = questName,
|
||||
InternalId = internalId,
|
||||
StateId = stateId,
|
||||
IsTracked = isTracked,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the QuestStates.dat row table base address.
|
||||
/// Uses QuestDatTableBase offset from PSD if configured, otherwise returns 0.
|
||||
/// </summary>
|
||||
private nint FindDatTableBase(GameOffsets offsets)
|
||||
{
|
||||
if (offsets.QuestDatRowSize <= 0) return 0;
|
||||
// Future: auto-discover table base by scanning for known patterns
|
||||
// For now, table base must be found externally and is not resolved here
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Reads a wchar* pointer at the given address and returns the string.</summary>
|
||||
private string? ResolveDatString(nint fieldAddr)
|
||||
{
|
||||
if (_nameCache.TryGetValue(fieldAddr, out var cached))
|
||||
return cached;
|
||||
|
||||
var mem = _ctx.Memory;
|
||||
var strPtr = mem.ReadPointer(fieldAddr);
|
||||
string? result = null;
|
||||
|
||||
if (strPtr != 0 && ((ulong)strPtr >> 32) is > 0 and < 0x7FFF)
|
||||
result = _strings.ReadNullTermWString(strPtr);
|
||||
|
||||
_nameCache[fieldAddr] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy/POE1 mode: reads struct entries with dat row pointers and string fields.
|
||||
/// </summary>
|
||||
private List<QuestSnapshot>? ReadStructVectorQuests(nint questFlagsAddr, GameOffsets offsets)
|
||||
{
|
||||
var mem = _ctx.Memory;
|
||||
|
||||
// StdVector: begin, end, capacity (3 pointers)
|
||||
var vecBegin = mem.ReadPointer(questFlagsAddr);
|
||||
var vecEnd = mem.ReadPointer(questFlagsAddr + 8);
|
||||
if (vecBegin == 0 || vecEnd <= vecBegin) return null;
|
||||
|
|
@ -91,7 +239,6 @@ public sealed class QuestReader
|
|||
var entryCount = totalBytes / entrySize;
|
||||
if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null;
|
||||
|
||||
// Bulk read all entries
|
||||
var vecData = mem.ReadBytes(vecBegin, totalBytes);
|
||||
if (vecData is null) return null;
|
||||
|
||||
|
|
@ -101,20 +248,16 @@ public sealed class QuestReader
|
|||
{
|
||||
var entryOffset = i * entrySize;
|
||||
|
||||
// Read quest dat pointer
|
||||
nint questDatPtr = 0;
|
||||
if (entryOffset + offsets.QuestEntryQuestPtrOffset + 8 <= vecData.Length)
|
||||
questDatPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryQuestPtrOffset);
|
||||
|
||||
// Read state ID byte
|
||||
byte stateId = 0;
|
||||
if (entryOffset + offsets.QuestEntryStateIdOffset < vecData.Length)
|
||||
stateId = vecData[entryOffset + offsets.QuestEntryStateIdOffset];
|
||||
|
||||
// Resolve quest name from dat pointer (cached)
|
||||
var questName = ResolveQuestName(questDatPtr);
|
||||
|
||||
// Read state text pointer and resolve
|
||||
string? stateText = null;
|
||||
if (offsets.QuestEntryStateTextOffset > 0 &&
|
||||
entryOffset + offsets.QuestEntryStateTextOffset + 8 <= vecData.Length)
|
||||
|
|
@ -124,7 +267,6 @@ public sealed class QuestReader
|
|||
stateText = _strings.ReadNullTermWString(stateTextPtr);
|
||||
}
|
||||
|
||||
// Read progress text pointer and resolve
|
||||
string? progressText = null;
|
||||
if (offsets.QuestEntryProgressTextOffset > 0 &&
|
||||
entryOffset + offsets.QuestEntryProgressTextOffset + 8 <= vecData.Length)
|
||||
|
|
@ -147,10 +289,6 @@ public sealed class QuestReader
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves quest name by following QuestDatPtr → dat row → wchar* name.
|
||||
/// Results are cached since quest names don't change.
|
||||
/// </summary>
|
||||
private string? ResolveQuestName(nint questDatPtr)
|
||||
{
|
||||
if (questDatPtr == 0) return null;
|
||||
|
|
@ -161,7 +299,6 @@ public sealed class QuestReader
|
|||
var mem = _ctx.Memory;
|
||||
string? name = null;
|
||||
|
||||
// Follow the dat row pointer — first field is typically a wchar* name
|
||||
var high = (ulong)questDatPtr >> 32;
|
||||
if (high is > 0 and < 0x7FFF)
|
||||
{
|
||||
|
|
@ -178,6 +315,6 @@ public sealed class QuestReader
|
|||
public void InvalidateCache()
|
||||
{
|
||||
_nameCache.Clear();
|
||||
_lastServerData = 0;
|
||||
_lastPsd = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ namespace Roboto.Memory;
|
|||
public sealed class SkillSnapshot
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? InternalName { get; init; }
|
||||
/// <summary>Address of ActiveSkillPtr in game memory (for CE inspection).</summary>
|
||||
public nint Address { get; init; }
|
||||
/// <summary>Raw bytes at ActiveSkillPtr for offset discovery.</summary>
|
||||
public byte[]? RawBytes { get; init; }
|
||||
public bool CanBeUsed { get; init; }
|
||||
public int UseStage { get; init; }
|
||||
public int CastType { get; init; }
|
||||
|
|
@ -19,6 +24,13 @@ public sealed class SkillSnapshot
|
|||
public int ActiveCooldowns { get; init; }
|
||||
/// <summary>From Cooldowns vector — max uses (charges) for the skill.</summary>
|
||||
public int MaxUses { get; init; }
|
||||
|
||||
/// <summary>Low 16 bits of UnknownIdAndEquipmentInfo — skill ID used for SkillBarIds matching.</summary>
|
||||
public ushort Id { get; init; }
|
||||
/// <summary>High 16 bits of UnknownIdAndEquipmentInfo — equipment slot / secondary ID.</summary>
|
||||
public ushort Id2 { get; init; }
|
||||
/// <summary>Skill bar slot index (0-12) from SkillBarIds, or -1 if not on the skill bar.</summary>
|
||||
public int SkillBarSlot { get; init; } = -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -44,7 +56,7 @@ public sealed class SkillReader
|
|||
_strings = strings;
|
||||
}
|
||||
|
||||
public List<SkillSnapshot>? ReadPlayerSkills(nint localPlayerPtr)
|
||||
public List<SkillSnapshot>? ReadPlayerSkills(nint localPlayerPtr, nint psdPtr = 0)
|
||||
{
|
||||
if (localPlayerPtr == 0) return null;
|
||||
var mem = _ctx.Memory;
|
||||
|
|
@ -59,6 +71,9 @@ public sealed class SkillReader
|
|||
_lastActorComp = actorComp;
|
||||
}
|
||||
|
||||
// Read SkillBarIds from PSD if offset is configured
|
||||
var skillBarIds = ReadSkillBarIds(psdPtr);
|
||||
|
||||
// Read ActiveSkills vector at Actor+0xB00
|
||||
var vecFirst = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector);
|
||||
var vecLast = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector + 8);
|
||||
|
|
@ -87,8 +102,8 @@ public sealed class SkillReader
|
|||
var high = (ulong)activeSkillPtr >> 32;
|
||||
if (high == 0 || high >= 0x7FFF) continue;
|
||||
|
||||
// Read ActiveSkillDetails struct
|
||||
var details = mem.Read<ActiveSkillDetails>(activeSkillPtr);
|
||||
// Read ActiveSkillDetails struct — ptr points 0x10 into the object (past vtable+header)
|
||||
var details = mem.Read<ActiveSkillDetails>(activeSkillPtr - 0x10);
|
||||
|
||||
// Resolve skill name via GEPL FK chain (cached)
|
||||
var name = ResolveSkillName(activeSkillPtr, details);
|
||||
|
|
@ -99,6 +114,24 @@ public sealed class SkillReader
|
|||
// Deduplicate by UnknownIdAndEquipmentInfo
|
||||
if (!seen.Add(details.UnknownIdAndEquipmentInfo)) continue;
|
||||
|
||||
// Extract Id/Id2 from UnknownIdAndEquipmentInfo
|
||||
var id = (ushort)(details.UnknownIdAndEquipmentInfo & 0xFFFF);
|
||||
var id2 = (ushort)(details.UnknownIdAndEquipmentInfo >> 16);
|
||||
|
||||
// Match to skill bar slot
|
||||
var slot = -1;
|
||||
if (skillBarIds is not null)
|
||||
{
|
||||
for (var s = 0; s < skillBarIds.Length; s++)
|
||||
{
|
||||
if (skillBarIds[s].Id == id && skillBarIds[s].Id2 == id2)
|
||||
{
|
||||
slot = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match cooldown entry by UnknownIdAndEquipmentInfo
|
||||
var canBeUsed = true;
|
||||
var activeCooldowns = 0;
|
||||
|
|
@ -117,9 +150,15 @@ public sealed class SkillReader
|
|||
}
|
||||
}
|
||||
|
||||
// Read raw bytes for offset discovery (from true object base)
|
||||
var rawBytes = mem.ReadBytes(activeSkillPtr - 0x10, 0xC0);
|
||||
|
||||
result.Add(new SkillSnapshot
|
||||
{
|
||||
Name = name,
|
||||
InternalName = name,
|
||||
Address = activeSkillPtr - 0x10,
|
||||
RawBytes = rawBytes,
|
||||
CanBeUsed = canBeUsed,
|
||||
UseStage = details.UseStage,
|
||||
CastType = details.CastType,
|
||||
|
|
@ -127,12 +166,41 @@ public sealed class SkillReader
|
|||
CooldownTimeMs = details.TotalCooldownTimeInMs,
|
||||
ActiveCooldowns = activeCooldowns,
|
||||
MaxUses = cdMaxUses,
|
||||
Id = id,
|
||||
Id2 = id2,
|
||||
SkillBarSlot = slot,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads SkillBarIds from PlayerServerData: Buffer13 of (ushort Id, ushort Id2).
|
||||
/// 13 slots × 4 bytes = 52 bytes total.
|
||||
/// </summary>
|
||||
private (ushort Id, ushort Id2)[]? ReadSkillBarIds(nint psdPtr)
|
||||
{
|
||||
var offset = _ctx.Offsets.SkillBarIdsOffset;
|
||||
if (offset <= 0 || psdPtr == 0) return null;
|
||||
|
||||
const int slotCount = 13;
|
||||
const int bufferSize = slotCount * 4; // 52 bytes
|
||||
var data = _ctx.Memory.ReadBytes(psdPtr + offset, bufferSize);
|
||||
if (data is null) return null;
|
||||
|
||||
var slots = new (ushort Id, ushort Id2)[slotCount];
|
||||
for (var i = 0; i < slotCount; i++)
|
||||
{
|
||||
var off = i * 4;
|
||||
slots[i] = (
|
||||
BitConverter.ToUInt16(data, off),
|
||||
BitConverter.ToUInt16(data, off + 2)
|
||||
);
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Cooldowns vector at Actor+0xB18.
|
||||
/// Each entry is an ActiveSkillCooldown struct (0x48 bytes).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue