diff --git a/offsets.json b/offsets.json
index 4d6677b..2c1be35 100644
--- a/offsets.json
+++ b/offsets.json
@@ -2,62 +2,97 @@
"ProcessName": "PathOfExileSteam",
"GameStatePattern": "48 83 EC ?? 48 8B F1 33 ED 48 39 2D ^",
"GameStateGlobalOffset": 0,
- "PatternResultAdjust": 24,
- "StatesBeginOffset": 72,
- "StateStride": 16,
+ "PatternResultAdjust": "0x18",
+ "StatesBeginOffset": "0x48",
+ "StateStride": "0x10",
"StatePointerOffset": 0,
"StateCount": 12,
"InGameStateIndex": 4,
- "ActiveStatesOffset": 32,
+ "ActiveStatesOffset": "0x20",
"StatesInline": true,
- "InGameStateDirectOffset": 528,
- "IsLoadingOffset": 832,
- "EscapeStateOffset": 524,
- "IngameDataFromStateOffset": 656,
- "WorldDataFromStateOffset": 760,
- "AreaLevelOffset": 196,
+ "InGameStateDirectOffset": "0x210",
+ "IsLoadingOffset": "0x340",
+ "EscapeStateOffset": "0x20C",
+ "IngameDataFromStateOffset": "0x290",
+ "WorldDataFromStateOffset": "0x2F8",
+ "AreaLevelOffset": "0xC4",
"AreaLevelIsByte": true,
"AreaLevelStaticOffset": 0,
- "AreaHashOffset": 236,
- "ServerDataOffset": 2544,
- "LocalPlayerDirectOffset": 2576,
- "EntityListOffset": 2896,
+ "AreaHashOffset": "0xEC",
+ "ServerDataOffset": "0xA08",
+ "LocalPlayerDirectOffset": "0xA10",
+ "EntityListOffset": "0xB50",
"EntityCountInternalOffset": 8,
"EntityNodeLeftOffset": 0,
"EntityNodeParentOffset": 8,
- "EntityNodeRightOffset": 16,
- "EntityNodeValueOffset": 40,
- "EntityIdOffset": 128,
- "EntityFlagsOffset": 132,
+ "EntityNodeRightOffset": "0x10",
+ "EntityNodeValueOffset": "0x28",
+ "EntityIdOffset": "0x80",
+ "EntityFlagsOffset": "0x84",
"EntityDetailsOffset": 8,
"EntityPathStringOffset": 8,
- "LocalPlayerOffset": 32,
- "ComponentListOffset": 16,
+ "LocalPlayerOffset": "0x20",
+ "PlayerServerDataOffset": "0x48",
+ "QuestFlagsOffset": "0x308",
+ "QuestFlagEntrySize": 4,
+ "QuestEntryQuestPtrOffset": 0,
+ "QuestEntryStateIdOffset": 0,
+ "QuestEntryStateTextOffset": 0,
+ "QuestEntryProgressTextOffset": 0,
+ "QuestFlagsContainerType": "int_vector",
+ "QuestFlagsMaxEntries": "0x100",
+ "SkillBarIdsOffset": "0x71A8",
+ "QuestCountOffset": "0x250",
+ "QuestCompanionOffset": "0x18",
+ "QuestCompanionEntrySize": "0x18",
+ "QuestCompanionDatPtrOffset": "0x10",
+ "QuestCompanionTrackedOffset": 4,
+ "QuestCompanionObjPtrOffset": "0x10",
+ "QuestTrackedMarker": "0x43020000",
+ "QuestObjEncounterStateOffset": 8,
+ "QuestDatRowSize": "0x77",
+ "QuestDatNameOffset": 0,
+ "QuestDatInternalIdOffset": "0x6B",
+ "QuestDatActOffset": "0x73",
+ "ComponentListOffset": "0x10",
"EntityHeaderOffset": 8,
- "ComponentLookupOffset": 40,
- "ComponentLookupVec2Offset": 40,
- "ComponentLookupEntrySize": 16,
+ "ComponentLookupOffset": "0x28",
+ "ComponentLookupVec2Offset": "0x28",
+ "ComponentLookupEntrySize": "0x10",
"ComponentLookupNameOffset": 0,
"ComponentLookupIndexOffset": 8,
"LifeComponentIndex": -1,
"RenderComponentIndex": -1,
- "LifeComponentOffset1": 1056,
- "LifeComponentOffset2": 152,
- "LifeHealthOffset": 424,
- "LifeManaOffset": 504,
- "LifeEsOffset": 560,
- "VitalCurrentOffset": 48,
- "VitalTotalOffset": 44,
- "PositionXOffset": 312,
- "PositionYOffset": 316,
- "PositionZOffset": 320,
- "CameraOffset": 776,
- "CameraMatrixOffset": 416,
- "TerrainListOffset": 3264,
+ "LifeComponentOffset1": "0x420",
+ "LifeComponentOffset2": "0x98",
+ "LifeHealthOffset": "0x1A8",
+ "LifeManaOffset": "0x1F8",
+ "LifeEsOffset": "0x230",
+ "VitalCurrentOffset": "0x30",
+ "VitalTotalOffset": "0x2C",
+ "PositionXOffset": "0x138",
+ "PositionYOffset": "0x13C",
+ "PositionZOffset": "0x140",
+ "CameraOffset": "0x308",
+ "CameraMatrixOffset": "0x1A0",
+ "TerrainListOffset": "0xCC0",
"TerrainInline": true,
- "TerrainDimensionsOffset": 144,
- "TerrainWalkableGridOffset": 328,
- "TerrainBytesPerRowOffset": 424,
+ "TerrainDimensionsOffset": "0x90",
+ "TerrainWalkableGridOffset": "0x148",
+ "TerrainBytesPerRowOffset": "0x1A8",
"TerrainGridPtrOffset": 8,
- "SubTilesPerCell": 23
-}
\ No newline at end of file
+ "SubTilesPerCell": "0x17",
+ "UiRootStructOffset": "0x340",
+ "UiRootPtrOffset": "0x5B8",
+ "GameUiPtrOffset": "0xBE0",
+ "GameUiControllerPtrOffset": "0xBE8",
+ "UiElementSelfOffset": 8,
+ "UiElementChildrenOffset": "0x10",
+ "UiElementParentOffset": "0xB8",
+ "UiElementStringIdOffset": "0x98",
+ "UiElementFlagsOffset": "0x180",
+ "UiElementVisibleBit": 11,
+ "UiElementSizeOffset": "0x288",
+ "UiElementTextOffset": "0x448",
+ "UiElementScanRange": "0x1000"
+}
diff --git a/src/Automata.Ui/Automata.Ui.csproj b/src/Automata.Ui/Automata.Ui.csproj
index 3c9e2b8..ce866f4 100644
--- a/src/Automata.Ui/Automata.Ui.csproj
+++ b/src/Automata.Ui/Automata.Ui.csproj
@@ -27,6 +27,10 @@
+
+
+
+
diff --git a/src/Automata.Ui/ViewModels/MemoryViewModel.cs b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
index 9f21e9b..9345e10 100644
--- a/src/Automata.Ui/ViewModels/MemoryViewModel.cs
+++ b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
@@ -102,6 +102,7 @@ public partial class MemoryViewModel : ObservableObject
private MemoryNodeViewModel? _entityTypesNode;
private MemoryNodeViewModel? _entityListNode;
private MemoryNodeViewModel? _skillsNode;
+ private MemoryNodeViewModel? _questsNode;
partial void OnIsEnabledChanged(bool value)
{
@@ -208,11 +209,13 @@ public partial class MemoryViewModel : ObservableObject
_playerMana = new MemoryNodeViewModel("Mana:") { Value = "?", ValueColor = "#484f58" };
_playerEs = new MemoryNodeViewModel("ES:") { Value = "?", ValueColor = "#484f58" };
_skillsNode = new MemoryNodeViewModel("Skills") { IsExpanded = false };
+ _questsNode = new MemoryNodeViewModel("Quests") { IsExpanded = false };
player.Children.Add(_playerPos);
player.Children.Add(_playerLife);
player.Children.Add(_playerMana);
player.Children.Add(_playerEs);
player.Children.Add(_skillsNode);
+ player.Children.Add(_questsNode);
// Entities
var entitiesGroup = new MemoryNodeViewModel("Entities");
@@ -454,12 +457,13 @@ public partial class MemoryViewModel : ObservableObject
_playerEs!.Set("? (set LifeComponentIndex)", false);
}
- // Player skills
+ // Player skills — expandable nodes with full details
if (_skillsNode is not null)
{
if (snap.PlayerSkills is { Count: > 0 })
{
- _skillsNode.Value = $"{snap.PlayerSkills.Count} skills";
+ var onBar = snap.PlayerSkills.Count(s => s.SkillBarSlot >= 0);
+ _skillsNode.Value = $"{snap.PlayerSkills.Count} skills ({onBar} on bar)";
_skillsNode.ValueColor = "#3fb950";
while (_skillsNode.Children.Count > snap.PlayerSkills.Count)
@@ -469,31 +473,81 @@ public partial class MemoryViewModel : ObservableObject
{
var skill = snap.PlayerSkills[i];
var name = skill.Name ?? $"Skill#{i}";
- var label = $"[{i}] {name}:";
-
- var parts = new List();
- parts.Add(skill.CanBeUsed ? "Ready" : "Cooldown");
- if (skill.UseStage != 0)
- parts.Add($"stage:{skill.UseStage}");
- if (skill.CooldownTimeMs > 0)
- parts.Add($"cd:{skill.CooldownTimeMs}ms");
- if (skill.MaxUses > 1)
- parts.Add($"charges:{skill.MaxUses - skill.ActiveCooldowns}/{skill.MaxUses}");
- parts.Add($"cast:{skill.CastType}");
-
- var value = string.Join(" ", parts);
+ var slotTag = skill.SkillBarSlot >= 0 ? $"bar:{skill.SkillBarSlot}" : "off-bar";
+ var summary = skill.CanBeUsed ? "Ready" : "Cooldown";
var color = skill.CanBeUsed ? "#3fb950" : "#d29922";
+ // Build detail fields
+ var detailsList = new List<(string Label, string Value)>
+ {
+ ("Address", $"0x{skill.Address:X}"),
+ ("InternalName", skill.InternalName ?? "—"),
+ ("Id", $"0x{skill.Id:X4}"),
+ ("Id2", $"0x{skill.Id2:X4}"),
+ ("SkillBarSlot", skill.SkillBarSlot >= 0 ? skill.SkillBarSlot.ToString() : "-1"),
+ ("CanBeUsed", skill.CanBeUsed.ToString()),
+ ("UseStage", skill.UseStage.ToString()),
+ ("CastType", skill.CastType.ToString()),
+ ("CooldownTimeMs", skill.CooldownTimeMs > 0 ? $"{skill.CooldownTimeMs}ms" : "0"),
+ ("MaxUses", skill.MaxUses.ToString()),
+ ("ActiveCooldowns", skill.ActiveCooldowns.ToString()),
+ ("TotalUses", skill.TotalUses.ToString()),
+ };
+
+ // Add raw hex dump rows (16 bytes per line, from true object base)
+ if (skill.RawBytes is { Length: > 0 })
+ {
+ detailsList.Add(("———", $"Raw dump (base=0x{skill.Address:X})"));
+ for (var off = 0; off < skill.RawBytes.Length; off += 16)
+ {
+ var len = Math.Min(16, skill.RawBytes.Length - off);
+ var hex = BitConverter.ToString(skill.RawBytes, off, len).Replace('-', ' ');
+ var i32 = off + 4 <= skill.RawBytes.Length
+ ? BitConverter.ToInt32(skill.RawBytes, off) : 0;
+ var i64 = off + 8 <= skill.RawBytes.Length
+ ? BitConverter.ToInt64(skill.RawBytes, off) : 0L;
+ var interp = $"i32={i32} i64=0x{i64:X}";
+ detailsList.Add(($"+0x{off:X2}", $"{hex} ({interp})"));
+ }
+ }
+
+ var details = detailsList.ToArray();
+
+ MemoryNodeViewModel skillNode;
if (i < _skillsNode.Children.Count)
{
- _skillsNode.Children[i].Name = label;
- _skillsNode.Children[i].Value = value;
- _skillsNode.Children[i].ValueColor = color;
+ skillNode = _skillsNode.Children[i];
+ skillNode.Name = $"{name} ({slotTag})";
+ skillNode.Value = summary;
+ skillNode.ValueColor = color;
}
else
{
- var node = new MemoryNodeViewModel(label) { Value = value, ValueColor = color };
- _skillsNode.Children.Add(node);
+ skillNode = new MemoryNodeViewModel($"{name} ({slotTag})")
+ {
+ Value = summary, ValueColor = color, IsExpanded = false
+ };
+ _skillsNode.Children.Add(skillNode);
+ }
+
+ // Update detail children
+ while (skillNode.Children.Count > details.Length)
+ skillNode.Children.RemoveAt(skillNode.Children.Count - 1);
+
+ for (var d = 0; d < details.Length; d++)
+ {
+ var (label, val) = details[d];
+ if (d < skillNode.Children.Count)
+ {
+ skillNode.Children[d].Name = $"{label}:";
+ skillNode.Children[d].Value = val;
+ skillNode.Children[d].ValueColor = "#8b949e";
+ }
+ else
+ {
+ skillNode.Children.Add(new MemoryNodeViewModel($"{label}:")
+ { Value = val, ValueColor = "#8b949e" });
+ }
}
}
}
@@ -505,6 +559,51 @@ public partial class MemoryViewModel : ObservableObject
}
}
+ // Quest states with rich info from companion vector
+ if (_questsNode is not null)
+ {
+ if (snap.QuestFlags is { Count: > 0 })
+ {
+ var named = snap.QuestFlags.Count(q => q.QuestName is not null);
+ _questsNode.Value = $"{snap.QuestFlags.Count} quest states ({named} named)";
+ _questsNode.ValueColor = "#3fb950";
+
+ while (_questsNode.Children.Count > snap.QuestFlags.Count)
+ _questsNode.Children.RemoveAt(_questsNode.Children.Count - 1);
+
+ for (var i = 0; i < snap.QuestFlags.Count; i++)
+ {
+ var q = snap.QuestFlags[i];
+ var trackedPrefix = q.IsTracked ? "[T] " : "";
+ var stateLabel = q.StateId switch { 1 => "locked", 2 => "started", _ => $"s{q.StateId}" };
+ var label = $"{trackedPrefix}{q.QuestName ?? (q.QuestStateIndex > 0 ? $"#{q.QuestStateIndex}" : $"[{i}]")}";
+ var value = q.InternalId is not null
+ ? $"idx={q.QuestStateIndex} {stateLabel} id={q.InternalId}"
+ : $"idx={q.QuestStateIndex} {stateLabel}";
+
+ var color = q.IsTracked ? "#58a6ff" : q.StateId == 2 ? "#8b949e" : "#484f58";
+
+ if (i < _questsNode.Children.Count)
+ {
+ _questsNode.Children[i].Name = label;
+ _questsNode.Children[i].Value = value;
+ _questsNode.Children[i].ValueColor = color;
+ }
+ else
+ {
+ var node = new MemoryNodeViewModel(label) { Value = value, ValueColor = color };
+ _questsNode.Children.Add(node);
+ }
+ }
+ }
+ else
+ {
+ _questsNode.Value = "—";
+ _questsNode.ValueColor = "#484f58";
+ _questsNode.Children.Clear();
+ }
+ }
+
// Entities
if (snap.Entities is { Count: > 0 })
{
@@ -1220,4 +1319,40 @@ public partial class MemoryViewModel : ObservableObject
ScanResult = _reader.Diagnostics!.ScanQuestFlags();
}
+
+ [RelayCommand]
+ private void ScanUiElementsExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.ScanUiElements();
+ }
+
+ [RelayCommand]
+ private void ScanUiTextExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.ScanUiElementText();
+ }
+
+ [RelayCommand]
+ private void ScanQuestObjectsExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.ScanQuestStateObjects();
+ }
}
diff --git a/src/Automata.Ui/Views/MainWindow.axaml b/src/Automata.Ui/Views/MainWindow.axaml
index cba5cc5..5106860 100644
--- a/src/Automata.Ui/Views/MainWindow.axaml
+++ b/src/Automata.Ui/Views/MainWindow.axaml
@@ -778,6 +778,12 @@
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
+
+
+
QuestState.dat row index (POE2 int_vector mode). 0 if using legacy pointer mode.
+ public int QuestStateIndex { get; init; }
public string? QuestName { get; init; }
+ /// Internal quest ID from dat row (e.g. "TreeOfSouls2", "IncursionQuest1_Act1").
+ public string? InternalId { get; init; }
+ /// Encounter state: 1=locked/not encountered, 2=available/started.
public byte StateId { get; init; }
+ /// True if this quest is the currently tracked/active quest in the UI.
+ public bool IsTracked { get; init; }
public string? StateText { get; init; }
public string? ProgressText { get; init; }
}
diff --git a/src/Roboto.Core/SkillState.cs b/src/Roboto.Core/SkillState.cs
index 99c9af1..6168749 100644
--- a/src/Roboto.Core/SkillState.cs
+++ b/src/Roboto.Core/SkillState.cs
@@ -4,12 +4,26 @@ public record SkillState
{
public int SlotIndex { get; init; }
public ushort ScanCode { get; init; }
- public short SkillId { get; init; }
+ public ushort SkillId { get; init; }
+ public ushort Id2 { get; init; }
public string? Name { get; init; }
public string? InternalName { get; init; }
+ public int UseStage { get; init; }
+ public int CastType { get; init; }
+ public int CooldownTimeMs { get; init; }
+ public int SkillBarSlot { get; init; } = -1;
public int ChargesCurrent { get; init; }
public int ChargesMax { get; init; }
public float CooldownRemaining { get; init; }
public bool CanBeUsed { get; init; }
- public bool CanUse => CanBeUsed && CooldownRemaining <= 0;
+
+ // Derived properties
+ public bool IsUsing => UseStage >= 2;
+ public bool IsUsingOrCharging => UseStage >= 1;
+ public bool IsChanneling => CastType == 10;
+ public bool IsOnSkillBar => SkillBarSlot >= 0;
+ public bool IsOnCooldown => ChargesMax > 0 && ChargesCurrent <= 0;
+ public int RemainingUses => ChargesMax > 0 ? ChargesCurrent : 0;
+ public float CooldownSeconds => CooldownTimeMs / 1000f;
+ public bool CanUse => CanBeUsed && !IsOnCooldown;
}
diff --git a/src/Roboto.Data/MemoryPoller.cs b/src/Roboto.Data/MemoryPoller.cs
index d1aff1d..1c87b7b 100644
--- a/src/Roboto.Data/MemoryPoller.cs
+++ b/src/Roboto.Data/MemoryPoller.cs
@@ -20,6 +20,7 @@ public sealed class MemoryPoller : IDisposable
private Thread? _thread;
private volatile bool _running;
private bool _disposed;
+ private int _lastQuestCount;
// Cached resolved addresses (re-resolved on each cold tick)
private nint _cameraMatrixAddr;
@@ -276,12 +277,23 @@ public sealed class MemoryPoller : IDisposable
ManaTotal = snap.ManaTotal,
EsCurrent = snap.EsCurrent,
EsTotal = snap.EsTotal,
- Skills = snap.PlayerSkills?.Select((s, i) => new SkillState
+ Skills = snap.PlayerSkills?
+ .Where(s => s.SkillBarSlot >= 0)
+ .Select(s => new SkillState
{
- SlotIndex = i,
- Name = s.Name,
+ SlotIndex = s.SkillBarSlot,
+ SkillId = s.Id,
+ Id2 = s.Id2,
+ Name = StripPlayerSuffix(s.Name),
+ InternalName = s.InternalName,
+ UseStage = s.UseStage,
+ CastType = s.CastType,
+ CooldownTimeMs = s.CooldownTimeMs,
+ SkillBarSlot = s.SkillBarSlot,
+ ChargesCurrent = Math.Max(0, s.MaxUses - s.ActiveCooldowns),
+ ChargesMax = s.MaxUses,
+ CooldownRemaining = s.ActiveCooldowns > 0 ? s.CooldownTimeMs / 1000f : 0f,
CanBeUsed = s.CanBeUsed,
- CooldownRemaining = s.ActiveCooldowns > 0 ? s.CooldownTimeMs : 0,
}).ToList() ?? [],
};
@@ -321,11 +333,21 @@ public sealed class MemoryPoller : IDisposable
{
state.ActiveQuests = snap.QuestFlags.Select(q => new QuestProgress
{
+ QuestStateIndex = q.QuestStateIndex,
QuestName = q.QuestName,
+ InternalId = q.InternalId,
StateId = q.StateId,
+ IsTracked = q.IsTracked,
StateText = q.StateText,
ProgressText = q.ProgressText,
}).ToList();
+
+ if (_lastQuestCount != snap.QuestFlags.Count)
+ {
+ var indices = string.Join(", ", snap.QuestFlags.Select(q => q.QuestStateIndex));
+ Log.Debug("Quest state indices ({Count}): [{Indices}]", snap.QuestFlags.Count, indices);
+ _lastQuestCount = snap.QuestFlags.Count;
+ }
}
if (snap.Terrain is not null)
@@ -341,6 +363,14 @@ public sealed class MemoryPoller : IDisposable
return state;
}
+ private static string? StripPlayerSuffix(string? name)
+ {
+ if (name is null) return null;
+ if (name.EndsWith("Player", StringComparison.Ordinal))
+ return name[..^6];
+ return name;
+ }
+
public void Dispose()
{
if (_disposed) return;
diff --git a/src/Roboto.GameOffsets/Components/Actor.cs b/src/Roboto.GameOffsets/Components/Actor.cs
index 66de195..fd5971e 100644
--- a/src/Roboto.GameOffsets/Components/Actor.cs
+++ b/src/Roboto.GameOffsets/Components/Actor.cs
@@ -27,20 +27,22 @@ public struct ActiveSkillEntry
}
///
-/// Details of an active skill, reached by following ActiveSkillEntry.ActiveSkillPtr.
-/// From ExileCore2 GameOffsets.Objects.Components.ActiveSkillDetails.
+/// Details of an active skill. The shared_ptr in the ActiveSkills vector points
+/// 0x10 bytes into the object (past vtable + UseStage/CastType), so we read from
+/// ActiveSkillPtr - 0x10 and all offsets are relative to the true object base.
///
[StructLayout(LayoutKind.Explicit, Pack = 1)]
public struct ActiveSkillDetails
{
+ [FieldOffset(0x00)] public nint Vtable;
[FieldOffset(0x08)] public int UseStage;
[FieldOffset(0x0C)] public int CastType;
- [FieldOffset(0x10)] public uint UnknownIdAndEquipmentInfo;
- [FieldOffset(0x18)] public nint GrantedEffectsPerLevelDatRow;
- [FieldOffset(0x20)] public nint ActiveSkillsDatPtr;
- [FieldOffset(0x30)] public nint GrantedEffectStatSetsPerLevelDatRow;
- [FieldOffset(0x98)] public int TotalUses;
- [FieldOffset(0xA8)] public int TotalCooldownTimeInMs;
+ [FieldOffset(0x20)] public uint UnknownIdAndEquipmentInfo;
+ [FieldOffset(0x28)] public nint GrantedEffectsPerLevelDatRow;
+ [FieldOffset(0x30)] public nint ActiveSkillsDatPtr;
+ [FieldOffset(0x40)] public nint GrantedEffectStatSetsPerLevelDatRow;
+ [FieldOffset(0xA8)] public int TotalUses;
+ [FieldOffset(0xB8)] public int TotalCooldownTimeInMs;
}
///
diff --git a/src/Roboto.Memory/GameMemoryReader.cs b/src/Roboto.Memory/GameMemoryReader.cs
index ae7a845..4aa2cf3 100644
--- a/src/Roboto.Memory/GameMemoryReader.cs
+++ b/src/Roboto.Memory/GameMemoryReader.cs
@@ -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);
}
diff --git a/src/Roboto.Memory/GameOffsets.cs b/src/Roboto.Memory/GameOffsets.cs
index 4c54f16..0a3c5ea 100644
--- a/src/Roboto.Memory/GameOffsets.cs
+++ b/src/Roboto.Memory/GameOffsets.cs
@@ -1,15 +1,68 @@
+using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Serilog;
namespace Roboto.Memory;
+///
+/// Reads/writes int as hex strings ("0x1A8") or plain numbers (424).
+/// On write, values >= 16 are emitted as "0xHEX", smaller values as plain numbers.
+///
+internal sealed class HexIntConverter : JsonConverter
+{
+ 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);
+ }
+}
+
+/// Same as HexIntConverter but for uint (e.g. QuestTrackedMarker).
+internal sealed class HexUintConverter : JsonConverter
+{
+ 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;
/// AreaInstance → CurrentAreaHash uint (dump: 0xEC).
public int AreaHashOffset { get; set; } = 0xEC;
- /// AreaInstance → ServerData pointer (dump: 0x9F0 via LocalPlayerStruct.ServerDataPtr).
- public int ServerDataOffset { get; set; } = 0x9F0;
- /// AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr).
+ /// AreaInstance → ServerData pointer. Heap object with vtable, StdVector at +0x50 for PlayerServerData.
+ public int ServerDataOffset { get; set; } = 0xA08;
+ /// AreaInstance → LocalPlayer entity pointer.
public int LocalPlayerDirectOffset { get; set; } = 0xA10;
/// AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50).
public int EntityListOffset { get; set; } = 0xB50;
@@ -90,24 +143,56 @@ public sealed class GameOffsets
// ServerData → fields
/// ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).
public int LocalPlayerOffset { get; set; } = 0x20;
- /// ServerData → PlayerServerData pointer (PerPlayerServerData struct).
- public int PlayerServerDataOffset { get; set; } = 0x50;
- /// PlayerServerData → QuestFlags container offset (PerPlayerServerDataOffsets: 0x230 = 560).
- public int QuestFlagsOffset { get; set; } = 0x230;
- /// Size of each quest flag entry in bytes. 0 = disabled (offsets not yet discovered via CE).
- public int QuestFlagEntrySize { get; set; } = 0;
- /// Offset within each quest entry to the Quest.dat row pointer.
+ /// ServerData → PlayerServerData StdVector (begin/end/cap). ExileCore says 0x50 but current binary has it at 0x48.
+ public int PlayerServerDataOffset { get; set; } = 0x48;
+ /// PSD → SkillBarIds: Buffer13 of (ushort Id, ushort Id2). 13 slots × 4 bytes = 52 bytes. Discovered via skillbar_scanner.py.
+ public int SkillBarIdsOffset { get; set; } = 0x71A8;
+
+ /// PSD → QuestState int32 index vector (StdVector of int32). CE confirmed: 0x308.
+ public int QuestFlagsOffset { get; set; } = 0x308;
+ /// Size of each quest flag entry in bytes. 4 = int32 QuestState.dat row index.
+ public int QuestFlagEntrySize { get; set; } = 4;
+ /// Offset within each quest entry to the Quest.dat row pointer (legacy pointer mode, 0 = N/A).
public int QuestEntryQuestPtrOffset { get; set; } = 0;
- /// Offset within each quest entry to the byte state ID.
+ /// Offset within each quest entry to the byte state ID (legacy pointer mode, 0 = N/A).
public int QuestEntryStateIdOffset { get; set; } = 0;
- /// Offset within each quest entry to the wchar* state text pointer.
+ /// Offset within each quest entry to the wchar* state text pointer (legacy pointer mode, 0 = N/A).
public int QuestEntryStateTextOffset { get; set; } = 0;
- /// Offset within each quest entry to the wchar* progress text pointer.
+ /// Offset within each quest entry to the wchar* progress text pointer (legacy pointer mode, 0 = N/A).
public int QuestEntryProgressTextOffset { get; set; } = 0;
- /// Container type for quest flags: "vector" or "map".
- public string QuestFlagsContainerType { get; set; } = "vector";
+ /// Container type: "int_vector" = flat int32 array of QuestState.dat indices, "vector" = struct entries with pointers.
+ public string QuestFlagsContainerType { get; set; } = "int_vector";
/// Maximum number of quest entries to read (sanity limit).
- public int QuestFlagsMaxEntries { get; set; } = 128;
+ public int QuestFlagsMaxEntries { get; set; } = 256;
+ /// PSD offset to quest struct count field (QF+0x020 = PSD+0x250). 0 = disabled.
+ public int QuestCountOffset { get; set; } = 0x250;
+
+ // ── QuestFlags companion vector (QF+0x018): 24-byte structs ──
+ // Layout: +0x00 int32 QuestStateId, +0x04 uint32 TrackedFlag, +0x10 ptr QuestStateObj
+ /// Offset from QuestFlags to companion StdVector (begin/end/cap). 0x18 = QF+0x018.
+ public int QuestCompanionOffset { get; set; } = 0x18;
+ /// Size of each companion entry in bytes.
+ public int QuestCompanionEntrySize { get; set; } = 24;
+ /// Offset within companion entry to the QuestStates.dat row pointer (legacy, broken for POE2).
+ public int QuestCompanionDatPtrOffset { get; set; } = 0x10;
+ /// Offset within companion entry to the tracked flag uint32. Value == QuestTrackedMarker means tracked. 0x04.
+ public int QuestCompanionTrackedOffset { get; set; } = 0x04;
+ /// Offset within companion entry to the quest state object pointer. 0x10.
+ public int QuestCompanionObjPtrOffset { get; set; } = 0x10;
+ /// uint32 marker value indicating a quest is currently tracked in the UI. 0x43020000 = float 130.0.
+ public uint QuestTrackedMarker { get; set; } = 0x43020000;
+ /// Offset within the quest state object to the encounter state byte (1=locked, 2=started). 0x08.
+ public int QuestObjEncounterStateOffset { get; set; } = 0x08;
+
+ // ── QuestStates.dat row layout (119 bytes, non-aligned fields) ──
+ /// Size of each .dat row in bytes. 0x77 = 119. 0 = name resolution disabled.
+ public int QuestDatRowSize { get; set; } = 0x77;
+ /// Dat row → Quest display name wchar* pointer.
+ public int QuestDatNameOffset { get; set; } = 0x00;
+ /// Dat row → Internal quest ID wchar* pointer (e.g. "TreeOfSouls2").
+ public int QuestDatInternalIdOffset { get; set; } = 0x6B;
+ /// Dat row → Act/phase number int32.
+ public int QuestDatActOffset { get; set; } = 0x73;
// ── Entity / Component ──
public int ComponentListOffset { get; set; } = 0x10;
@@ -150,6 +235,36 @@ public sealed class GameOffsets
/// Offset within Camera struct to the Matrix4x4 (64 bytes). 0 = disabled.
public int CameraMatrixOffset { get; set; } = 0x1A0;
+ // ── UiRootStruct (InGameState → UI tree roots) ──
+ /// Offset from InGameState to UiRootStruct pointer. GameOverlay2: 0x340.
+ public int UiRootStructOffset { get; set; } = 0x340;
+ /// Offset within UiRootStruct to UiRoot UIElement pointer. GameOffsetsNew: 0x5B8.
+ public int UiRootPtrOffset { get; set; } = 0x5B8;
+ /// Offset within UiRootStruct to GameUi UIElement pointer. GameOffsetsNew: 0xBE0.
+ public int GameUiPtrOffset { get; set; } = 0xBE0;
+ /// Offset within UiRootStruct to GameUiController pointer. GameOffsetsNew: 0xBE8.
+ public int GameUiControllerPtrOffset { get; set; } = 0xBE8;
+
+ // ── UIElement offsets (GameOffsetsNew UiElementBaseOffset) ──
+ /// UIElement → Self pointer (validation: should equal element address). 0x08.
+ public int UiElementSelfOffset { get; set; } = 0x08;
+ /// UIElement → StdVector of child UIElement pointers (begin/end/cap). 0x10.
+ public int UiElementChildrenOffset { get; set; } = 0x10;
+ /// UIElement → Parent UIElement pointer. 0xB8.
+ public int UiElementParentOffset { get; set; } = 0xB8;
+ /// UIElement → StdWString StringId (inline MSVC std::wstring). 0x98.
+ public int UiElementStringIdOffset { get; set; } = 0x98;
+ /// UIElement → Flags uint32 (visibility at bit 0x0B). 0x180.
+ public int UiElementFlagsOffset { get; set; } = 0x180;
+ /// Bit position for IsVisible in UIElement Flags. 0x0B = bit 11.
+ public int UiElementVisibleBit { get; set; } = 0x0B;
+ /// UIElement → UnscaledSize (float, float). 0x288.
+ public int UiElementSizeOffset { get; set; } = 0x288;
+ /// UIElement → Display text StdWString. 0x448. Not all elements have text.
+ public int UiElementTextOffset { get; set; } = 0x448;
+ /// How many bytes to scan from InGameState for UIElement pointers (0x1000 = 4KB).
+ public int UiElementScanRange { get; set; } = 0x1000;
+
// ── Terrain (inline in AreaInstance) ──
/// Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).
public int TerrainListOffset { get; set; } = 0xCC0;
diff --git a/src/Roboto.Memory/MemoryDiagnostics.cs b/src/Roboto.Memory/MemoryDiagnostics.cs
index 673de40..0004d1d 100644
--- a/src/Roboto.Memory/MemoryDiagnostics.cs
+++ b/src/Roboto.Memory/MemoryDiagnostics.cs
@@ -3877,11 +3877,11 @@ public sealed class MemoryDiagnostics
// Scan AreaInstance around the expected region for heap pointers
sb.AppendLine();
- sb.AppendLine("Scanning AreaInstance 0x980..0xA30 for heap pointers:");
+ sb.AppendLine("Scanning AreaInstance 0x980..0xA60 for heap pointers:");
sb.AppendLine(new string('─', 80));
const int scanStart = 0x980;
- const int scanLen = 0xB0;
+ const int scanLen = 0xE0;
var scanData = mem.ReadBytes(ingameData + scanStart, scanLen);
if (scanData is not null)
{
@@ -3905,13 +3905,18 @@ public sealed class MemoryDiagnostics
var rtti = _rtti.ResolveRttiName(targetVtable);
annotation = rtti is not null ? $" → vtable: {rtti}" : " → vtable (no RTTI)";
}
- else if (targetVtable != 0)
+
+ // Always check +0x50 for StdVector pattern (ServerData signature)
+ var sv50Begin = mem.ReadPointer(val + 0x50);
+ var sv50End = mem.ReadPointer(val + 0x58);
+ if (sv50Begin != 0 && sv50End > sv50Begin &&
+ ((ulong)sv50Begin >> 32) is > 0 and < 0x7FFF)
{
- // Check if target+0x50 looks like a StdVector (ServerData candidate)
- var t50_0 = mem.ReadPointer(val + 0x50);
- var t50_1 = mem.ReadPointer(val + 0x58);
- if (t50_0 != 0 && t50_1 > t50_0)
- annotation += $" → has StdVector at +0x50 (begin=0x{t50_0:X}, size={(int)(t50_1-t50_0)})";
+ annotation += $" → +0x50 StdVec(begin=0x{sv50Begin:X}, size={(int)(sv50End-sv50Begin)})";
+ // Check if vector[0] dereferences to a large struct (PerPlayerServerData)
+ var psd0 = mem.ReadPointer(sv50Begin);
+ if (psd0 != 0 && ((ulong)psd0 >> 32) is > 0 and < 0x7FFF)
+ annotation += $" → [0]=0x{psd0:X}";
}
}
else if (val == localPlayer)
@@ -3933,8 +3938,64 @@ public sealed class MemoryDiagnostics
sb.AppendLine(new string('═', 80));
+ // Scan ServerData for StdVectors with actual content (non-null, non-trivial)
+ // ServerData is large (~0x400+ bytes), scan 0x400 to cover PlayerInventories at +0x320
+ sb.AppendLine($"\nServerData StdVector scan (0x{serverData:X}, first 0x400 bytes):");
+ sb.AppendLine("Showing only StdVectors with size > 0 and non-null begin:");
+ sb.AppendLine(new string('─', 80));
+ const int sdScanSize = 0x400;
+ var sdData = mem.ReadBytes(serverData, sdScanSize);
+ if (sdData is not null)
+ {
+ for (var off = 0; off + 24 <= sdData.Length; off += 8)
+ {
+ var v0 = (nint)BitConverter.ToInt64(sdData, off);
+ var v1 = (nint)BitConverter.ToInt64(sdData, off + 8);
+ var v2 = (nint)BitConverter.ToInt64(sdData, off + 16);
+
+ if (v0 == 0) continue;
+ var h0 = (ulong)v0 >> 32;
+ if (h0 is 0 or >= 0x7FFF) continue;
+ if (v1 <= v0) continue;
+ var h1 = (ulong)v1 >> 32;
+ if (h1 is 0 or >= 0x7FFF) continue;
+ // v2 >= v1 for capacity
+ if (v2 < v1) continue;
+
+ var vecSize = (int)(v1 - v0);
+ if (vecSize <= 0 || vecSize > 0x100000) continue;
+
+ // Read first entry to check content
+ var entry0 = mem.ReadPointer(v0);
+ var entry0Desc = "";
+ if (entry0 != 0)
+ {
+ var eh = (ulong)entry0 >> 32;
+ if (eh is > 0 and < 0x7FFF)
+ {
+ entry0Desc = $"[0]=0x{entry0:X}";
+ // Try reading as dat row (first field = wchar* name)
+ var namePtr = mem.ReadPointer(entry0);
+ if (namePtr != 0)
+ {
+ var name = _strings.ReadNullTermWString(namePtr);
+ if (name is not null)
+ entry0Desc += $" → dat → \"{name}\"";
+ }
+ }
+ }
+ else
+ {
+ entry0Desc = "[0]=NULL";
+ }
+
+ sb.AppendLine($" SD+0x{off:X3}: StdVec size={vecSize,6} ({vecSize / 8,4} ptrs) {entry0Desc}");
+ }
+ }
+
+ sb.AppendLine(new string('═', 80));
+
// PlayerServerData — ExileCore: ServerData+0x50 is StdVector (begin/end/cap)
- // The vector contains pointers to PerPlayerServerData structs
var psdVecBegin = mem.ReadPointer(serverData + offsets.PlayerServerDataOffset);
var psdVecEnd = mem.ReadPointer(serverData + offsets.PlayerServerDataOffset + 8);
sb.AppendLine($"PlayerServerData StdVector (+0x{offsets.PlayerServerDataOffset:X}):");
@@ -3944,25 +4005,296 @@ public sealed class MemoryDiagnostics
if (psdVecBegin == 0)
{
sb.AppendLine("Error: PlayerServerData vector begin is null");
- // Dump hex around ServerData+0x50 for discovery
- sb.AppendLine();
- sb.AppendLine("Hex dump at ServerData+0x40..0x70:");
- DumpHexRegion(sb, mem, serverData + 0x40, 0x30, 0x40);
return sb.ToString();
}
- // Dereference: vector entry is a pointer to PerPlayerServerData
- var playerServerData = mem.ReadPointer(psdVecBegin);
- var vecEntries = psdVecEnd > psdVecBegin ? (int)(psdVecEnd - psdVecBegin) / 8 : 0;
- sb.AppendLine($" entries: {vecEntries} (vector of pointers, 8 bytes each)");
- sb.AppendLine($" [0] → PlayerServerData: 0x{playerServerData:X}");
+ var psdVecSize = (int)(psdVecEnd - psdVecBegin);
+ sb.AppendLine($" vector data size: {psdVecSize} bytes");
+ sb.AppendLine($" begin is ServerData+0x{psdVecBegin - serverData:X} (inside={psdVecBegin >= serverData && psdVecBegin < serverData + 0x1000})");
- if (playerServerData == 0)
+ // Try approach A: dereference (vector of pointers)
+ var derefVal = mem.ReadPointer(psdVecBegin);
+ sb.AppendLine();
+ sb.AppendLine($" Approach A (vector of ptrs): [0] = 0x{derefVal:X} {(derefVal != 0 ? "OK" : "NULL")}");
+
+ // Try approach B: use begin directly (vector is inline data buffer / ExileCore M[] accessor)
+ sb.AppendLine($" Approach B (begin = PSD addr): 0x{psdVecBegin:X}");
+
+ // For approach B, check if begin+0x230 (QuestFlags) has valid data
+ var questAtBegin = psdVecBegin + offsets.QuestFlagsOffset;
+ var qfProbe = mem.ReadBytes(questAtBegin, 24);
+ if (qfProbe is not null)
{
- sb.AppendLine("Error: PlayerServerData[0] pointer is null");
- return sb.ToString();
+ var qf0 = (nint)BitConverter.ToInt64(qfProbe, 0);
+ var qf1 = (nint)BitConverter.ToInt64(qfProbe, 8);
+ var looksLikeVec = qf0 != 0 && qf1 > qf0 && ((ulong)qf0 >> 32) is > 0 and < 0x7FFF;
+ sb.AppendLine($" begin+0x{offsets.QuestFlagsOffset:X} probe: {(looksLikeVec ? "StdVector pattern!" : "no vector pattern")}");
+ sb.AppendLine($" [0]=0x{qf0:X16} [1]=0x{qf1:X16}");
}
+ // For approach A (if deref worked), also probe QuestFlags
+ if (derefVal != 0 && ((ulong)derefVal >> 32) is > 0 and < 0x7FFF)
+ {
+ var questAtDeref = derefVal + offsets.QuestFlagsOffset;
+ var qdProbe = mem.ReadBytes(questAtDeref, 24);
+ if (qdProbe is not null)
+ {
+ var qd0 = (nint)BitConverter.ToInt64(qdProbe, 0);
+ var qd1 = (nint)BitConverter.ToInt64(qdProbe, 8);
+ var looksLikeVec = qd0 != 0 && qd1 > qd0 && ((ulong)qd0 >> 32) is > 0 and < 0x7FFF;
+ sb.AppendLine($" deref+0x{offsets.QuestFlagsOffset:X} probe: {(looksLikeVec ? "StdVector pattern!" : "no vector pattern")}");
+ sb.AppendLine($" [0]=0x{qd0:X16} [1]=0x{qd1:X16}");
+ }
+ }
+
+ // Pick best: prefer whichever has a StdVector at QuestFlagsOffset
+ nint playerServerData;
+ if (derefVal != 0 && ((ulong)derefVal >> 32) is > 0 and < 0x7FFF)
+ playerServerData = derefVal; // approach A
+ else
+ playerServerData = psdVecBegin; // approach B: use begin directly
+
+ sb.AppendLine();
+ sb.AppendLine($"Using PlayerServerData: 0x{playerServerData:X} (approach {(playerServerData == derefVal ? "A" : "B")})");
+
+ // Scan PerPlayerServerData for all StdVectors (0xA00 bytes to cover quest region + beyond)
+ sb.AppendLine();
+ sb.AppendLine($"PerPlayerServerData StdVector scan (first 0xA00 bytes):");
+ sb.AppendLine(new string('─', 80));
+ const int psdScanSize = 0xA00;
+ var psdData = mem.ReadBytes(playerServerData, psdScanSize);
+ var psdNonEmptyVectors = new List<(int offset, nint begin, int size)>();
+ if (psdData is not null)
+ {
+ for (var off = 0; off + 24 <= psdData.Length; off += 8)
+ {
+ var v0 = (nint)BitConverter.ToInt64(psdData, off);
+ var v1 = (nint)BitConverter.ToInt64(psdData, off + 8);
+ var v2 = (nint)BitConverter.ToInt64(psdData, off + 16);
+
+ if (v0 == 0) continue;
+ var h0 = (ulong)v0 >> 32;
+ if (h0 is 0 or >= 0x7FFF) continue;
+ if (v1 < v0) continue;
+ var h1 = (ulong)v1 >> 32;
+ if (h1 is 0 or >= 0x7FFF) continue;
+ if (v2 < v1) continue;
+
+ var vecSize = (int)(v1 - v0);
+ var psdVecCap = (int)(v2 - v0);
+ var isEmpty = v0 == v1;
+
+ if (!isEmpty && vecSize > 0 && vecSize < 0x100000)
+ psdNonEmptyVectors.Add((off, v0, vecSize));
+
+ // For non-empty vectors, try to resolve content
+ var desc = "";
+ if (!isEmpty && vecSize > 0)
+ {
+ var entry0 = mem.ReadPointer(v0);
+ if (entry0 != 0 && ((ulong)entry0 >> 32) is > 0 and < 0x7FFF)
+ {
+ desc = $"[0]=0x{entry0:X}";
+ var namePtr = mem.ReadPointer(entry0);
+ if (namePtr != 0)
+ {
+ var name = _strings.ReadNullTermWString(namePtr);
+ if (name is not null)
+ desc += $" → \"{name}\"";
+ }
+ }
+ else if (entry0 != 0)
+ {
+ var lo = (int)(entry0 & 0xFFFFFFFF);
+ var hi = (int)((long)entry0 >> 32);
+ desc = $"[0]={{lo32={lo}, hi32={hi}}}";
+ }
+ }
+
+ var marker = off >= 0x220 && off <= 0x260 ? " ◄ near +0x230" : "";
+ sb.AppendLine($" PSD+0x{off:X3}: size={vecSize,6} cap={psdVecCap,6} {(isEmpty ? "EMPTY" : $"entries={vecSize / 8}")}{(desc != "" ? $" {desc}" : "")}{marker}");
+ }
+ }
+
+ // Brute-force: scan PSD for ptr→wchar* (direct strings) AND ptr→ptr→wchar* (dat rows)
+ sb.AppendLine();
+ sb.AppendLine($"Brute-force string scan in PSD (first 0x{psdScanSize:X} bytes):");
+ sb.AppendLine(new string('─', 80));
+ var datHits = 0;
+ if (psdData is not null)
+ {
+ for (var off = 0; off + 8 <= psdData.Length; off += 8)
+ {
+ var val = (nint)BitConverter.ToInt64(psdData, off);
+ if (val == 0) continue;
+ var h = (ulong)val >> 32;
+ if (h is 0 or >= 0x7FFF) continue;
+ if ((val & 0x1) != 0) continue; // wchar* can be 2-aligned
+
+ // Level 1: try val as direct wchar* string
+ var directStr = _strings.ReadNullTermWString(val);
+ if (directStr is not null && directStr.Length >= 3)
+ {
+ sb.AppendLine($" PSD+0x{off:X3}: 0x{val:X} → wchar \"{directStr}\"");
+ datHits++;
+ if (datHits >= 40) { sb.AppendLine(" ... (truncated)"); break; }
+ continue;
+ }
+
+ // Level 2: try val → ptr → wchar* (dat row pattern)
+ if ((val & 0x3) != 0) continue;
+ var namePtr = mem.ReadPointer(val);
+ if (namePtr == 0) continue;
+ var nh = (ulong)namePtr >> 32;
+ if (nh is 0 or >= 0x7FFF) continue;
+
+ var name = _strings.ReadNullTermWString(namePtr);
+ if (name is not null && name.Length >= 2)
+ {
+ sb.AppendLine($" PSD+0x{off:X3}: 0x{val:X} → dat → \"{name}\"");
+ datHits++;
+ if (datHits >= 40) { sb.AppendLine(" ... (truncated)"); break; }
+ }
+ }
+ }
+ if (datHits == 0) sb.AppendLine($" (none found in first 0x{psdScanSize:X} bytes)");
+
+ // Scan content of ALL non-empty PSD vectors for strings
+ foreach (var (vecOff, vBegin, vSize) in psdNonEmptyVectors)
+ {
+ var sampleSize = Math.Min(vSize, 512); // sample first 512 bytes
+ var vData = mem.ReadBytes(vBegin, sampleSize);
+ if (vData is null) continue;
+
+ var hits = 0;
+ var vecSb = new StringBuilder();
+ for (var i = 0; i + 8 <= vData.Length; i += 8)
+ {
+ var val = (nint)BitConverter.ToInt64(vData, i);
+ if (val == 0) continue;
+ var vh = (ulong)val >> 32;
+ if (vh is 0 or >= 0x7FFF || (val & 0x1) != 0) continue;
+
+ // Direct wchar*
+ var directStr = _strings.ReadNullTermWString(val);
+ if (directStr is not null && directStr.Length >= 3)
+ {
+ vecSb.AppendLine($" [{i / 8}] → wchar \"{directStr}\"");
+ hits++;
+ if (hits >= 10) { vecSb.AppendLine(" ... (truncated)"); break; }
+ continue;
+ }
+
+ // Dat row: ptr → ptr → wchar*
+ if ((val & 0x3) != 0) continue;
+ var namePtr = mem.ReadPointer(val);
+ if (namePtr == 0) continue;
+ var name = _strings.ReadNullTermWString(namePtr);
+ if (name is not null && name.Length >= 2)
+ {
+ vecSb.AppendLine($" [{i / 8}] → dat → \"{name}\"");
+ hits++;
+ if (hits >= 10) { vecSb.AppendLine(" ... (truncated)"); break; }
+ }
+ }
+ if (hits > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine($" PSD+0x{vecOff:X3} vector ({vSize} bytes, {sampleSize / 8} qwords sampled):");
+ sb.Append(vecSb);
+ }
+ }
+
+ // Dump key PSD vectors as raw data (quest states may be integers, not strings)
+ sb.AppendLine();
+ sb.AppendLine("Key PSD vector data dumps:");
+ sb.AppendLine(new string('─', 80));
+ foreach (var (vecOff, vBegin, vSize) in psdNonEmptyVectors)
+ {
+ // Only dump vectors that are small enough to be quest-related (< 1024 bytes)
+ // and skip the massive 55K+ vector
+ if (vSize > 1024 || vSize < 8) continue;
+
+ var vData = mem.ReadBytes(vBegin, vSize);
+ if (vData is null) continue;
+
+ sb.AppendLine($"\n PSD+0x{vecOff:X3} vector ({vSize} bytes):");
+
+ // Check if entries are pointers vs small integers
+ var ptrCount = 0;
+ var intCount = 0;
+ for (var i = 0; i + 8 <= vData.Length; i += 8)
+ {
+ var qw = (nint)BitConverter.ToInt64(vData, i);
+ if (qw == 0) continue;
+ var qh = (ulong)qw >> 32;
+ if (qh is > 0 and < 0x7FFF && (qw & 0x3) == 0) ptrCount++;
+ else if (qh == 0 && (qw & 0xFFFFFFFF) < 0x10000) intCount++;
+ }
+
+ if (ptrCount > intCount)
+ {
+ // Pointer vector — follow each entry
+ sb.AppendLine($" (looks like pointers: {ptrCount} ptrs, {intCount} small ints)");
+ for (var i = 0; i + 8 <= vData.Length && i < 256; i += 8)
+ {
+ var ptr = (nint)BitConverter.ToInt64(vData, i);
+ if (ptr == 0) { sb.AppendLine($" [{i / 8}] NULL"); continue; }
+ var ph = (ulong)ptr >> 32;
+ var ann = "";
+ if (ph is > 0 and < 0x7FFF && (ptr & 0x3) == 0)
+ {
+ var vtbl = mem.ReadPointer(ptr);
+ if (vtbl != 0 && _ctx.IsModuleAddress(vtbl))
+ {
+ var rn = _rtti.ResolveRttiName(vtbl);
+ ann = rn is not null ? $" → {rn}" : " → vtable (no RTTI)";
+ }
+ // Dump first 64 bytes of pointed-to object
+ var objBytes = mem.ReadBytes(ptr, 64);
+ if (objBytes is not null)
+ {
+ // Look for interesting fields
+ for (var oi = 8; oi + 8 <= objBytes.Length; oi += 8)
+ {
+ var ov = (nint)BitConverter.ToInt64(objBytes, oi);
+ if (ov == 0) continue;
+ var oh = (ulong)ov >> 32;
+ if (oh is > 0 and < 0x7FFF && (ov & 0x1) == 0)
+ {
+ var os = _strings.ReadNullTermWString(ov);
+ if (os is not null) { ann += $" obj+0x{oi:X2}=\"{os}\""; break; }
+ if ((ov & 0x3) == 0)
+ {
+ var op = mem.ReadPointer(ov);
+ if (op != 0)
+ {
+ var os2 = _strings.ReadNullTermWString(op);
+ if (os2 is not null) { ann += $" obj+0x{oi:X2}→\"{os2}\""; break; }
+ }
+ }
+ }
+ }
+ }
+ }
+ sb.AppendLine($" [{i / 8}] 0x{ptr:X}{ann}");
+ }
+ }
+ else
+ {
+ // Integer/struct vector — dump as int32
+ sb.AppendLine($" (looks like integers: {intCount} small ints, {ptrCount} ptrs)");
+ for (var i = 0; i + 4 <= vData.Length && i < 256; i += 4)
+ {
+ var i32 = BitConverter.ToInt32(vData, i);
+ sb.AppendLine($" +0x{i:X3}: {i32}");
+ }
+ if (vData.Length > 256) sb.AppendLine($" ... ({vData.Length - 256} more bytes)");
+ }
+ }
+
+ sb.AppendLine();
+
// QuestFlags region at PlayerServerData + 0x230
var questFlagsAddr = playerServerData + offsets.QuestFlagsOffset;
sb.AppendLine($"QuestFlags addr (+0x{offsets.QuestFlagsOffset:X}): 0x{questFlagsAddr:X}");
@@ -4017,7 +4349,10 @@ public sealed class MemoryDiagnostics
annotation = " [heap ptr]";
var targetVal = mem.ReadPointer(val);
if (targetVal != 0 && _ctx.IsModuleAddress(targetVal))
- annotation += " → vtable";
+ {
+ var rttiName = _rtti.ResolveRttiName(targetVal);
+ annotation += rttiName is not null ? $" → vtable: {rttiName}" : " → vtable (no RTTI)";
+ }
else
{
var str = _strings.ReadNullTermWString(val);
@@ -4093,16 +4428,395 @@ public sealed class MemoryDiagnostics
else
{
sb.AppendLine();
- sb.AppendLine("No StdVector pattern detected at +0x00. Container may be a map or different layout.");
- sb.AppendLine("Try adjusting QuestFlagsOffset in offsets.json and re-running.");
+ sb.AppendLine("No StdVector pattern at +0x00. Analyzing QF sub-structures:");
+
+ // Find all heap pointers and vectors within the QF region dump
+ for (var off = 0; off + 24 <= regionData.Length; off += 8)
+ {
+ var pv0 = (nint)BitConverter.ToInt64(regionData, off);
+ var pv1 = (nint)BitConverter.ToInt64(regionData, off + 8);
+ var pv2 = (nint)BitConverter.ToInt64(regionData, off + 16);
+
+ if (pv0 == 0) continue;
+ var ph0 = (ulong)pv0 >> 32;
+ if (ph0 is 0 or >= 0x7FFF) continue;
+ if (pv1 <= pv0) continue;
+ var ph1 = (ulong)pv1 >> 32;
+ if (ph1 is 0 or >= 0x7FFF) continue;
+ if (pv2 < pv1) continue;
+
+ var subVecSize = (int)(pv1 - pv0);
+ if (subVecSize <= 0 || subVecSize > 0x100000) continue;
+ var subVecCap = (int)(pv2 - pv0);
+ var subEmpty = pv0 == pv1;
+
+ sb.AppendLine($" QF+0x{off:X3}: StdVec size={subVecSize} cap={subVecCap} {(subEmpty ? "EMPTY" : $"({subVecSize} bytes)")}");
+
+ if (!subEmpty && subVecSize > 0 && subVecSize <= 4096)
+ {
+ // Dump vector content with int32 + pointer annotations
+ var subContent = mem.ReadBytes(pv0, subVecSize);
+ if (subContent is not null)
+ {
+ // Show entry size analysis
+ foreach (var es in new[] { 4, 8, 12, 16, 20, 24, 28, 32 })
+ {
+ if (subVecSize % es == 0)
+ sb.AppendLine($" Divides by {es}: {subVecSize / es} entries");
+ }
+
+ sb.AppendLine($" Content dump ({subVecSize} bytes):");
+ // Show as int32 pairs for compact view
+ for (var ci = 0; ci + 4 <= subContent.Length && ci < 512; ci += 4)
+ {
+ var i32 = BitConverter.ToInt32(subContent, ci);
+ var u32 = BitConverter.ToUInt32(subContent, ci);
+ var hexB = BitConverter.ToString(subContent, ci, 4).Replace("-", " ");
+
+ var ann = "";
+ // Check if this qword (at 8-byte boundary) is a pointer
+ if (ci % 8 == 0 && ci + 8 <= subContent.Length)
+ {
+ var qw = (nint)BitConverter.ToInt64(subContent, ci);
+ var qh = (ulong)qw >> 32;
+ if (qw != 0 && qh is > 0 and < 0x7FFF && (qw & 0x3) == 0)
+ {
+ ann = " [ptr→";
+ // Try resolving through pointer chain
+ var t1 = mem.ReadPointer(qw);
+ if (t1 != 0 && _ctx.IsModuleAddress(t1))
+ {
+ var rn = _rtti.ResolveRttiName(t1);
+ ann += rn is not null ? $"vtable:{rn}]" : "vtable]";
+ }
+ else
+ {
+ var ds = _strings.ReadNullTermWString(qw);
+ if (ds is not null) { ann += $"wchar:\"{ds}\"]"; }
+ else if (t1 != 0)
+ {
+ var ds2 = _strings.ReadNullTermWString(t1);
+ if (ds2 is not null) { ann += $"dat:\"{ds2}\"]"; }
+ else { ann += $"0x{t1:X}]"; }
+ }
+ else ann += "null]";
+ }
+ }
+ }
+
+ sb.AppendLine($" +0x{ci:X3}: {hexB} int32={i32,11} u32=0x{u32:X8}{ann}");
+ }
+ if (subVecSize > 512) sb.AppendLine($" ... ({subVecSize - 512} more bytes)");
+ }
+ }
+ }
+
+ // Follow heap pointer objects in QF region (check vtable → RTTI for container identification)
+ sb.AppendLine();
+ sb.AppendLine("Heap objects in QF region:");
+ for (var off = 0; off + 8 <= regionData.Length; off += 8)
+ {
+ var ptr = (nint)BitConverter.ToInt64(regionData, off);
+ if (ptr == 0) continue;
+ var ph = (ulong)ptr >> 32;
+ if (ph is 0 or >= 0x7FFF || (ptr & 0x3) != 0) continue;
+
+ var vtbl = mem.ReadPointer(ptr);
+ if (vtbl == 0 || !_ctx.IsModuleAddress(vtbl)) continue;
+
+ var rn = _rtti.ResolveRttiName(vtbl);
+ sb.AppendLine($" QF+0x{off:X3}: 0x{ptr:X} → {(rn ?? "vtable (no RTTI)")}");
+
+ // Dump first 128 bytes of this object for analysis
+ var objData = mem.ReadBytes(ptr, 128);
+ if (objData is not null)
+ {
+ for (var oi = 8; oi + 8 <= objData.Length; oi += 8) // skip vtable at +0
+ {
+ var ov = (nint)BitConverter.ToInt64(objData, oi);
+ if (ov == 0) continue;
+ var oh = (ulong)ov >> 32;
+ var ann = "";
+ if (oh is > 0 and < 0x7FFF && (ov & 0x1) == 0)
+ {
+ var os = _strings.ReadNullTermWString(ov);
+ if (os is not null) ann = $" → \"{os}\"";
+ else if ((ov & 0x3) == 0)
+ {
+ var op = mem.ReadPointer(ov);
+ if (op != 0)
+ {
+ var os2 = _strings.ReadNullTermWString(op);
+ if (os2 is not null) ann = $" → dat → \"{os2}\"";
+ }
+ }
+ }
+ if (ann != "" || ov != 0)
+ sb.AppendLine($" obj+0x{oi:X2}: 0x{ov:X16}{ann}");
+ }
+ }
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // QuestState Object Probe: scan ALL PSD pointer vectors for objects
+ // with ExileCore2-style QuestState fields:
+ // - QuestAddress: ptr → dat row → wchar* quest name
+ // - QuestStateId: int32 matching known indices (1-256)
+ // - QuestStateTextAddress: ptr → wchar*
+ // - QuestProgressTextAddress: ptr → wchar*
+ // ═══════════════════════════════════════════════════════════════════
+ sb.AppendLine();
+ sb.AppendLine(new string('═', 80));
+ sb.AppendLine("QuestState Object Probe:");
+ sb.AppendLine("Scanning PSD pointer vectors for objects with quest-like fields...");
+ sb.AppendLine(new string('─', 80));
+
+ // Collect known quest state indices from the int32 vector (PSD+0x308)
+ var knownIndices = new HashSet();
+ var questVecAddr = playerServerData + offsets.QuestFlagsOffset;
+ var qvBegin = mem.ReadPointer(questVecAddr);
+ var qvEnd = mem.ReadPointer(questVecAddr + 8);
+ if (qvBegin != 0 && qvEnd > qvBegin)
+ {
+ var qvSize = (int)(qvEnd - qvBegin);
+ if (qvSize > 0 && qvSize < 0x10000)
+ {
+ var qvData = mem.ReadBytes(qvBegin, qvSize);
+ if (qvData is not null)
+ {
+ for (var qi = 0; qi + 4 <= qvData.Length; qi += 4)
+ {
+ var idx = BitConverter.ToInt32(qvData, qi);
+ if (idx > 0 && idx < 1000) knownIndices.Add(idx);
+ }
+ }
+ }
+ }
+ sb.AppendLine($"Known quest state indices: {knownIndices.Count} (from int32 vector at PSD+0x{offsets.QuestFlagsOffset:X})");
+
+ // ═══════════════════════════════════════════════════════════════════
+ // COMPANION VECTOR: QF+0x018 = PSD+0x320
+ // The QuestFlags struct has TWO vectors side by side:
+ // QF+0x000 (PSD+0x308): StdVector — quest state indices
+ // QF+0x018 (PSD+0x320): StdVector — quest state DATA
+ // If they have the same entry count, each 24-byte struct corresponds
+ // to one int32 index and likely contains: QuestDatPtr, StateTextPtr, etc.
+ // ═══════════════════════════════════════════════════════════════════
+ sb.AppendLine();
+ sb.AppendLine(new string('═', 80));
+ sb.AppendLine("QF Companion Vector (QF+0x018 = PSD+0x320):");
+ sb.AppendLine(new string('─', 80));
+
+ var qfCompBegin = mem.ReadPointer(questFlagsAddr + 0x18);
+ var qfCompEnd = mem.ReadPointer(questFlagsAddr + 0x20);
+ var qfCompCap = mem.ReadPointer(questFlagsAddr + 0x28);
+
+ if (qfCompBegin != 0 && qfCompEnd > qfCompBegin)
+ {
+ var compSize = (int)(qfCompEnd - qfCompBegin);
+ var compCap = (int)(qfCompCap - qfCompBegin);
+ sb.AppendLine($" begin: 0x{qfCompBegin:X} end: 0x{qfCompEnd:X} cap: 0x{qfCompCap:X}");
+ sb.AppendLine($" data size: {compSize} bytes capacity: {compCap} bytes");
+
+ // Try common struct sizes to see what divides evenly
+ var indexCount = knownIndices.Count; // number of entries in int32 vector
+ sb.AppendLine($" int32 vector has {indexCount} entries");
+
+ if (indexCount > 0 && compSize % indexCount == 0)
+ {
+ var structSize = compSize / indexCount;
+ sb.AppendLine($" ★ Divides evenly: {compSize} / {indexCount} = {structSize} bytes per entry ★");
+ }
+
+ foreach (var es in new[] { 8, 12, 16, 20, 24, 28, 32, 40, 48 })
+ {
+ if (compSize % es == 0)
+ sb.AppendLine($" Divides by {es}: {compSize / es} entries");
+ }
+
+ // Read the companion vector data
+ var compData = mem.ReadBytes(qfCompBegin, Math.Min(compSize, 4096));
+ if (compData is not null)
+ {
+ // Determine struct size: prefer match with index count
+ var structSize = indexCount > 0 && compSize % indexCount == 0
+ ? compSize / indexCount
+ : 24; // fallback guess
+
+ var numEntries = compSize / structSize;
+ sb.AppendLine($"\nDumping as {structSize}-byte structs ({numEntries} entries):");
+ sb.AppendLine(new string('─', 80));
+
+ // Collect the int32 indices for cross-reference
+ var indices = new List();
+ if (qvBegin != 0 && qvEnd > qvBegin)
+ {
+ var idxData = mem.ReadBytes(qvBegin, (int)(qvEnd - qvBegin));
+ if (idxData is not null)
+ {
+ for (var qi = 0; qi + 4 <= idxData.Length; qi += 4)
+ indices.Add(BitConverter.ToInt32(idxData, qi));
+ }
+ }
+
+ for (var ei = 0; ei < numEntries && ei * structSize < compData.Length && ei < 40; ei++)
+ {
+ var entryOff = ei * structSize;
+ var idxLabel = ei < indices.Count ? $"idx={indices[ei]}" : "?";
+ sb.AppendLine($"\n [{ei}] ({idxLabel}):");
+
+ // Dump each qword in the struct
+ for (var fi = 0; fi < structSize && entryOff + fi + 8 <= compData.Length; fi += 8)
+ {
+ var fval = (nint)BitConverter.ToInt64(compData, entryOff + fi);
+ var hexB = BitConverter.ToString(compData, entryOff + fi,
+ Math.Min(8, compData.Length - entryOff - fi)).Replace("-", " ");
+
+ var lo32 = BitConverter.ToInt32(compData, entryOff + fi);
+ var hi32 = entryOff + fi + 4 < compData.Length
+ ? BitConverter.ToInt32(compData, entryOff + fi + 4)
+ : 0;
+
+ var ann = "";
+ if (fval != 0)
+ {
+ var fhigh = (ulong)fval >> 32;
+ if (fhigh is > 0 and < 0x7FFF && (fval & 0x1) == 0)
+ {
+ // Try as pointer
+ var directStr = _strings.ReadNullTermWString(fval);
+ if (directStr is not null && directStr.Length >= 1 && directStr.Length <= 500)
+ {
+ ann = $" → wchar \"{directStr}\"";
+ }
+ else if ((fval & 0x3) == 0)
+ {
+ var dp = mem.ReadPointer(fval);
+ if (dp != 0)
+ {
+ var ds = _strings.ReadNullTermWString(dp);
+ if (ds is not null)
+ ann = $" → dat → \"{ds}\"";
+ else
+ {
+ // Try 2-level: ptr → ptr → wchar*
+ var dp2 = mem.ReadPointer(dp);
+ if (dp2 != 0)
+ {
+ var ds2 = _strings.ReadNullTermWString(dp2);
+ if (ds2 is not null)
+ ann = $" → ptr → dat → \"{ds2}\"";
+ else
+ ann = $" → [0x{dp:X}]";
+ }
+ else
+ ann = $" → [0x{dp:X} → null]";
+ }
+ }
+ else
+ ann = " → [null]";
+ }
+ }
+ else
+ {
+ // Small integers
+ if (lo32 > 0 && lo32 < 10000 && hi32 == 0)
+ ann = knownIndices.Contains(lo32) ? $" ★idx={lo32}★" : $" int={lo32}";
+ else if (lo32 != 0 || hi32 != 0)
+ ann = $" [{lo32}/{hi32}]";
+ }
+ }
+
+ sb.AppendLine($" +0x{fi:X2}: {hexB}{ann}");
+ }
+
+ // Also try 4-byte alignment for potential int32 fields
+ if (structSize > 8)
+ {
+ var int32Fields = new List();
+ for (var fi = 0; fi + 4 <= structSize && entryOff + fi + 4 <= compData.Length; fi += 4)
+ {
+ var i32 = BitConverter.ToInt32(compData, entryOff + fi);
+ if (i32 > 0 && i32 < 256 && ei < indices.Count && i32 == indices[ei])
+ int32Fields.Add($"+0x{fi:X2}={i32} (matches own idx!)");
+ }
+ if (int32Fields.Count > 0)
+ sb.AppendLine($" int32 self-match: {string.Join(", ", int32Fields)}");
+ }
+ }
+
+ if (numEntries > 40) sb.AppendLine($"\n ... ({numEntries - 40} more entries)");
+ }
+ }
+ else
+ {
+ sb.AppendLine(" QF+0x018 is null or invalid — no companion vector found.");
+ sb.AppendLine($" QF+0x018: 0x{qfCompBegin:X} QF+0x020: 0x{qfCompEnd:X}");
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // Also probe 3 heap pointers at QF+0x018/0x020/0x028 as objects
+ // (in case they're not a StdVector but separate object pointers)
+ // ═══════════════════════════════════════════════════════════════════
+ sb.AppendLine();
+ sb.AppendLine(new string('═', 80));
+ sb.AppendLine("QF heap pointer probes (+0x018, +0x020, +0x028):");
+ sb.AppendLine(new string('─', 80));
+ foreach (var qfOff in new[] { 0x018, 0x020, 0x028 })
+ {
+ var ptr = mem.ReadPointer(questFlagsAddr + qfOff);
+ if (ptr == 0) continue;
+ var ph = (ulong)ptr >> 32;
+ if (ph is 0 or >= 0x7FFF) continue;
+ if ((ptr & 0x3) != 0)
+ {
+ sb.AppendLine($" QF+0x{qfOff:X3}: 0x{ptr:X} (not aligned, skip)");
+ continue;
+ }
+
+ var vtbl = mem.ReadPointer(ptr);
+ var hasVtbl = vtbl != 0 && _ctx.IsModuleAddress(vtbl);
+ var rtti = hasVtbl ? _rtti.ResolveRttiName(vtbl) : null;
+
+ sb.AppendLine($" QF+0x{qfOff:X3}: 0x{ptr:X}{(hasVtbl ? $" → {(rtti ?? "vtable (no RTTI)")}" : "")}");
+
+ // Dump 64 bytes with string probing
+ var objData = mem.ReadBytes(ptr, 64);
+ if (objData is null) continue;
+ for (var fi = 0; fi + 8 <= objData.Length; fi += 8)
+ {
+ var fval = (nint)BitConverter.ToInt64(objData, fi);
+ var hexB = BitConverter.ToString(objData, fi, 8).Replace("-", " ");
+ var ann = "";
+ if (fval != 0)
+ {
+ var fh = (ulong)fval >> 32;
+ if (fh is > 0 and < 0x7FFF && (fval & 0x1) == 0)
+ {
+ var s = _strings.ReadNullTermWString(fval);
+ if (s is not null) ann = $" → \"{s}\"";
+ else if ((fval & 0x3) == 0)
+ {
+ var dp = mem.ReadPointer(fval);
+ if (dp != 0)
+ {
+ var ds = _strings.ReadNullTermWString(dp);
+ if (ds is not null) ann = $" → dat → \"{ds}\"";
+ }
+ }
+ }
+ }
+ sb.AppendLine($" +0x{fi:X2}: {hexB} (0x{fval:X16}){ann}");
+ }
}
sb.AppendLine();
sb.AppendLine(new string('═', 80));
sb.AppendLine("Next steps:");
- sb.AppendLine("1. Accept a quest in-game, re-run this scan, compare vector size → derive entry size");
- sb.AppendLine("2. Look for heap pointers (Quest.dat row), byte values (state ID), wchar* (text)");
- sb.AppendLine("3. Update QuestFlagEntrySize and other quest offsets in offsets.json");
+ sb.AppendLine("1. Check companion vector entries for ptr→dat→quest name, ptr→wchar* text");
+ sb.AppendLine("2. Match struct field offsets to ExileCore2 QuestState layout");
+ sb.AppendLine("3. Update QuestReader to read companion vector alongside int32 indices");
return sb.ToString();
}
@@ -4118,4 +4832,689 @@ public sealed class MemoryDiagnostics
sb.AppendLine($" +0x{baseOffset + off:X3}: {hexBytes} (0x{val:X16})");
}
}
+
+ ///
+ /// Dumps quest state objects to discover the Quest pointer offset.
+ /// Follows: ServerData → PSD → QuestFlags companion vector → quest state object → probe for Quest dat ptr.
+ /// Scans each quest state object for heap pointers that dereference to wchar* strings (quest names).
+ ///
+ public string ScanQuestStateObjects()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (ingameData == 0) return "Error: AreaInstance not resolved";
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+ var sb = new StringBuilder();
+
+ var serverData = mem.ReadPointer(ingameData + offsets.ServerDataOffset);
+ if (serverData == 0) return "Error: ServerData is null";
+
+ var psdVecBegin = mem.ReadPointer(serverData + offsets.PlayerServerDataOffset);
+ if (psdVecBegin == 0) return "Error: PSD vector is null";
+ var psd = mem.ReadPointer(psdVecBegin);
+ if (psd == 0) return "Error: PSD[0] is null";
+
+ var questFlagsAddr = psd + offsets.QuestFlagsOffset;
+ sb.AppendLine($"PSD: 0x{psd:X} QuestFlags: 0x{questFlagsAddr:X}");
+
+ // Read int32 index vector
+ var idxBegin = mem.ReadPointer(questFlagsAddr);
+ var idxEnd = mem.ReadPointer(questFlagsAddr + 8);
+ if (idxBegin == 0 || idxEnd <= idxBegin) return "Error: int32 vector empty";
+ var idxCount = (int)(idxEnd - idxBegin) / 4;
+ sb.AppendLine($"Int32 vector: {idxCount} entries");
+
+ // Read companion vector
+ var compBegin = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset);
+ var compEnd = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset + 8);
+ if (compBegin == 0 || compEnd <= compBegin) return "Error: companion vector empty";
+ var compEntrySize = offsets.QuestCompanionEntrySize;
+ var compCount = (int)(compEnd - compBegin) / compEntrySize;
+ sb.AppendLine($"Companion vector: {compCount} entries × {compEntrySize} bytes");
+
+ var compData = mem.ReadBytes(compBegin, compCount * compEntrySize);
+ var idxData = mem.ReadBytes(idxBegin, idxCount * 4);
+ if (compData is null || idxData is null) return "Error: failed to read vectors";
+
+ sb.AppendLine(new string('═', 100));
+
+ // ── Dump first few quest state objects in detail ──
+ var dumpCount = Math.Min(compCount, 8);
+ sb.AppendLine($"\nDumping {dumpCount} quest state objects (0x200 bytes each):");
+ sb.AppendLine(new string('─', 100));
+
+ for (var i = 0; i < dumpCount; i++)
+ {
+ var compOff = i * compEntrySize;
+ var questStateId = BitConverter.ToInt32(compData, compOff);
+ var trackedVal = BitConverter.ToUInt32(compData, compOff + 4);
+ var questObjPtr = (nint)BitConverter.ToInt64(compData, compOff + offsets.QuestCompanionObjPtrOffset);
+ var isTracked = trackedVal == offsets.QuestTrackedMarker;
+
+ sb.AppendLine($"\n[{i}] QuestStateId={questStateId} tracked={isTracked} objPtr=0x{questObjPtr:X}");
+
+ if (questObjPtr == 0 || (ulong)questObjPtr >> 32 is 0 or >= 0x7FFF)
+ {
+ sb.AppendLine(" (null or invalid pointer)");
+ continue;
+ }
+
+ // Read 0x200 bytes of the quest state object
+ var objData = mem.ReadBytes(questObjPtr, 0x200);
+ if (objData is null)
+ {
+ sb.AppendLine(" (read failed)");
+ continue;
+ }
+
+ // Scan ALL heap pointers in the quest state object
+ // For each: try direct wchar*, then follow and scan target's contents for wchar* ptrs
+ sb.AppendLine(" Probing ALL offsets (direct + two-level + three-level):");
+ var anyHit = false;
+ for (var off = 0; off + 8 <= objData.Length; off += 8)
+ {
+ var val = (nint)BitConverter.ToInt64(objData, off);
+ if (val == 0) continue;
+ var high = (ulong)val >> 32;
+ if (high is 0 or >= 0x7FFF) continue;
+
+ // Skip internal pointers (pointing within the same object)
+ if (Math.Abs((long)val - (long)questObjPtr) < 0x300) continue;
+
+ // Level 1: try val as wchar* directly
+ var l1Bytes = mem.ReadBytes(val, 512);
+ if (l1Bytes is not null)
+ {
+ var l1Str = TryDecodeLatinWcharString(l1Bytes);
+ if (l1Str is not null)
+ {
+ sb.AppendLine($" +0x{off:X3} → \"{l1Str}\"");
+ anyHit = true;
+ continue;
+ }
+ }
+
+ // Level 2: follow val, scan target for wchar* pointers (covers dat row → wchar*)
+ var subData = mem.ReadBytes(val, 0x100);
+ if (subData is null) continue;
+
+ for (var subOff = 0; subOff + 8 <= subData.Length; subOff += 8)
+ {
+ var strPtr = (nint)BitConverter.ToInt64(subData, subOff);
+ if (strPtr == 0 || (ulong)strPtr >> 32 is 0 or >= 0x7FFF) continue;
+
+ var strBytes = mem.ReadBytes(strPtr, 512);
+ if (strBytes is null) continue;
+
+ var str = TryDecodeLatinWcharString(strBytes);
+ if (str is not null)
+ {
+ sb.AppendLine($" +0x{off:X3} → 0x{val:X} +0x{subOff:X2} → \"{str}\"");
+ anyHit = true;
+ }
+ }
+ }
+
+ if (!anyHit)
+ sb.AppendLine(" (no Latin strings found at any level)");
+
+ // Dump first 3 external sub-objects raw hex (the 0x20E1484xxxx pointers)
+ if (i < 3) // only for first 3 quest state objects to keep output manageable
+ {
+ sb.AppendLine($" Raw dump of first external sub-objects:");
+ var extCount = 0;
+ for (var off = 0x030; off + 8 <= Math.Min(0x098, objData.Length) && extCount < 3; off += 8)
+ {
+ var extPtr = (nint)BitConverter.ToInt64(objData, off);
+ if (extPtr == 0 || (ulong)extPtr >> 32 is 0 or >= 0x7FFF) continue;
+ if (Math.Abs((long)extPtr - (long)questObjPtr) < 0x300) continue;
+
+ var extData = mem.ReadBytes(extPtr, 0x80);
+ if (extData is null) continue;
+
+ sb.AppendLine($" obj+0x{off:X3} → 0x{extPtr:X}:");
+ for (var hex = 0; hex < extData.Length; hex += 16)
+ {
+ var hexPart = BitConverter.ToString(extData, hex, Math.Min(16, extData.Length - hex)).Replace("-", " ");
+ // Also show as qwords
+ var q1 = hex + 8 <= extData.Length ? $"0x{BitConverter.ToInt64(extData, hex):X}" : "";
+ sb.AppendLine($" +0x{hex:X2}: {hexPart,-48} {q1}");
+ }
+ extCount++;
+ }
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ /// Tries to decode bytes as a null-terminated wchar (UTF-16LE) string. Returns null if not valid text.
+ private static string? TryDecodeWcharString(byte[] data)
+ {
+ var charCount = 0;
+ for (var i = 0; i + 1 < data.Length; i += 2)
+ {
+ if (data[i] == 0 && data[i + 1] == 0) break;
+ var ch = (char)(data[i] | (data[i + 1] << 8));
+ if (char.IsControl(ch) && ch != '\t' && ch != '\n' && ch != '\r') return null;
+ charCount++;
+ if (charCount > 120) break;
+ }
+
+ if (charCount < 2) return null;
+
+ try
+ {
+ return Encoding.Unicode.GetString(data, 0, charCount * 2);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Strict string decoder: only accepts strings that are primarily ASCII/Latin characters.
+ /// Rejects random binary data that happens to decode as CJK or other non-Latin Unicode.
+ /// Good for POE quest names, paths, internal IDs which are English/Latin.
+ ///
+ private static string? TryDecodeLatinWcharString(byte[] data)
+ {
+ var charCount = 0;
+ var asciiCount = 0;
+ for (var i = 0; i + 1 < data.Length; i += 2)
+ {
+ if (data[i] == 0 && data[i + 1] == 0) break;
+ var ch = (char)(data[i] | (data[i + 1] << 8));
+ if (char.IsControl(ch) && ch != '\t' && ch != '\n' && ch != '\r') return null;
+ if (ch >= 0x20 && ch <= 0x7E) asciiCount++; // printable ASCII
+ charCount++;
+ if (charCount > 200) break;
+ }
+
+ // Require at least 3 chars with >60% ASCII
+ if (charCount < 3) return null;
+ if (asciiCount * 100 / charCount < 60) return null;
+
+ try
+ {
+ return Encoding.Unicode.GetString(data, 0, charCount * 2);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Discovers UIElements by structural matching: finds StdVector-of-pointers patterns where
+ /// children share the same layout (recursive tree structure). No assumptions about Self offset.
+ /// POE1 reference: InGameState+0x648=UiRoot, +0xC40=IngameUi. POE2 offsets differ.
+ ///
+ public string ScanUiElements()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+ var sb = new StringBuilder();
+
+ sb.AppendLine($"GameStateBase: 0x{_ctx.GameStateBase:X}");
+ sb.AppendLine($"ModuleBase: 0x{_ctx.ModuleBase:X} (offset: 0x{_ctx.GameStateBase - _ctx.ModuleBase:X})");
+
+ var controller = mem.ReadPointer(_ctx.GameStateBase);
+ if (controller == 0) return sb.ToString() + "\nError: controller is null (ReadPointer at GameStateBase returned 0)";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+
+ sb.AppendLine($"Controller: 0x{controller:X}");
+ sb.AppendLine($"InGameState: 0x{inGameState:X} (via {(offsets.InGameStateDirectOffset > 0 ? $"direct Ctrl+0x{offsets.InGameStateDirectOffset:X}" : "state array")})");
+ sb.AppendLine($"StatesCount: {snap.StatesCount}");
+ sb.AppendLine($"UiRoot offsets: Struct=+0x{offsets.UiRootStructOffset:X}, Root=+0x{offsets.UiRootPtrOffset:X}, GameUi=+0x{offsets.GameUiPtrOffset:X}");
+ sb.AppendLine($"UIElement offsets: Self=+0x{offsets.UiElementSelfOffset:X}, Children=+0x{offsets.UiElementChildrenOffset:X}, Parent=+0x{offsets.UiElementParentOffset:X}, StringId=+0x{offsets.UiElementStringIdOffset:X}");
+ sb.AppendLine(new string('═', 100));
+
+ // ── Phase 0: Follow UiRootStruct pointer chain from each state ──
+ sb.AppendLine("\nPhase 0: Following UiRootStruct pointer chain...");
+ sb.AppendLine(new string('─', 100));
+
+ // Dump all state slots
+ for (var si = 0; si < offsets.StateCount; si++)
+ {
+ var sp = mem.ReadPointer(controller + offsets.StatesBeginOffset + si * offsets.StateStride);
+ sb.AppendLine($" State[{si}]: 0x{sp:X}{(si == offsets.InGameStateIndex ? " ← InGameStateIndex" : "")}");
+ }
+ if (inGameState != 0)
+ sb.AppendLine($" Direct IGS (Ctrl+0x{offsets.InGameStateDirectOffset:X}): 0x{inGameState:X}");
+
+ var selfOff = offsets.UiElementSelfOffset;
+ var chOff = offsets.UiElementChildrenOffset;
+ var parentOff = offsets.UiElementParentOffset;
+ var readSize = Math.Max(parentOff + 8, chOff + 0x18);
+
+ // Try UiRootStruct chain from: Controller directly (may BE InGameState), state slots, and direct IGS
+ var uiRootCandidates = new List<(string source, nint statePtr)>();
+ uiRootCandidates.Add(("Controller", controller)); // Controller may be InGameState with new pattern
+ for (var si = 0; si < offsets.StateCount; si++)
+ {
+ var sp = mem.ReadPointer(controller + offsets.StatesBeginOffset + si * offsets.StateStride);
+ if (sp != 0 && (ulong)sp >> 32 is > 0 and < 0x7FFF)
+ uiRootCandidates.Add(($"State[{si}]", sp));
+ }
+ if (inGameState != 0 && inGameState != controller)
+ uiRootCandidates.Add(("Direct IGS", inGameState));
+
+ nint bestUiRoot = 0, bestGameUi = 0;
+ string bestSource = "";
+
+ foreach (var (source, statePtr) in uiRootCandidates)
+ {
+ var uiRootStructPtr = mem.ReadPointer(statePtr + offsets.UiRootStructOffset);
+ if (uiRootStructPtr == 0 || (ulong)uiRootStructPtr >> 32 is 0 or >= 0x7FFF) continue;
+
+ var uiRootPtr = mem.ReadPointer(uiRootStructPtr + offsets.UiRootPtrOffset);
+ var gameUiPtr = mem.ReadPointer(uiRootStructPtr + offsets.GameUiPtrOffset);
+ var gameUiCtrlPtr = mem.ReadPointer(uiRootStructPtr + offsets.GameUiControllerPtrOffset);
+
+ if (uiRootPtr == 0 && gameUiPtr == 0) continue;
+
+ sb.AppendLine($"\n {source} (0x{statePtr:X}):");
+ sb.AppendLine($" +0x{offsets.UiRootStructOffset:X} → UiRootStruct: 0x{uiRootStructPtr:X}");
+ sb.AppendLine($" +0x{offsets.UiRootPtrOffset:X} UiRootPtr: 0x{uiRootPtr:X}");
+ sb.AppendLine($" +0x{offsets.GameUiPtrOffset:X} GameUiPtr: 0x{gameUiPtr:X}");
+ sb.AppendLine($" +0x{offsets.GameUiControllerPtrOffset:X} GameUiControllerPtr: 0x{gameUiCtrlPtr:X}");
+
+ // Validate each as UIElement
+ foreach (var (label, elemPtr) in new[] { ("UiRoot", uiRootPtr), ("GameUi", gameUiPtr) })
+ {
+ if (elemPtr == 0 || (ulong)elemPtr >> 32 is 0 or >= 0x7FFF) continue;
+
+ var elemData = mem.ReadBytes(elemPtr, readSize);
+ if (elemData is null) { sb.AppendLine($" {label}: read failed"); continue; }
+
+ var selfVal = (nint)BitConverter.ToInt64(elemData, selfOff);
+ var selfStr = selfVal == elemPtr ? "Self✓" : selfVal == 0 ? "Self=0" : $"Self=0x{selfVal:X}";
+
+ var cb = (nint)BitConverter.ToInt64(elemData, chOff);
+ var ce = (nint)BitConverter.ToInt64(elemData, chOff + 8);
+ var cc = (nint)BitConverter.ToInt64(elemData, chOff + 16);
+ var chCount = (cb != 0 && ce >= cb && cc >= ce && (ulong)cb >> 32 is > 0 and < 0x7FFF)
+ ? (int)(ce - cb) / 8 : -1;
+
+ var parentVal = elemData.Length > parentOff + 8 ? (nint)BitConverter.ToInt64(elemData, parentOff) : 0;
+
+ var strId = ReadUiElementStringId(mem, elemPtr, offsets);
+
+ sb.AppendLine($" {label}: 0x{elemPtr:X} {selfStr} ch@{chOff:X3}={chCount} parent=0x{parentVal:X} StringId=\"{strId}\"");
+
+ // If Self✓ and has children, check first child
+ if (selfVal == elemPtr && chCount > 0 && chCount < 5000)
+ {
+ if (label == "UiRoot" && bestUiRoot == 0) { bestUiRoot = elemPtr; bestSource = source; }
+ if (label == "GameUi" && bestGameUi == 0) { bestGameUi = elemPtr; bestSource = source; }
+
+ var fc = mem.ReadPointer(cb);
+ if (fc != 0 && (ulong)fc >> 32 is > 0 and < 0x7FFF)
+ {
+ var fcSelf = mem.ReadPointer(fc + selfOff);
+ var fcParent = mem.ReadPointer(fc + parentOff);
+ sb.AppendLine($" child[0]=0x{fc:X} Self{(fcSelf == fc ? "✓" : "✗")} Parent{(fcParent == elemPtr ? "✓" : $"=0x{fcParent:X}")}");
+ }
+ }
+ }
+ }
+
+ // ── Phase 1: Walk UIElement tree from UiRoot and GameUi ──
+ sb.AppendLine(new string('═', 100));
+ sb.AppendLine("\nPhase 1: Walking UIElement trees...");
+ sb.AppendLine(new string('─', 100));
+
+ foreach (var (label, rootPtr) in new[] { ("UiRoot", bestUiRoot), ("GameUi", bestGameUi) })
+ {
+ if (rootPtr == 0) { sb.AppendLine($" {label}: not found"); continue; }
+
+ sb.AppendLine($"\n {label}: 0x{rootPtr:X} (from {bestSource})");
+
+ var visited = new HashSet();
+ var bfsQueue = new Queue<(nint ptr, int depth, string path)>();
+ bfsQueue.Enqueue((rootPtr, 0, label));
+ var nodeCount = 0;
+ var maxBfsDepth = 0;
+ var strIds = new List<(int depth, string path, string id)>();
+ var depthCounts = new Dictionary();
+
+ while (bfsQueue.Count > 0 && nodeCount < 5000)
+ {
+ var (nodePtr, depth, path) = bfsQueue.Dequeue();
+ if (!visited.Add(nodePtr)) continue;
+ if (depth > 15) continue;
+ nodeCount++;
+ if (depth > maxBfsDepth) maxBfsDepth = depth;
+ depthCounts.TryGetValue(depth, out var dc);
+ depthCounts[depth] = dc + 1;
+
+ var nodeData = mem.ReadBytes(nodePtr, readSize);
+ if (nodeData is null) continue;
+
+ // Verify Self (skip nodes without Self✓ except root)
+ var nSelf = (nint)BitConverter.ToInt64(nodeData, selfOff);
+ if (nSelf != nodePtr && depth > 0) continue;
+
+ // Read StringId at configured offset
+ var sid = ReadUiElementStringId(mem, nodePtr, offsets);
+ if (sid is { Length: > 0 })
+ strIds.Add((depth, path, sid));
+
+ // Enqueue children
+ var ncb = (nint)BitConverter.ToInt64(nodeData, chOff);
+ var nce = (nint)BitConverter.ToInt64(nodeData, chOff + 8);
+ if (ncb != 0 && nce > ncb && (ulong)ncb >> 32 is > 0 and < 0x7FFF)
+ {
+ var chN = (int)(nce - ncb) / 8;
+ if (chN > 0 && chN <= 5000)
+ {
+ var readN = Math.Min(chN, 200);
+ var chData = mem.ReadBytes(ncb, readN * 8);
+ if (chData is not null)
+ {
+ for (var ci = 0; ci < readN; ci++)
+ {
+ var cp = (nint)BitConverter.ToInt64(chData, ci * 8);
+ if (cp != 0 && (ulong)cp >> 32 is > 0 and < 0x7FFF && !visited.Contains(cp))
+ bfsQueue.Enqueue((cp, depth + 1, $"{path}[{ci}]"));
+ }
+ }
+ }
+ }
+ }
+
+ sb.AppendLine($" Nodes: {nodeCount}, MaxDepth: {maxBfsDepth}");
+ sb.AppendLine($" Depth distribution: {string.Join(", ", depthCounts.OrderBy(kv => kv.Key).Select(kv => $"d{kv.Key}={kv.Value}"))}");
+ sb.AppendLine($" StringIds found: {strIds.Count}");
+ foreach (var (d, p, sid) in strIds.OrderBy(s => s.depth).Take(100))
+ sb.AppendLine($" d={d} {p}: \"{sid}\"");
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Scans UIElements that should have visible text (label_title, close_button, quest_selector, etc.)
+ /// and dumps all StdWString-like fields to discover the text content offset.
+ ///
+ public string ScanUiElementText()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+ var sb = new StringBuilder();
+
+ // Resolve GameUi
+ var snap = new GameStateSnapshot();
+ var igs = _stateReader.ResolveInGameState(snap);
+ if (igs == 0) return "Error: InGameState not resolved";
+
+ var uiRootStruct = mem.ReadPointer(igs + offsets.UiRootStructOffset);
+ if (uiRootStruct == 0) return "Error: UiRootStruct is null";
+
+ var gameUiPtr = mem.ReadPointer(uiRootStruct + offsets.GameUiPtrOffset);
+ if (gameUiPtr == 0) return "Error: GameUi is null";
+
+ sb.AppendLine("Scanning UIElements for text content fields...");
+ sb.AppendLine($"GameUi: 0x{gameUiPtr:X}");
+ sb.AppendLine(new string('═', 100));
+
+ // Target elements likely to have visible text
+ var targets = new[] { "label_title", "close_button", "quest_selector", "chat_box",
+ "HUD", "instance_paused", "changing_area_message", "InfoLabel", "StashSearchBar" };
+
+ // BFS to find target elements
+ var found = new List<(nint addr, string stringId, string path)>();
+ var visited = new HashSet();
+ var queue = new Queue<(nint ptr, int depth, string path)>();
+ queue.Enqueue((gameUiPtr, 0, "GameUi"));
+
+ while (queue.Count > 0 && found.Count < 20)
+ {
+ var (nodePtr, depth, path) = queue.Dequeue();
+ if (!visited.Add(nodePtr) || depth > 4) continue;
+
+ var selfVal = mem.ReadPointer(nodePtr + offsets.UiElementSelfOffset);
+ if (selfVal != nodePtr && depth > 0) continue;
+
+ var sid = ReadUiElementStringId(mem, nodePtr, offsets);
+ if (sid is { Length: > 0 } && targets.Contains(sid))
+ found.Add((nodePtr, sid, path));
+
+ var childPtrs = ReadUiElementChildPtrs(mem, nodePtr, offsets, 200);
+ for (var i = 0; i < childPtrs.Count; i++)
+ {
+ if (!visited.Contains(childPtrs[i]))
+ queue.Enqueue((childPtrs[i], depth + 1, $"{path}[{i}]"));
+ }
+ }
+
+ sb.AppendLine($"Found {found.Count} target elements\n");
+
+ // For each target, read 0x800 bytes and scan for StdWString patterns
+ foreach (var (addr, stringId, path) in found.Take(10))
+ {
+ sb.AppendLine($"── {stringId} ({path}) @ 0x{addr:X} ──");
+
+ ScanElementForStrings(mem, offsets, sb, addr, " ", 0x800);
+
+ // Also scan children (leaf text nodes often hold the actual display text)
+ var children = ReadUiElementChildPtrs(mem, addr, offsets, 20);
+ for (var ci = 0; ci < Math.Min(children.Count, 5); ci++)
+ {
+ var childPtr = children[ci];
+ var childSelf = mem.ReadPointer(childPtr + offsets.UiElementSelfOffset);
+ if (childSelf != childPtr) continue;
+
+ var childSid = ReadUiElementStringId(mem, childPtr, offsets);
+ var childChildren = ReadUiElementChildren(mem, childPtr, offsets);
+ sb.AppendLine($" child[{ci}]: 0x{childPtr:X} StringId=\"{childSid}\" ch={childChildren.count}");
+ ScanElementForStrings(mem, offsets, sb, childPtr, " ", 0x800);
+ }
+ sb.AppendLine();
+ }
+
+ return sb.ToString();
+ }
+
+ /// Reads children StdVector at an arbitrary offset (not using configured offset).
+ private (int count, nint begin) ReadChildrenAt(ProcessMemory mem, nint addr, int vecOffset)
+ {
+ var b = mem.ReadPointer(addr + vecOffset);
+ var e = mem.ReadPointer(addr + vecOffset + 8);
+ if (b == 0 || e <= b) return (0, b);
+ var c = (int)(e - b) / 8;
+ return c > 10000 ? (0, b) : (c, b);
+ }
+
+ /// Reads child pointers from a StdVector at an arbitrary offset.
+ private List ReadChildPtrsAt(ProcessMemory mem, nint addr, int vecOffset, int maxCount)
+ {
+ var result = new List();
+ var b = mem.ReadPointer(addr + vecOffset);
+ var e = mem.ReadPointer(addr + vecOffset + 8);
+ if (b == 0 || e <= b) return result;
+ var count = Math.Min((int)(e - b) / 8, maxCount);
+ if (count <= 0) return result;
+ var data = mem.ReadBytes(b, count * 8);
+ if (data is null) return result;
+ for (var i = 0; i < count; i++)
+ {
+ var ptr = (nint)BitConverter.ToInt64(data, i * 8);
+ if (ptr != 0 && (ulong)ptr >> 32 is > 0 and < 0x7FFF)
+ result.Add(ptr);
+ }
+ return result;
+ }
+
+ /// Scans a UIElement's memory for all StdWString-like fields.
+ private void ScanElementForStrings(ProcessMemory mem, GameOffsets offsets, StringBuilder sb, nint addr, string indent, int scanSize)
+ {
+ var data = mem.ReadBytes(addr, scanSize);
+ if (data is null) { sb.AppendLine($"{indent}Read failed"); return; }
+
+ var found = false;
+ for (var off = 0; off + 0x20 <= data.Length; off += 8)
+ {
+ var len = BitConverter.ToInt32(data, off + 0x10);
+ var cap = BitConverter.ToInt32(data, off + 0x18);
+ if (len <= 0 || len > 4096 || cap < len || cap > 8192) continue;
+
+ string? str = null;
+ try
+ {
+ if (cap <= 8)
+ {
+ var byteLen = Math.Min(len * 2, 16);
+ str = Encoding.Unicode.GetString(data, off, byteLen).TrimEnd('\0');
+ }
+ else
+ {
+ var ptr = (nint)BitConverter.ToInt64(data, off);
+ if (ptr != 0 && (ulong)ptr >> 32 is > 0 and < 0x7FFF)
+ {
+ var strBytes = mem.ReadBytes(ptr, Math.Min(len * 2, 512));
+ if (strBytes is not null)
+ str = Encoding.Unicode.GetString(strBytes).TrimEnd('\0');
+ }
+ }
+ }
+ catch { continue; }
+
+ if (str is not { Length: > 0 } || str.Any(char.IsControl)) continue;
+
+ var label = off == offsets.UiElementStringIdOffset ? " ← StringId"
+ : off == offsets.UiElementTextOffset ? " ← Text"
+ : "";
+ sb.AppendLine($"{indent}+0x{off:X3} [len={len} cap={cap}]: \"{str}\"{label}");
+ found = true;
+ }
+
+ if (!found)
+ sb.AppendLine($"{indent}No StdWString patterns found");
+ }
+
+ /// Reads the children StdVector pointers from a UIElement.
+ private List ReadUiElementChildPtrs(ProcessMemory mem, nint elementAddr, GameOffsets offsets, int maxCount)
+ {
+ var result = new List();
+ var vecBegin = mem.ReadPointer(elementAddr + offsets.UiElementChildrenOffset);
+ var vecEnd = mem.ReadPointer(elementAddr + offsets.UiElementChildrenOffset + 8);
+ if (vecBegin == 0 || vecEnd <= vecBegin) return result;
+
+ var totalBytes = (int)(vecEnd - vecBegin);
+ var count = totalBytes / 8; // pointers
+ if (count <= 0 || count > 10000) return result;
+ count = Math.Min(count, maxCount);
+
+ var data = mem.ReadBytes(vecBegin, count * 8);
+ if (data is null) return result;
+
+ for (var i = 0; i < count; i++)
+ {
+ var ptr = (nint)BitConverter.ToInt64(data, i * 8);
+ if (ptr != 0)
+ result.Add(ptr);
+ }
+ return result;
+ }
+
+ /// Gets children count and vector info for a UIElement.
+ private (int count, nint begin, nint end) ReadUiElementChildren(ProcessMemory mem, nint elementAddr, GameOffsets offsets)
+ {
+ var vecBegin = mem.ReadPointer(elementAddr + offsets.UiElementChildrenOffset);
+ var vecEnd = mem.ReadPointer(elementAddr + offsets.UiElementChildrenOffset + 8);
+ if (vecBegin == 0 || vecEnd <= vecBegin) return (0, vecBegin, vecEnd);
+ var count = (int)(vecEnd - vecBegin) / 8;
+ if (count > 10000) return (0, vecBegin, vecEnd); // sanity
+ return (count, vecBegin, vecEnd);
+ }
+
+ /// Reads the StringId (element identifier) from a UIElement.
+ private string? ReadUiElementStringId(ProcessMemory mem, nint elementAddr, GameOffsets offsets)
+ => ReadUiWString(mem, elementAddr + offsets.UiElementStringIdOffset);
+
+ /// Reads the display text from a UIElement (not all elements have text).
+ private string? ReadUiElementText(ProcessMemory mem, nint elementAddr, GameOffsets offsets)
+ => ReadUiWString(mem, elementAddr + offsets.UiElementTextOffset);
+
+ ///
+ /// Reads an MSVC std::wstring at the given address.
+ /// Layout: Buffer(8) + Reserved(8) + Length(int32) + pad(4) + Capacity(int32) + pad(4) = 32 bytes.
+ /// SSO: if Capacity <= 8, string is inline in Buffer+Reserved (16 bytes). Otherwise Buffer is a heap pointer.
+ ///
+ private string? ReadUiWString(ProcessMemory mem, nint addr)
+ {
+ var strData = mem.ReadBytes(addr, 32);
+ if (strData is null || strData.Length < 28) return null;
+
+ var length = BitConverter.ToInt32(strData, 0x10); // wchar count
+ var capacity = BitConverter.ToInt32(strData, 0x18); // wchar capacity
+ if (length <= 0 || length > 4096 || capacity < length) return null;
+
+ try
+ {
+ if (capacity <= 8)
+ {
+ var byteLen = Math.Min(length * 2, 16);
+ return Encoding.Unicode.GetString(strData, 0, byteLen).TrimEnd('\0');
+ }
+ else
+ {
+ var ptr = (nint)BitConverter.ToInt64(strData, 0);
+ if (ptr == 0 || (ulong)ptr >> 32 is 0 or >= 0x7FFF) return null;
+ var charData = mem.ReadBytes(ptr, Math.Min(length * 2, 512));
+ if (charData is null) return null;
+ return Encoding.Unicode.GetString(charData).TrimEnd('\0');
+ }
+ }
+ catch { return null; }
+ }
+
+ /// BFS traversal of UIElement tree, collecting elements that match a predicate.
+ private void BfsSearchUiElements(
+ ProcessMemory mem, GameOffsets offsets,
+ nint rootAddr, string rootPath, int currentDepth, int maxDepth,
+ HashSet visited, List<(nint addr, string? stringId, int depth, string path)> results,
+ Func predicate, int selfOffset = -1)
+ {
+ if (selfOffset < 0) selfOffset = offsets.UiElementSelfOffset;
+ if (currentDepth > maxDepth) return;
+ if (!visited.Add(rootAddr)) return;
+
+ var stringId = ReadUiElementStringId(mem, rootAddr, offsets);
+ if (predicate(stringId))
+ results.Add((rootAddr, stringId, currentDepth, rootPath));
+
+ if (currentDepth >= maxDepth) return;
+
+ var childPtrs = ReadUiElementChildPtrs(mem, rootAddr, offsets, 200);
+ for (var i = 0; i < childPtrs.Count; i++)
+ {
+ var childPtr = childPtrs[i];
+ var selfCheck = mem.ReadPointer(childPtr + selfOffset);
+ if (selfCheck != childPtr) continue;
+
+ var childId = ReadUiElementStringId(mem, childPtr, offsets);
+ var childPath = $"{rootPath}/{childId ?? $"[{i}]"}";
+ BfsSearchUiElements(mem, offsets, childPtr, childPath, currentDepth + 1, maxDepth, visited, results, predicate, selfOffset);
+ }
+ }
}
diff --git a/src/Roboto.Memory/QuestNameLookup.cs b/src/Roboto.Memory/QuestNameLookup.cs
new file mode 100644
index 0000000..3cda3e9
--- /dev/null
+++ b/src/Roboto.Memory/QuestNameLookup.cs
@@ -0,0 +1,67 @@
+using System.Text.Json;
+using Serilog;
+
+namespace Roboto.Memory;
+
+///
+/// Loads quest name mappings from a JSON file (generated by tools/dump_quest_names.py).
+/// Provides QuestStateId → (name, internalId, act) lookup.
+///
+public sealed class QuestNameLookup
+{
+ private readonly Dictionary _entries = new();
+ private bool _loaded;
+
+ public record QuestNameEntry(string? Name, string? InternalId, int Act);
+
+ ///
+ /// Tries to load quest names from the given JSON path.
+ /// File format: { "0": { "name": "...", "internalId": "...", "act": 1 }, ... }
+ ///
+ 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);
+}
diff --git a/src/Roboto.Memory/QuestReader.cs b/src/Roboto.Memory/QuestReader.cs
index 1555895..c3987c3 100644
--- a/src/Roboto.Memory/QuestReader.cs
+++ b/src/Roboto.Memory/QuestReader.cs
@@ -8,32 +8,42 @@ namespace Roboto.Memory;
///
public sealed class QuestSnapshot
{
+ /// QuestState.dat row index (int_vector mode) or 0 (pointer mode).
+ public int QuestStateIndex { get; init; }
public nint QuestDatPtr { get; init; }
public string? QuestName { get; init; }
+ /// Internal quest ID from dat row (e.g. "TreeOfSouls2", "IncursionQuest1_Act1").
+ public string? InternalId { get; init; }
+ /// Encounter state from quest state object: 1=locked/not encountered, 2=available/started.
public byte StateId { get; init; }
+ /// True if this quest is the currently tracked/active quest in the UI.
+ public bool IsTracked { get; init; }
public string? StateText { get; init; }
public string? ProgressText { get; init; }
}
///
-/// 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.
///
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 _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;
}
///
@@ -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? ReadVectorQuests(nint questFlagsAddr, GameOffsets offsets)
+ ///
+ /// 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.
+ ///
+ private List? 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(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;
+ }
+
+ ///
+ /// Finds the QuestStates.dat row table base address.
+ /// Uses QuestDatTableBase offset from PSD if configured, otherwise returns 0.
+ ///
+ 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;
+ }
+
+ /// Reads a wchar* pointer at the given address and returns the string.
+ 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;
+ }
+
+ ///
+ /// Legacy/POE1 mode: reads struct entries with dat row pointers and string fields.
+ ///
+ private List? 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;
}
- ///
- /// Resolves quest name by following QuestDatPtr → dat row → wchar* name.
- /// Results are cached since quest names don't change.
- ///
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;
}
}
diff --git a/src/Roboto.Memory/SkillReader.cs b/src/Roboto.Memory/SkillReader.cs
index 388c338..7bfdb76 100644
--- a/src/Roboto.Memory/SkillReader.cs
+++ b/src/Roboto.Memory/SkillReader.cs
@@ -9,6 +9,11 @@ namespace Roboto.Memory;
public sealed class SkillSnapshot
{
public string? Name { get; init; }
+ public string? InternalName { get; init; }
+ /// Address of ActiveSkillPtr in game memory (for CE inspection).
+ public nint Address { get; init; }
+ /// Raw bytes at ActiveSkillPtr for offset discovery.
+ 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; }
/// From Cooldowns vector — max uses (charges) for the skill.
public int MaxUses { get; init; }
+
+ /// Low 16 bits of UnknownIdAndEquipmentInfo — skill ID used for SkillBarIds matching.
+ public ushort Id { get; init; }
+ /// High 16 bits of UnknownIdAndEquipmentInfo — equipment slot / secondary ID.
+ public ushort Id2 { get; init; }
+ /// Skill bar slot index (0-12) from SkillBarIds, or -1 if not on the skill bar.
+ public int SkillBarSlot { get; init; } = -1;
}
///
@@ -44,7 +56,7 @@ public sealed class SkillReader
_strings = strings;
}
- public List? ReadPlayerSkills(nint localPlayerPtr)
+ public List? 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(activeSkillPtr);
+ // Read ActiveSkillDetails struct — ptr points 0x10 into the object (past vtable+header)
+ var details = mem.Read(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;
}
+ ///
+ /// Reads SkillBarIds from PlayerServerData: Buffer13 of (ushort Id, ushort Id2).
+ /// 13 slots × 4 bytes = 52 bytes total.
+ ///
+ 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;
+ }
+
///
/// Reads the Cooldowns vector at Actor+0xB18.
/// Each entry is an ActiveSkillCooldown struct (0x48 bytes).
diff --git a/src/Roboto.Systems/CombatSystem.cs b/src/Roboto.Systems/CombatSystem.cs
index 1d0824d..66e7370 100644
--- a/src/Roboto.Systems/CombatSystem.cs
+++ b/src/Roboto.Systems/CombatSystem.cs
@@ -220,11 +220,8 @@ public class CombatSystem : ISystem
foreach (var ms in memorySkills)
{
if (ms.Name is null) continue;
- // Memory names have "Player" suffix, profile names don't
- var cleanName = ms.Name.EndsWith("Player", StringComparison.Ordinal)
- ? ms.Name[..^6]
- : ms.Name;
- if (string.Equals(cleanName, profileSkillName, StringComparison.OrdinalIgnoreCase))
+ // Name is already stripped of "Player" suffix by MemoryPoller
+ if (string.Equals(ms.Name, profileSkillName, StringComparison.OrdinalIgnoreCase))
return ms;
}
return null;
diff --git a/tools/MemoryViewer/MemoryViewer.cs b/tools/MemoryViewer/MemoryViewer.cs
new file mode 100644
index 0000000..1f0f7da
--- /dev/null
+++ b/tools/MemoryViewer/MemoryViewer.cs
@@ -0,0 +1,506 @@
+
+
+namespace MemoryViewer
+{
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Linq;
+ using System.Numerics;
+ using System.Text;
+ using GameHelper;
+ using GameHelper.Plugin;
+ using GameHelper.Utils;
+ using GameOffsets;
+ using ImGuiNET;
+ using Newtonsoft.Json;
+
+
+ public sealed class MemoryViewer : PCore
+ {
+ private bool isWindowVisible = false;
+ private DateTime lastRefreshTime = DateTime.MinValue;
+ private MemoryValidationData validationData = new MemoryValidationData();
+
+ private struct MemoryValidationData
+ {
+ public bool GameStatesValid;
+ public IntPtr GameStatesAddress;
+ public IntPtr InGameStateAddress;
+ public IntPtr AreaInstanceAddress;
+ public IntPtr IngameDataAddress;
+ public IntPtr EntityListAddress;
+ public IntPtr LocalPlayerAddress;
+ public Dictionary Addresses;
+ }
+
+ private struct AddressInfo
+ {
+ public string Name;
+ public IntPtr Address;
+ public bool IsValid;
+ public bool IsReadable;
+ public string ValueHex;
+ public string Description;
+ }
+
+
+ public override void OnEnable(bool isGameOpened)
+ {
+ LoadSettings();
+ }
+
+
+ public override void OnDisable()
+ {
+ SaveSettings();
+ }
+
+
+ public override void SaveSettings()
+ {
+ try
+ {
+ var settingsPath = Path.Combine(this.DllDirectory, "settings.json");
+ var json = JsonConvert.SerializeObject(this.Settings, Formatting.Indented);
+ File.WriteAllText(settingsPath, json);
+ }
+ catch (Exception ex)
+ {
+ CategorizedLogger.LogError($"[MemoryViewer] Failed to save settings: {ex.Message}");
+ }
+ }
+
+ private void LoadSettings()
+ {
+ try
+ {
+ var settingsPath = Path.Combine(this.DllDirectory, "settings.json");
+ if (File.Exists(settingsPath))
+ {
+ var json = File.ReadAllText(settingsPath);
+ this.Settings = JsonConvert.DeserializeObject(json) ?? new MemoryViewerSettings();
+ }
+ }
+ catch (Exception ex)
+ {
+ CategorizedLogger.LogError($"[MemoryViewer] Failed to load settings: {ex.Message}");
+ this.Settings = new MemoryViewerSettings();
+ }
+ }
+
+
+ public override void DrawSettings()
+ {
+ if (ImGui.CollapsingHeader("Memory Viewer Settings"))
+ {
+ var hexBytes = this.Settings.HexViewBytes;
+ if (ImGui.InputInt("Hex View Bytes", ref hexBytes, 64, 256))
+ {
+ this.Settings.HexViewBytes = Math.Max(64, Math.Min(4096, hexBytes));
+ }
+
+ ImGui.Checkbox("Auto Refresh", ref this.Settings.AutoRefresh);
+
+ if (this.Settings.AutoRefresh)
+ {
+ var interval = this.Settings.RefreshIntervalMs;
+ if (ImGui.InputInt("Refresh Interval (ms)", ref interval, 100, 500))
+ {
+ this.Settings.RefreshIntervalMs = Math.Max(100, Math.Min(10000, interval));
+ }
+ }
+ }
+ }
+
+
+ public override void DrawUI()
+ {
+ if (Core.Process.Handle == null || Core.Process.Handle.IsInvalid || Core.Process.Handle.IsClosed)
+ {
+ return;
+ }
+
+
+ if (this.Settings.AutoRefresh &&
+ (DateTime.Now - lastRefreshTime).TotalMilliseconds >= this.Settings.RefreshIntervalMs)
+ {
+ RefreshMemoryData();
+ lastRefreshTime = DateTime.Now;
+ }
+
+ ImGui.SetNextWindowSize(new Vector2(900, 700), ImGuiCond.FirstUseEver);
+ if (ImGui.Begin("Memory Viewer", ref this.isWindowVisible))
+ {
+ DrawMemoryViewer();
+ ImGui.End();
+ }
+ }
+
+ private void DrawMemoryViewer()
+ {
+ ImGui.TextWrapped("Memory Address Validator - View and validate game memory addresses");
+ ImGui.Separator();
+
+ if (ImGui.Button("Refresh Data"))
+ {
+ RefreshMemoryData();
+ }
+
+ ImGui.SameLine();
+ if (ImGui.Button("Dump to Log"))
+ {
+ DumpToLog();
+ }
+
+ ImGui.Separator();
+
+
+ if (ImGui.CollapsingHeader("GameStates Structure"))
+ {
+ DrawAddressInfo("GameStates", validationData.GameStatesAddress, validationData.GameStatesValid,
+ "Main GameStates pointer - Base offset for all game state access");
+
+ if (validationData.GameStatesValid && validationData.GameStatesAddress != IntPtr.Zero)
+ {
+ DrawOffsetInfo("InGameState Index", 4, "Index in GameStates array");
+ }
+ }
+
+
+ if (ImGui.CollapsingHeader("InGameState Structure"))
+ {
+ DrawAddressInfo("InGameState", validationData.InGameStateAddress, validationData.InGameStateAddress != IntPtr.Zero,
+ "Current InGameState pointer");
+
+ if (validationData.InGameStateAddress != IntPtr.Zero)
+ {
+ DrawOffsetInfo("AreaInstanceData", 0x948, "AreaInstanceData offset");
+ DrawOffsetInfo("IngameData Offset", 0x370, "IngameData structure offset");
+ }
+ }
+
+
+ if (ImGui.CollapsingHeader("IngameData Structure"))
+ {
+ DrawAddressInfo("IngameData", validationData.IngameDataAddress, validationData.IngameDataAddress != IntPtr.Zero,
+ "IngameData structure pointer");
+
+ if (validationData.IngameDataAddress != IntPtr.Zero)
+ {
+ DrawOffsetInfo("Entity List", 0x490, "EntityList pointer offset");
+ DrawOffsetInfo("Entities Count", 0x498, "Entity count offset");
+ DrawOffsetInfo("Local Player", 0x408, "Local player pointer offset");
+ }
+ }
+
+
+ if (ImGui.CollapsingHeader("AreaInstance Structure"))
+ {
+ DrawAddressInfo("AreaInstance", validationData.AreaInstanceAddress, validationData.AreaInstanceAddress != IntPtr.Zero,
+ "AreaInstance structure pointer");
+
+ if (validationData.AreaInstanceAddress != IntPtr.Zero)
+ {
+ DrawOffsetInfo("Local Player Ptr", 0xA10, "Local player pointer");
+ DrawOffsetInfo("Entity List Ptr", 0xB50, "Entity list pointer (was 0x13F8)");
+ DrawOffsetInfo("Terrain List Ptr", 0x12C8, "Terrain/Exits list pointer");
+ DrawOffsetInfo("Terrain Grid Ptr", 0x08, "Terrain grid pointer (was 0x30)");
+ DrawOffsetInfo("Terrain Dimensions", 0x28, "Terrain dimensions pointer");
+ }
+ }
+
+
+ if (ImGui.CollapsingHeader("Entity Structure"))
+ {
+ DrawOffsetInfo("Component List Ptr", 0x10, "ComponentList pointer (inside ItemBase)");
+ }
+
+
+ if (ImGui.CollapsingHeader("Component Offsets"))
+ {
+ DrawOffsetInfo("Component Index Debuffs", 3, "Debuff component index");
+ DrawOffsetInfo("Component Owner Entity", 0x08, "Entity pointer in ComponentHeader");
+ }
+
+
+ if (ImGui.CollapsingHeader("Life Component Offsets"))
+ {
+ DrawOffsetInfo("Health", 0x1A8, "Health VitalStruct offset");
+ DrawOffsetInfo("Mana", 0x1F8, "Mana VitalStruct offset");
+ DrawOffsetInfo("Energy Shield", 0x230, "Energy Shield VitalStruct offset");
+ DrawOffsetInfo("Buffs", 0x58, "Buffs offset");
+ }
+
+
+ if (ImGui.CollapsingHeader("VitalStruct Offsets"))
+ {
+ DrawOffsetInfo("Reserved Flat", 0x10, "Reserved flat value");
+ DrawOffsetInfo("Reserved Percent", 0x14, "Reserved percentage value");
+ DrawOffsetInfo("Total (Max)", 0x2C, "Maximum value");
+ DrawOffsetInfo("Current", 0x30, "Current value");
+ }
+
+
+ if (ImGui.CollapsingHeader("Render/Position Component"))
+ {
+ DrawOffsetInfo("Position X", 0x138, "X coordinate");
+ DrawOffsetInfo("Position Y", 0x13C, "Y coordinate");
+ DrawOffsetInfo("Position Z", 0x140, "Z coordinate");
+ }
+
+
+ if (ImGui.CollapsingHeader("Memory Hex View"))
+ {
+ if (validationData.Addresses != null && validationData.Addresses.Count > 0)
+ {
+ var selectedAddr = validationData.Addresses.First().Key;
+ if (ImGui.BeginCombo("Select Address", selectedAddr))
+ {
+ foreach (var addr in validationData.Addresses.Keys)
+ {
+ bool isSelected = addr == selectedAddr;
+ if (ImGui.Selectable(addr, isSelected))
+ {
+ selectedAddr = addr;
+ }
+ if (isSelected)
+ {
+ ImGui.SetItemDefaultFocus();
+ }
+ }
+ ImGui.EndCombo();
+ }
+
+ if (validationData.Addresses.ContainsKey(selectedAddr))
+ {
+ var addrInfo = validationData.Addresses[selectedAddr];
+ DrawHexView(addrInfo.Address, this.Settings.HexViewBytes);
+ }
+ }
+ }
+ }
+
+ private void DrawAddressInfo(string name, IntPtr address, bool isValid, string description)
+ {
+ var color = isValid && address != IntPtr.Zero ? new Vector4(0, 1, 0, 1) : new Vector4(1, 0, 0, 1);
+ var status = isValid && address != IntPtr.Zero ? "✓ VALID" : "✗ INVALID";
+
+ ImGui.TextColored(color, $"{status} {name}");
+ ImGui.SameLine();
+ ImGui.Text($"0x{address.ToInt64():X}");
+ if (!string.IsNullOrEmpty(description))
+ {
+ ImGui.TextWrapped($" {description}");
+ }
+ }
+
+ private void DrawOffsetInfo(string name, long offset, string description)
+ {
+ ImGui.Text($"{name}: 0x{offset:X}");
+ if (!string.IsNullOrEmpty(description))
+ {
+ ImGui.SameLine();
+ ImGui.TextDisabled($"({description})");
+ }
+ }
+
+ private void DrawHexView(IntPtr address, int byteCount)
+ {
+ if (address == IntPtr.Zero)
+ {
+ ImGui.Text("Invalid address");
+ return;
+ }
+
+ try
+ {
+ var reader = Core.Process.Handle;
+ var bytes = reader.ReadMemoryArray(address, byteCount);
+
+ if (bytes != null && bytes.Length > 0)
+ {
+ ImGui.Text($"Address: 0x{address.ToInt64():X} ({bytes.Length} bytes)");
+ ImGui.Separator();
+
+ ImGui.PushFont(ImGui.GetIO().Fonts.Fonts[1]);
+ var sb = new StringBuilder();
+
+ for (int i = 0; i < bytes.Length; i++)
+ {
+ if (i > 0 && i % 16 == 0)
+ {
+ ImGui.Text(sb.ToString());
+ sb.Clear();
+ }
+ sb.Append($"{bytes[i]:X2} ");
+ }
+ if (sb.Length > 0)
+ {
+ ImGui.Text(sb.ToString());
+ }
+
+ ImGui.PopFont();
+ }
+ }
+ catch (Exception ex)
+ {
+ ImGui.TextColored(new Vector4(1, 0, 0, 1), $"Error reading memory: {ex.Message}");
+ }
+ }
+
+ private void RefreshMemoryData()
+ {
+ validationData = new MemoryValidationData
+ {
+ Addresses = new Dictionary()
+ };
+
+ if (Core.Process.Handle == null || Core.Process.Handle.IsInvalid || Core.Process.Handle.IsClosed)
+ {
+ return;
+ }
+
+ try
+ {
+ var reader = Core.Process.Handle;
+
+
+ if (Core.Process.StaticAddresses.ContainsKey("Game States"))
+ {
+ validationData.GameStatesAddress = Core.Process.StaticAddresses["Game States"];
+ validationData.GameStatesValid = ValidateAddress(validationData.GameStatesAddress);
+
+ if (validationData.GameStatesValid)
+ {
+
+ try
+ {
+ var gameStatesPtr = reader.ReadMemory(validationData.GameStatesAddress);
+ var inGameStatePtr = reader.ReadMemory(new IntPtr(gameStatesPtr.ToInt64() + (4 * 8)));
+
+ if (ValidateAddress(inGameStatePtr))
+ {
+ validationData.InGameStateAddress = inGameStatePtr;
+
+
+ try
+ {
+ var ingameDataPtr = reader.ReadMemory(new IntPtr(inGameStatePtr.ToInt64() + 0x370));
+ if (ValidateAddress(ingameDataPtr))
+ {
+ validationData.IngameDataAddress = ingameDataPtr;
+
+
+ try
+ {
+ var localPlayerPtr = reader.ReadMemory(new IntPtr(ingameDataPtr.ToInt64() + 0x408));
+ if (ValidateAddress(localPlayerPtr))
+ {
+ validationData.LocalPlayerAddress = localPlayerPtr;
+ validationData.Addresses["Local Player"] = new AddressInfo
+ {
+ Name = "Local Player",
+ Address = localPlayerPtr,
+ IsValid = true,
+ IsReadable = true,
+ ValueHex = $"0x{localPlayerPtr.ToInt64():X}",
+ Description = "Local player entity pointer"
+ };
+ }
+ }
+ catch { }
+ }
+ }
+ catch { }
+ }
+ }
+ catch { }
+ }
+ }
+
+
+ if (validationData.GameStatesAddress != IntPtr.Zero)
+ {
+ validationData.Addresses["GameStates"] = new AddressInfo
+ {
+ Name = "GameStates",
+ Address = validationData.GameStatesAddress,
+ IsValid = validationData.GameStatesValid,
+ IsReadable = validationData.GameStatesValid,
+ ValueHex = $"0x{validationData.GameStatesAddress.ToInt64():X}",
+ Description = "GameStates pointer"
+ };
+ }
+
+ if (validationData.InGameStateAddress != IntPtr.Zero)
+ {
+ validationData.Addresses["InGameState"] = new AddressInfo
+ {
+ Name = "InGameState",
+ Address = validationData.InGameStateAddress,
+ IsValid = true,
+ IsReadable = true,
+ ValueHex = $"0x{validationData.InGameStateAddress.ToInt64():X}",
+ Description = "InGameState pointer"
+ };
+ }
+
+ if (validationData.IngameDataAddress != IntPtr.Zero)
+ {
+ validationData.Addresses["IngameData"] = new AddressInfo
+ {
+ Name = "IngameData",
+ Address = validationData.IngameDataAddress,
+ IsValid = true,
+ IsReadable = true,
+ ValueHex = $"0x{validationData.IngameDataAddress.ToInt64():X}",
+ Description = "IngameData pointer"
+ };
+ }
+ }
+ catch (Exception ex)
+ {
+ CategorizedLogger.LogError($"[MemoryViewer] Error refreshing memory data: {ex.Message}");
+ }
+ }
+
+ private bool ValidateAddress(IntPtr address)
+ {
+ if (address == IntPtr.Zero || address.ToInt64() < 0x10000 || address.ToInt64() > 0x7FFFFFFFFFFF)
+ {
+ return false;
+ }
+
+ try
+ {
+ var reader = Core.Process.Handle;
+
+ reader.ReadMemory(address);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private void DumpToLog()
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("=== Memory Viewer Dump ===");
+ sb.AppendLine($"GameStates: 0x{validationData.GameStatesAddress.ToInt64():X} (Valid: {validationData.GameStatesValid})");
+ sb.AppendLine($"InGameState: 0x{validationData.InGameStateAddress.ToInt64():X}");
+ sb.AppendLine($"IngameData: 0x{validationData.IngameDataAddress.ToInt64():X}");
+ sb.AppendLine($"Local Player: 0x{validationData.LocalPlayerAddress.ToInt64():X}");
+ sb.AppendLine();
+ sb.AppendLine("All Addresses:");
+ foreach (var addr in validationData.Addresses.Values)
+ {
+ sb.AppendLine($" {addr.Name}: 0x{addr.Address.ToInt64():X} (Valid: {addr.IsValid}, Readable: {addr.IsReadable})");
+ }
+
+ CategorizedLogger.Log(CategorizedLogger.LogCategory.AddressOffsets, sb.ToString());
+ }
+ }
+}
diff --git a/tools/MemoryViewer/MemoryViewer.csproj b/tools/MemoryViewer/MemoryViewer.csproj
new file mode 100644
index 0000000..c8c01a8
--- /dev/null
+++ b/tools/MemoryViewer/MemoryViewer.csproj
@@ -0,0 +1,29 @@
+
+
+ Library
+ net8.0
+ true
+ true
+
+
+
+ x64
+
+
+
+ x64
+
+
+
+
+
+
+
+ false
+ false
+
+
+
+
+
+
diff --git a/tools/MemoryViewer/MemoryViewerSettings.cs b/tools/MemoryViewer/MemoryViewerSettings.cs
new file mode 100644
index 0000000..da3e300
--- /dev/null
+++ b/tools/MemoryViewer/MemoryViewerSettings.cs
@@ -0,0 +1,23 @@
+
+
+namespace MemoryViewer
+{
+ using GameHelper.Plugin;
+
+
+ public class MemoryViewerSettings : IPSettings
+ {
+
+
+ public bool Enable { get; set; } = true;
+
+
+ public int HexViewBytes { get; set; } = 256;
+
+
+ public bool AutoRefresh { get; set; } = true;
+
+
+ public int RefreshIntervalMs { get; set; } = 1000;
+ }
+}
diff --git a/tools/PatternTool/PatternTool.cs b/tools/PatternTool/PatternTool.cs
new file mode 100644
index 0000000..2bb2b0a
--- /dev/null
+++ b/tools/PatternTool/PatternTool.cs
@@ -0,0 +1,621 @@
+
+
+namespace PatternTool
+{
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Linq;
+ using System.Text;
+ using GameHelper;
+ using GameHelper.Plugin;
+ using GameHelper.Utils;
+ using GameOffsets;
+ using ImGuiNET;
+ using Newtonsoft.Json;
+
+
+ public sealed class PatternTool : PCore
+ {
+ private string _dumpDirectory;
+ private string _customPatternInput = string.Empty;
+ private string _lastScanResult = string.Empty;
+ private readonly List _scanResults = new();
+ private readonly Dictionary _offsetInfo = new();
+
+ private struct PatternScanResult
+ {
+ public string PatternName;
+ public IntPtr Address;
+ public long Offset;
+ public string PatternString;
+ public bool Valid;
+ }
+
+ private struct OffsetInfo
+ {
+ public string Name;
+ public long Offset;
+ public string Description;
+ public IntPtr? Value;
+ }
+
+
+ public override void OnEnable(bool isGameOpened)
+ {
+ _dumpDirectory = Path.Combine(this.DllDirectory, this.Settings.OutputDirectory);
+ Directory.CreateDirectory(_dumpDirectory);
+
+ LoadSettings();
+ InitializeOffsetInfo();
+ }
+
+
+ public override void OnDisable()
+ {
+ SaveSettings();
+ }
+
+
+ public override void DrawSettings()
+ {
+ if (ImGui.CollapsingHeader("Pattern Tool Settings"))
+ {
+ var outputDir = this.Settings.OutputDirectory;
+ if (ImGui.InputText("Output Directory", ref outputDir, 256))
+ {
+ this.Settings.OutputDirectory = outputDir;
+ _dumpDirectory = Path.Combine(this.DllDirectory, this.Settings.OutputDirectory);
+ Directory.CreateDirectory(_dumpDirectory);
+ }
+
+ ImGui.Checkbox("Auto Dump on Area Change", ref this.Settings.AutoDumpOnAreaChange);
+ ImGui.Checkbox("Enable Custom Pattern Scan", ref this.Settings.EnableCustomPatternScan);
+
+ var maxScanMB = this.Settings.MaxScanSizeMB;
+ if (ImGui.InputInt("Max Scan Size (MB)", ref maxScanMB, 10, 100))
+ {
+ if (maxScanMB < 1) maxScanMB = 1;
+ if (maxScanMB > 1000) maxScanMB = 1000;
+ this.Settings.MaxScanSizeMB = maxScanMB;
+ }
+
+ ImGui.Separator();
+
+ if (ImGui.Button("Scan All Known Patterns"))
+ {
+ ScanAllKnownPatterns();
+ }
+
+ if (ImGui.Button("Dump Patterns & Offsets"))
+ {
+ DumpPatternsAndOffsets();
+ }
+ }
+
+ if (ImGui.CollapsingHeader("Custom Pattern Scanner"))
+ {
+ ImGui.TextWrapped("Enter pattern bytes (hex, space-separated, use ?? for wildcards):");
+ ImGui.InputTextMultiline("##PatternInput", ref _customPatternInput, 1024, new System.Numerics.Vector2(-1, 100));
+
+ if (ImGui.Button("Scan Custom Pattern"))
+ {
+ ScanCustomPattern(_customPatternInput);
+ }
+
+ if (!string.IsNullOrEmpty(_lastScanResult))
+ {
+ ImGui.Separator();
+ ImGui.TextWrapped(_lastScanResult);
+ }
+ }
+
+ if (ImGui.CollapsingHeader("Scan Results"))
+ {
+ if (_scanResults.Count > 0)
+ {
+ if (ImGui.BeginTable("ScanResults", 4, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.Resizable))
+ {
+ ImGui.TableSetupColumn("Pattern");
+ ImGui.TableSetupColumn("Address");
+ ImGui.TableSetupColumn("Offset");
+ ImGui.TableSetupColumn("Valid");
+ ImGui.TableHeadersRow();
+
+ foreach (var result in _scanResults)
+ {
+ ImGui.TableNextRow();
+ ImGui.TableNextColumn();
+ ImGui.Text(result.PatternName);
+ ImGui.TableNextColumn();
+ ImGui.Text($"0x{result.Address.ToInt64():X}");
+ ImGui.TableNextColumn();
+ ImGui.Text($"0x{result.Offset:X}");
+ ImGui.TableNextColumn();
+ ImGui.TextColored(result.Valid ? new System.Numerics.Vector4(0, 1, 0, 1) : new System.Numerics.Vector4(1, 0, 0, 1),
+ result.Valid ? "Yes" : "No");
+ }
+
+ ImGui.EndTable();
+ }
+ }
+ else
+ {
+ ImGui.Text("No scan results yet. Click 'Scan All Known Patterns' to start.");
+ }
+ }
+
+ if (ImGui.CollapsingHeader("Offset Reference"))
+ {
+ ImGui.TextWrapped("Known offsets within structures:");
+
+ if (ImGui.BeginTable("OffsetInfo", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.Resizable))
+ {
+ ImGui.TableSetupColumn("Name");
+ ImGui.TableSetupColumn("Offset");
+ ImGui.TableSetupColumn("Description");
+ ImGui.TableHeadersRow();
+
+ foreach (var offset in _offsetInfo.Values.OrderBy(o => o.Name))
+ {
+ ImGui.TableNextRow();
+ ImGui.TableNextColumn();
+ ImGui.Text(offset.Name);
+ ImGui.TableNextColumn();
+ ImGui.Text($"0x{offset.Offset:X}");
+ ImGui.TableNextColumn();
+ ImGui.TextWrapped(offset.Description);
+ }
+
+ ImGui.EndTable();
+ }
+ }
+ }
+
+
+ public override void DrawUI()
+ {
+
+
+ }
+
+
+ public override void SaveSettings()
+ {
+ try
+ {
+ var settingsPath = Path.Combine(this.DllDirectory, "settings.json");
+ var json = JsonConvert.SerializeObject(this.Settings, Formatting.Indented);
+ File.WriteAllText(settingsPath, json);
+ }
+ catch (Exception ex)
+ {
+ CategorizedLogger.LogError($"[PatternTool] Failed to save settings: {ex.Message}", ex);
+ }
+ }
+
+ private void LoadSettings()
+ {
+ try
+ {
+ var settingsPath = Path.Combine(this.DllDirectory, "settings.json");
+ if (File.Exists(settingsPath))
+ {
+ var json = File.ReadAllText(settingsPath);
+ var loaded = JsonConvert.DeserializeObject(json);
+ if (loaded != null)
+ {
+ this.Settings = loaded;
+ _dumpDirectory = Path.Combine(this.DllDirectory, this.Settings.OutputDirectory);
+ Directory.CreateDirectory(_dumpDirectory);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ CategorizedLogger.LogError($"[PatternTool] Failed to load settings: {ex.Message}", ex);
+ }
+ }
+
+ private void InitializeOffsetInfo()
+ {
+
+ _offsetInfo["IN_GAME_STATE_INDEX"] = new OffsetInfo
+ {
+ Name = "IN_GAME_STATE_INDEX",
+ Offset = 4,
+ Description = "Index in GameStates array for InGameState pointer"
+ };
+
+
+ _offsetInfo["AREA_INSTANCE_DATA"] = new OffsetInfo
+ {
+ Name = "AREA_INSTANCE_DATA",
+ Offset = 0x948,
+ Description = "AreaInstanceData pointer offset in InGameState"
+ };
+
+ _offsetInfo["INGAME_DATA_OFFSET"] = new OffsetInfo
+ {
+ Name = "INGAME_DATA_OFFSET",
+ Offset = 0x370,
+ Description = "Offset to IngameData in InGameState"
+ };
+
+
+ _offsetInfo["INGAME_DATA_ENTITY_LIST"] = new OffsetInfo
+ {
+ Name = "INGAME_DATA_ENTITY_LIST",
+ Offset = 0x490,
+ Description = "EntityList pointer offset in IngameData"
+ };
+
+ _offsetInfo["INGAME_DATA_ENTITIES_COUNT"] = new OffsetInfo
+ {
+ Name = "INGAME_DATA_ENTITIES_COUNT",
+ Offset = 0x498,
+ Description = "Entities count offset in IngameData"
+ };
+
+ _offsetInfo["INGAME_DATA_LOCAL_PLAYER"] = new OffsetInfo
+ {
+ Name = "INGAME_DATA_LOCAL_PLAYER",
+ Offset = 0x408,
+ Description = "Local player pointer offset in IngameData (0x408 in source, was 0xA10 in area? verify)"
+ };
+
+
+ _offsetInfo["LOCAL_PLAYER_PTR"] = new OffsetInfo
+ {
+ Name = "LOCAL_PLAYER_PTR",
+ Offset = 0xA10,
+ Description = "Local player pointer offset in AreaInstance"
+ };
+
+ _offsetInfo["ENTITY_LIST_PTR"] = new OffsetInfo
+ {
+ Name = "ENTITY_LIST_PTR",
+ Offset = 0xB50,
+ Description = "EntityList pointer offset in AreaInstance (Confirmed by Graph Fuzzer BFS Tree, was 0x13F8)"
+ };
+
+ _offsetInfo["TERRAIN_LIST_PTR"] = new OffsetInfo
+ {
+ Name = "TERRAIN_LIST_PTR",
+ Offset = 0x12C8,
+ Description = "Terrain/Exits list pointer offset in AreaInstance (Count ~260)"
+ };
+
+ _offsetInfo["TERRAIN_GRID_PTR"] = new OffsetInfo
+ {
+ Name = "TERRAIN_GRID_PTR",
+ Offset = 0x08,
+ Description = "Terrain grid pointer offset (was 0x30)"
+ };
+
+ _offsetInfo["TERRAIN_DIMENSIONS_PTR"] = new OffsetInfo
+ {
+ Name = "TERRAIN_DIMENSIONS_PTR",
+ Offset = 0x28,
+ Description = "Terrain dimensions pointer offset"
+ };
+
+
+ _offsetInfo["COMPONENT_LIST_PTR"] = new OffsetInfo
+ {
+ Name = "COMPONENT_LIST_PTR",
+ Offset = 0x10,
+ Description = "Component list pointer offset inside ItemBase/Entity"
+ };
+
+
+ _offsetInfo["COMPONENT_INDEX_DEBUFFS"] = new OffsetInfo
+ {
+ Name = "COMPONENT_INDEX_DEBUFFS",
+ Offset = 3,
+ Description = "Index in component list for Debuffs component"
+ };
+
+
+ _offsetInfo["LIFE_COMPONENT_HEALTH"] = new OffsetInfo
+ {
+ Name = "LIFE_COMPONENT_HEALTH",
+ Offset = 0x1A8,
+ Description = "Health VitalStruct offset in Life component"
+ };
+
+ _offsetInfo["LIFE_COMPONENT_MANA"] = new OffsetInfo
+ {
+ Name = "LIFE_COMPONENT_MANA",
+ Offset = 0x1F8,
+ Description = "Mana VitalStruct offset in Life component"
+ };
+
+ _offsetInfo["LIFE_COMPONENT_ES"] = new OffsetInfo
+ {
+ Name = "LIFE_COMPONENT_ES",
+ Offset = 0x230,
+ Description = "Energy Shield VitalStruct offset in Life component"
+ };
+
+ _offsetInfo["LIFE_COMPONENT_BUFFS"] = new OffsetInfo
+ {
+ Name = "LIFE_COMPONENT_BUFFS",
+ Offset = 0x58,
+ Description = "Buffs pointer offset in Life component"
+ };
+
+
+ _offsetInfo["DEBUFF_COMPONENT_LIST"] = new OffsetInfo
+ {
+ Name = "DEBUFF_COMPONENT_LIST",
+ Offset = 0x160,
+ Description = "Debuff list pointer offset in Debuff component"
+ };
+
+
+ _offsetInfo["VITAL_RESERVED_FLAT"] = new OffsetInfo
+ {
+ Name = "VITAL_RESERVED_FLAT",
+ Offset = 0x10,
+ Description = "Reserved flat value offset in VitalStruct"
+ };
+
+ _offsetInfo["VITAL_RESERVED_PERCENT"] = new OffsetInfo
+ {
+ Name = "VITAL_RESERVED_PERCENT",
+ Offset = 0x14,
+ Description = "Reserved percent value offset in VitalStruct"
+ };
+
+ _offsetInfo["VITAL_TOTAL"] = new OffsetInfo
+ {
+ Name = "VITAL_TOTAL",
+ Offset = 0x2C,
+ Description = "Max value offset in VitalStruct"
+ };
+
+ _offsetInfo["VITAL_CURRENT"] = new OffsetInfo
+ {
+ Name = "VITAL_CURRENT",
+ Offset = 0x30,
+ Description = "Current value offset in VitalStruct"
+ };
+
+
+ _offsetInfo["POSITION_X"] = new OffsetInfo
+ {
+ Name = "POSITION_X",
+ Offset = 0x138,
+ Description = "X position offset in Render/Position component"
+ };
+
+ _offsetInfo["POSITION_Y"] = new OffsetInfo
+ {
+ Name = "POSITION_Y",
+ Offset = 0x13C,
+ Description = "Y position offset in Render/Position component"
+ };
+
+ _offsetInfo["POSITION_Z"] = new OffsetInfo
+ {
+ Name = "POSITION_Z",
+ Offset = 0x140,
+ Description = "Z position offset in Render/Position component"
+ };
+
+
+ _offsetInfo["COMPONENT_OWNER_ENTITY"] = new OffsetInfo
+ {
+ Name = "COMPONENT_OWNER_ENTITY",
+ Offset = 0x08,
+ Description = "Owner entity pointer offset in Component Header (to check Owner)"
+ };
+ }
+
+ private void ScanAllKnownPatterns()
+ {
+ _scanResults.Clear();
+
+ if (Core.Process.Address == IntPtr.Zero)
+ {
+ _lastScanResult = "ERROR: Process not available";
+ return;
+ }
+
+ try
+ {
+ var baseAddress = Core.Process.Address;
+ var patterns = StaticOffsetsPatterns.Patterns;
+ var staticAddresses = Core.Process.StaticAddresses;
+
+ foreach (var pattern in patterns)
+ {
+ var result = new PatternScanResult
+ {
+ PatternName = pattern.Name,
+ PatternString = ReconstructPatternString(pattern),
+ Valid = false
+ };
+
+ if (staticAddresses.TryGetValue(pattern.Name, out var address) && address != IntPtr.Zero)
+ {
+ result.Address = address;
+ result.Offset = address.ToInt64() - baseAddress.ToInt64();
+ result.Valid = true;
+ }
+
+ _scanResults.Add(result);
+ }
+
+ _lastScanResult = $"Scanned {patterns.Length} patterns. Found {_scanResults.Count(r => r.Valid)} valid addresses.";
+ }
+ catch (Exception ex)
+ {
+ _lastScanResult = $"ERROR: {ex.Message}";
+ CategorizedLogger.LogError($"[PatternTool] Scan error: {ex.Message}", ex);
+ }
+ }
+
+ private void ScanCustomPattern(string patternString)
+ {
+ if (string.IsNullOrWhiteSpace(patternString))
+ {
+ _lastScanResult = "ERROR: Pattern string is empty";
+ return;
+ }
+
+ if (Core.Process.Address == IntPtr.Zero)
+ {
+ _lastScanResult = "ERROR: Process not available";
+ return;
+ }
+
+ try
+ {
+
+ var pattern = new Pattern("Custom", patternString);
+ var baseAddress = Core.Process.Address;
+ var reader = Core.Process.Handle;
+ var scanSize = this.Settings.MaxScanSizeMB * 1024 * 1024;
+
+ var buffer = reader.ReadMemoryArray(baseAddress, scanSize);
+ if (buffer == null || buffer.Length < pattern.Data.Length)
+ {
+ _lastScanResult = "ERROR: Cannot read process memory";
+ return;
+ }
+
+ var matches = new List();
+ for (int i = 0; i <= buffer.Length - pattern.Data.Length; i++)
+ {
+ bool match = true;
+ for (int j = 0; j < pattern.Data.Length; j++)
+ {
+ if (pattern.Mask[j] && buffer[i + j] != pattern.Data[j])
+ {
+ match = false;
+ break;
+ }
+ }
+
+ if (match)
+ {
+ matches.Add(new IntPtr(baseAddress.ToInt64() + i));
+ }
+ }
+
+ _lastScanResult = $"Found {matches.Count} matches for pattern '{patternString}'";
+ if (matches.Count > 0 && matches.Count <= 10)
+ {
+ _lastScanResult += "\nMatches:\n";
+ foreach (var match in matches)
+ {
+ var offset = match.ToInt64() - baseAddress.ToInt64();
+ _lastScanResult += $" 0x{match.ToInt64():X} (offset: 0x{offset:X})\n";
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _lastScanResult = $"ERROR: {ex.Message}";
+ CategorizedLogger.LogError($"[PatternTool] Custom scan error: {ex.Message}", ex);
+ }
+ }
+
+ private void DumpPatternsAndOffsets()
+ {
+ try
+ {
+ var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ var filename = Path.Combine(_dumpDirectory, $"pattern_tool_dump_{timestamp}.txt");
+ var sb = new StringBuilder();
+
+ var baseAddress = Core.Process.Address;
+ var staticAddresses = Core.Process.StaticAddresses;
+
+ sb.AppendLine("=".PadRight(80, '='));
+ sb.AppendLine("PATTERN TOOL DUMP - Patterns & Offsets");
+ sb.AppendLine($"Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
+ sb.AppendLine($"Base Address: 0x{baseAddress.ToInt64():X}");
+ sb.AppendLine("=".PadRight(80, '='));
+ sb.AppendLine();
+
+
+ sb.AppendLine("KNOWN PATTERNS:");
+ sb.AppendLine("-".PadRight(80, '-'));
+ foreach (var pattern in StaticOffsetsPatterns.Patterns)
+ {
+ var patternString = ReconstructPatternString(pattern);
+ var found = staticAddresses.TryGetValue(pattern.Name, out var address);
+
+ sb.AppendLine($"Pattern: {pattern.Name}");
+ sb.AppendLine($" Pattern String: {patternString}");
+ sb.AppendLine($" Bytes To Skip: {pattern.BytesToSkip}");
+ if (found && address != IntPtr.Zero)
+ {
+ var offset = address.ToInt64() - baseAddress.ToInt64();
+ sb.AppendLine($" Address: 0x{address.ToInt64():X}");
+ sb.AppendLine($" Offset: 0x{offset:X} ({offset})");
+ sb.AppendLine($" IDA: Press G -> {offset:X}");
+ }
+ else
+ {
+ sb.AppendLine($" Address: NOT FOUND");
+ }
+ sb.AppendLine();
+ }
+
+
+ sb.AppendLine();
+ sb.AppendLine("=".PadRight(80, '='));
+ sb.AppendLine("KNOWN OFFSETS:");
+ sb.AppendLine("-".PadRight(80, '-'));
+ foreach (var offset in _offsetInfo.Values.OrderBy(o => o.Name))
+ {
+ sb.AppendLine($"{offset.Name}:");
+ sb.AppendLine($" Offset: 0x{offset.Offset:X} ({offset.Offset})");
+ sb.AppendLine($" Description: {offset.Description}");
+ sb.AppendLine();
+ }
+
+ File.WriteAllText(filename, sb.ToString());
+ _lastScanResult = $"SUCCESS: Dumped to {filename}";
+ }
+ catch (Exception ex)
+ {
+ _lastScanResult = $"ERROR: {ex.Message}";
+ CategorizedLogger.LogError($"[PatternTool] Dump error: {ex.Message}", ex);
+ }
+ }
+
+ private static string ReconstructPatternString(Pattern pattern)
+ {
+ try
+ {
+ var parts = new List();
+ for (int i = 0; i < pattern.Data.Length && i < pattern.Mask.Length; i++)
+ {
+ if (pattern.BytesToSkip >= 0 && i == pattern.BytesToSkip)
+ {
+ parts.Add("^");
+ }
+
+ if (pattern.Mask[i])
+ {
+ parts.Add($"{pattern.Data[i]:X2}");
+ }
+ else
+ {
+ parts.Add("??");
+ }
+ }
+ return string.Join(" ", parts);
+ }
+ catch
+ {
+ return "N/A";
+ }
+ }
+ }
+}
diff --git a/tools/PatternTool/PatternTool.csproj b/tools/PatternTool/PatternTool.csproj
new file mode 100644
index 0000000..cc1e505
--- /dev/null
+++ b/tools/PatternTool/PatternTool.csproj
@@ -0,0 +1,32 @@
+
+
+
+ Library
+ net8.0
+ true
+
+
+
+ x64
+
+
+
+ x64
+
+
+
+
+
+
+
+
+
+ false
+ false
+
+
+
+
+
+
+
diff --git a/tools/PatternTool/PatternToolSettings.cs b/tools/PatternTool/PatternToolSettings.cs
new file mode 100644
index 0000000..fc5f5ad
--- /dev/null
+++ b/tools/PatternTool/PatternToolSettings.cs
@@ -0,0 +1,24 @@
+
+
+namespace PatternTool
+{
+ using System;
+ using GameHelper.Plugin;
+
+
+ public class PatternToolSettings : IPSettings
+ {
+
+
+ public string OutputDirectory { get; set; } = "pattern_dumps";
+
+
+ public bool AutoDumpOnAreaChange { get; set; } = false;
+
+
+ public bool EnableCustomPatternScan { get; set; } = true;
+
+
+ public int MaxScanSizeMB { get; set; } = 100;
+ }
+}
diff --git a/tools/skillbar_scanner.py b/tools/skillbar_scanner.py
new file mode 100644
index 0000000..fb633f2
--- /dev/null
+++ b/tools/skillbar_scanner.py
@@ -0,0 +1,791 @@
+#!/usr/bin/env python3
+"""
+SkillBar Discovery Tool — finds the SkillBarIds offset within PlayerServerData.
+
+ExileCore uses ServerData.SkillBarIds: a Buffer13<(ushort Id, ushort Id2)> —
+13 skill bar slots, each a pair of ushort values (4 bytes per slot, 52 bytes total).
+
+This script:
+1. Attaches to the game process via pymem
+2. Follows the pointer chain to read Actor component's ActiveSkills
+3. Extracts (Id, Id2) ushort pairs from each skill's UnknownIdAndEquipmentInfo
+4. Reads PSD memory and scans for a 52-byte region containing those IDs
+5. Reports the PSD offset and dumps the skill bar mapping
+
+Usage:
+ pip install pymem
+ python tools/skillbar_scanner.py
+"""
+
+import json
+import struct
+import sys
+from pathlib import Path
+
+try:
+ import pymem
+ import pymem.process
+except ImportError:
+ print("ERROR: pymem not installed. Run: pip install pymem")
+ sys.exit(1)
+
+
+def load_offsets() -> dict:
+ """Load offsets.json from project root."""
+ p = Path(__file__).resolve().parent.parent / "offsets.json"
+ if not p.exists():
+ print(f"ERROR: offsets.json not found at {p}")
+ sys.exit(1)
+ with open(p) as f:
+ data = json.load(f)
+ # Convert hex strings to int
+ result = {}
+ for k, v in data.items():
+ if isinstance(v, str) and v.startswith("0x"):
+ result[k] = int(v, 16)
+ elif isinstance(v, (int, float)):
+ result[k] = int(v)
+ else:
+ result[k] = v
+ return result
+
+
+def read_ptr(pm: pymem.Pymem, addr: int) -> int:
+ """Read a 64-bit pointer. Returns 0 on failure."""
+ try:
+ return struct.unpack(" int:
+ try:
+ return struct.unpack(" int:
+ try:
+ return struct.unpack(" bool:
+ """Check if address looks like a valid 64-bit heap pointer."""
+ if addr == 0:
+ return False
+ high = (addr >> 32) & 0xFFFFFFFF
+ return 0 < high < 0x7FFF
+
+
+def read_std_vector(pm: pymem.Pymem, vec_addr: int) -> tuple[int, int]:
+ """Read begin/end pointers of an MSVC std::vector."""
+ begin = read_ptr(pm, vec_addr)
+ end = read_ptr(pm, vec_addr + 8)
+ return begin, end
+
+
+def read_ascii_string(pm: pymem.Pymem, addr: int, max_len: int = 64) -> str | None:
+ """Read a null-terminated ASCII string."""
+ try:
+ data = pm.read_bytes(addr, max_len)
+ null_idx = data.index(0)
+ return data[:null_idx].decode("ascii", errors="replace")
+ except Exception:
+ return None
+
+
+def find_component_address(pm: pymem.Pymem, entity_ptr: int, component_name: str, offsets: dict,
+ verbose: bool = False) -> int:
+ """
+ Find a named component's address via the ComponentLookup system.
+ Entity+0x08 → EntityDetails → +0x28 → ComponentLookup → +0x28 → Vec2 (name entries)
+ Each entry: { char* name (8), int32 index (4), int32 flags (4) } = 16 bytes
+ Then: Entity+0x10 → component pointer list → [index] → component address
+ """
+ details_ptr = read_ptr(pm, entity_ptr + offsets["EntityDetailsOffset"])
+ if verbose:
+ print(f" Entity+0x{offsets['EntityDetailsOffset']:X} → EntityDetails: 0x{details_ptr:X}")
+ if not is_valid_ptr(details_ptr):
+ if verbose:
+ print(" ERROR: Invalid EntityDetails pointer")
+ return 0
+
+ lookup_ptr = read_ptr(pm, details_ptr + offsets["ComponentLookupOffset"])
+ if verbose:
+ print(f" EntityDetails+0x{offsets['ComponentLookupOffset']:X} → ComponentLookup: 0x{lookup_ptr:X}")
+ if not is_valid_ptr(lookup_ptr):
+ if verbose:
+ print(" ERROR: Invalid ComponentLookup pointer")
+ return 0
+
+ vec_begin, vec_end = read_std_vector(pm, lookup_ptr + offsets["ComponentLookupVec2Offset"])
+ if verbose:
+ print(f" ComponentLookup+0x{offsets['ComponentLookupVec2Offset']:X} → Vec2: 0x{vec_begin:X}..0x{vec_end:X}")
+ if not is_valid_ptr(vec_begin) or vec_end <= vec_begin:
+ if verbose:
+ print(" ERROR: Invalid Vec2 range")
+ return 0
+
+ entry_size = offsets["ComponentLookupEntrySize"] # 16
+ total = vec_end - vec_begin
+ count = total // entry_size
+ if verbose:
+ print(f" Entries: {count} (entry_size={entry_size})")
+ if count <= 0 or count > 128:
+ if verbose:
+ print(f" ERROR: Bad entry count: {count}")
+ return 0
+
+ # Read all entries at once
+ try:
+ data = pm.read_bytes(vec_begin, total)
+ except Exception:
+ return 0
+
+ target_index = -1
+ found_names = []
+ for i in range(count):
+ off = i * entry_size
+ name_ptr = struct.unpack(" dict:
+ """
+ Follow the full pointer chain from module base to all needed addresses.
+ Returns dict with AreaInstance, ServerData, PSD, LocalPlayer, etc.
+ """
+ result = {}
+
+ # Find module base
+ module = pymem.process.module_from_name(pm.process_handle, offsets["ProcessName"] + ".exe")
+ if module is None:
+ print("ERROR: Could not find game module")
+ return result
+ module_base = module.lpBaseOfDll
+ module_size = module.SizeOfImage
+ print(f"Module base: 0x{module_base:X} size: 0x{module_size:X}")
+
+ # Pattern scan for GameState base
+ pattern = offsets.get("GameStatePattern", "")
+ game_state_base = 0
+
+ if pattern and "^" in pattern:
+ # Parse pattern: bytes before ^ are prefix, ^ marks the RIP displacement position,
+ # bytes after the 4 ?? wildcards are suffix (must also match)
+ parts = pattern.split()
+ caret_idx = parts.index("^")
+
+ # Build prefix (before ^)
+ prefix_bytes = []
+ prefix_mask = []
+ for p in parts[:caret_idx]:
+ if p == "??":
+ prefix_bytes.append(0)
+ prefix_mask.append(False)
+ else:
+ prefix_bytes.append(int(p, 16))
+ prefix_mask.append(True)
+ prefix_len = len(prefix_bytes)
+
+ # Count wildcards after ^ (the displacement bytes)
+ disp_wildcards = 0
+ suffix_start = caret_idx + 1
+ while suffix_start < len(parts) and parts[suffix_start] == "??":
+ disp_wildcards += 1
+ suffix_start += 1
+ if disp_wildcards == 0:
+ disp_wildcards = 4 # default: 4-byte RIP displacement
+
+ # Build suffix (after the wildcards)
+ suffix_bytes = []
+ suffix_mask = []
+ for p in parts[suffix_start:]:
+ if p == "??":
+ suffix_bytes.append(0)
+ suffix_mask.append(False)
+ else:
+ suffix_bytes.append(int(p, 16))
+ suffix_mask.append(True)
+ suffix_len = len(suffix_bytes)
+
+ total_pattern_len = prefix_len + disp_wildcards + suffix_len
+
+ # Scan module memory in chunks
+ CHUNK = 0x100000
+ for chunk_off in range(0, module_size, CHUNK):
+ try:
+ chunk_size = min(CHUNK + 256, module_size - chunk_off)
+ if chunk_size <= total_pattern_len:
+ continue
+ data = pm.read_bytes(module_base + chunk_off, chunk_size)
+ except Exception:
+ continue
+
+ for i in range(len(data) - total_pattern_len):
+ # Match prefix
+ match = True
+ for j in range(prefix_len):
+ if prefix_mask[j] and data[i + j] != prefix_bytes[j]:
+ match = False
+ break
+ if not match:
+ continue
+
+ # Match suffix (after displacement bytes)
+ suffix_off = i + prefix_len + disp_wildcards
+ for j in range(suffix_len):
+ if suffix_mask[j] and data[suffix_off + j] != suffix_bytes[j]:
+ match = False
+ break
+ if not match:
+ continue
+
+ # Read RIP-relative displacement at caret position
+ disp_offset = i + prefix_len
+ disp = struct.unpack(" 0:
+ game_state_base = module_base + gso
+ print(f"GameState base (manual): 0x{game_state_base:X}")
+ else:
+ print("ERROR: Could not find GameState base via pattern or manual offset")
+ return result
+
+ print(f"GameState base: 0x{game_state_base:X}")
+ result["GameStateBase"] = game_state_base
+
+ # GameState → ReadPointer → Controller
+ controller = read_ptr(pm, game_state_base)
+ if not is_valid_ptr(controller):
+ print("ERROR: Invalid controller pointer")
+ return result
+ print(f"Controller: 0x{controller:X}")
+ result["Controller"] = controller
+
+ # Controller → InGameState (direct offset)
+ igs_off = offsets.get("InGameStateDirectOffset", 0x210)
+ in_game_state = read_ptr(pm, controller + igs_off)
+ print(f" Controller+0x{igs_off:X} → raw=0x{in_game_state:X}")
+ if not is_valid_ptr(in_game_state):
+ # Try inline state array fallback: controller + StatesBeginOffset + InGameStateIndex * StateStride
+ states_begin = offsets.get("StatesBeginOffset", 0x48)
+ igs_index = offsets.get("InGameStateIndex", 4)
+ stride = offsets.get("StateStride", 0x10)
+ state_ptr_off = offsets.get("StatePointerOffset", 0)
+ inline_off = states_begin + igs_index * stride + state_ptr_off
+ in_game_state = read_ptr(pm, controller + inline_off)
+ print(f" Fallback: Controller+0x{inline_off:X} → raw=0x{in_game_state:X}")
+ if not is_valid_ptr(in_game_state):
+ print("ERROR: Invalid InGameState pointer (tried direct + inline fallback)")
+ print(" Make sure you are logged in and in-game (not at login screen)")
+ return result
+ print(f"InGameState: 0x{in_game_state:X}")
+ result["InGameState"] = in_game_state
+
+ # InGameState → AreaInstance
+ area_instance = read_ptr(pm, in_game_state + offsets["IngameDataFromStateOffset"])
+ if not is_valid_ptr(area_instance):
+ print("ERROR: Invalid AreaInstance pointer")
+ return result
+ print(f"AreaInstance: 0x{area_instance:X}")
+ result["AreaInstance"] = area_instance
+
+ # AreaInstance → ServerData
+ server_data = read_ptr(pm, area_instance + offsets["ServerDataOffset"])
+ if not is_valid_ptr(server_data):
+ print("ERROR: Invalid ServerData pointer")
+ return result
+ print(f"ServerData: 0x{server_data:X}")
+ result["ServerData"] = server_data
+
+ # ServerData → PlayerServerData vector → PSD[0]
+ psd_vec_begin, psd_vec_end = read_std_vector(pm, server_data + offsets["PlayerServerDataOffset"])
+ if not is_valid_ptr(psd_vec_begin):
+ print("ERROR: Invalid PSD vector")
+ return result
+ psd_ptr = read_ptr(pm, psd_vec_begin)
+ if not is_valid_ptr(psd_ptr):
+ print("ERROR: Invalid PSD[0] pointer")
+ return result
+ print(f"PSD[0]: 0x{psd_ptr:X}")
+ result["PSD"] = psd_ptr
+
+ # AreaInstance → LocalPlayer entity
+ # Try direct offset first, then scan nearby offsets to auto-discover
+ local_player = 0
+ lp_direct_off = offsets["LocalPlayerDirectOffset"]
+ lp_candidate = read_ptr(pm, area_instance + lp_direct_off)
+ print(f" AreaInstance+0x{lp_direct_off:X} → 0x{lp_candidate:X}")
+
+ # Validate: a real entity has vtable in module range at +0x00 and a heap pointer at +0x08
+ def is_entity(addr: int) -> bool:
+ if not is_valid_ptr(addr):
+ return False
+ vtable = read_ptr(pm, addr)
+ details = read_ptr(pm, addr + 8)
+ return module_base <= vtable < module_base + module_size and is_valid_ptr(details)
+
+ if is_entity(lp_candidate):
+ local_player = lp_candidate
+ else:
+ # Maybe it's an intermediate struct — try deref + small offsets
+ for sub_off in [0x00, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30]:
+ candidate = read_ptr(pm, lp_candidate + sub_off) if is_valid_ptr(lp_candidate) else 0
+ if is_entity(candidate):
+ print(f" Found entity via deref+0x{sub_off:X}: 0x{candidate:X}")
+ local_player = candidate
+ break
+
+ if local_player == 0:
+ # Scan AreaInstance offsets near 0xA10 for any entity pointer
+ print(" Scanning AreaInstance offsets 0xA00..0xA30 for entity pointer...")
+ for scan_off in range(0xA00, 0xA30, 8):
+ candidate = read_ptr(pm, area_instance + scan_off)
+ if is_entity(candidate):
+ print(f" Found entity at AreaInstance+0x{scan_off:X}: 0x{candidate:X}")
+ local_player = candidate
+ break
+
+ if local_player == 0 and is_valid_ptr(server_data):
+ # Fallback: ServerData + LocalPlayerOffset
+ lp_sd = read_ptr(pm, server_data + offsets.get("LocalPlayerOffset", 0x20))
+ if is_entity(lp_sd):
+ local_player = lp_sd
+ print(f" Found entity via ServerData+0x{offsets.get('LocalPlayerOffset', 0x20):X}: 0x{local_player:X}")
+
+ if local_player == 0:
+ # Dump first 0x20 bytes at the direct candidate for debugging
+ if is_valid_ptr(lp_candidate):
+ try:
+ raw = pm.read_bytes(lp_candidate, 0x40)
+ print(f" DEBUG: Raw bytes at 0x{lp_candidate:X}:")
+ for row in range(0, 0x40, 16):
+ hex_str = " ".join(f"{b:02X}" for b in raw[row:row + 16])
+ ptrs = " ".join(f"0x{struct.unpack(' list[tuple[int, int, int]]:
+ """
+ Read ActiveSkills vector from Actor component.
+ Returns list of (raw_uint32, id_lo16, id_hi16) tuples.
+ """
+ ACTIVE_SKILLS_OFFSET = 0xB00
+ vec_begin, vec_end = read_std_vector(pm, actor_comp + ACTIVE_SKILLS_OFFSET)
+ if not is_valid_ptr(vec_begin) or vec_end <= vec_begin:
+ print(" No active skills found")
+ return []
+
+ total = vec_end - vec_begin
+ entry_size = 0x10 # ActiveSkillEntry: 2 pointers
+ count = total // entry_size
+ if count <= 0 or count > 128:
+ print(f" Bad skill count: {count}")
+ return []
+
+ try:
+ data = pm.read_bytes(vec_begin, total)
+ except Exception:
+ return []
+
+ skills = []
+ for i in range(count):
+ # Follow ptr1 (ActiveSkillPtr)
+ active_skill_ptr = struct.unpack("> 16) & 0xFFFF
+
+ # Also try to read the skill name for display
+ skills.append((uid, id_lo, id_hi))
+
+ return skills
+
+
+def read_wchar_string(pm: pymem.Pymem, addr: int, max_bytes: int = 128) -> str | None:
+ """Read null-terminated wchar_t (UTF-16LE) string."""
+ if not is_valid_ptr(addr):
+ return None
+ try:
+ raw = pm.read_bytes(addr, max_bytes)
+ chars = []
+ for j in range(0, len(raw) - 1, 2):
+ c = struct.unpack(" 0xFFFF:
+ return None
+ chars.append(chr(c))
+ name = "".join(chars)
+ return name if name and len(name) > 1 and all(32 <= ord(c) < 127 for c in name) else None
+ except Exception:
+ return None
+
+
+def resolve_skill_name(pm: pymem.Pymem, active_skill_ptr: int) -> str | None:
+ """
+ Try to resolve a skill name via multiple paths:
+ 1. ActiveSkillsDatPtr (+0x20) → wchar* (direct dat row string)
+ 2. ActiveSkillsDatPtr (+0x20) → ptr → wchar* (indirect via pointer in dat row)
+ 3. GEPL FK chain: +0x18 → GEPL row → +0x00 FK → GE row → +0x00 → wchar*
+ """
+ # Path 1: Direct wchar* at +0x20
+ dat_ptr = read_ptr(pm, active_skill_ptr + 0x20)
+ if is_valid_ptr(dat_ptr):
+ name = read_wchar_string(pm, dat_ptr)
+ if name:
+ return name
+ # Path 2: Indirect — dat_ptr is a dat row, first field is a pointer to wchar*
+ str_ptr = read_ptr(pm, dat_ptr)
+ if is_valid_ptr(str_ptr):
+ name = read_wchar_string(pm, str_ptr)
+ if name:
+ return name
+
+ # Path 3: GEPL FK chain
+ gepl_ptr = read_ptr(pm, active_skill_ptr + 0x18)
+ if is_valid_ptr(gepl_ptr):
+ ge_fk = read_ptr(pm, gepl_ptr)
+ if is_valid_ptr(ge_fk):
+ # GE+0x00 → ActiveSkills.dat row → wchar*
+ as_dat = read_ptr(pm, ge_fk)
+ if is_valid_ptr(as_dat):
+ name = read_wchar_string(pm, as_dat)
+ if name:
+ return name
+ # Try indirect
+ str_ptr = read_ptr(pm, as_dat)
+ if is_valid_ptr(str_ptr):
+ name = read_wchar_string(pm, str_ptr)
+ if name:
+ return name
+
+ return None
+
+
+def read_active_skills_with_names(pm: pymem.Pymem, actor_comp: int) -> list[dict]:
+ """Read ActiveSkills with name resolution. Returns list of skill dicts."""
+ ACTIVE_SKILLS_OFFSET = 0xB00
+ vec_begin, vec_end = read_std_vector(pm, actor_comp + ACTIVE_SKILLS_OFFSET)
+ if not is_valid_ptr(vec_begin) or vec_end <= vec_begin:
+ return []
+
+ total = vec_end - vec_begin
+ entry_size = 0x10
+ count = total // entry_size
+ if count <= 0 or count > 128:
+ return []
+
+ try:
+ data = pm.read_bytes(vec_begin, total)
+ except Exception:
+ return []
+
+ skills = []
+ seen = set()
+ for i in range(count):
+ asp = struct.unpack("> 16) & 0xFFFF
+ name = resolve_skill_name(pm, asp)
+
+ skills.append({
+ "raw": uid,
+ "id": id_lo,
+ "id2": id_hi,
+ "name": name,
+ "ptr": asp,
+ })
+
+ return skills
+
+
+def find_uint32_occurrences(psd_data: bytes, target_values: set[int]) -> list[tuple[int, int]]:
+ """Find all 4-byte-aligned occurrences of any target uint32 in PSD data.
+ Returns list of (offset, value)."""
+ hits = []
+ for off in range(0, len(psd_data) - 3, 4):
+ val = struct.unpack(" list[tuple[int, int, list]]:
+ """
+ Scan PSD memory for a Buffer13<(ushort Id, ushort Id2)> pattern.
+ 13 slots × 4 bytes = 52 bytes. We look for 4-byte-aligned offsets where
+ reading 13 × (ushort, ushort) produces overlap with known skill IDs.
+
+ Returns list of (offset, overlap_count, slot_data) sorted by overlap desc.
+ """
+ SLOTS = 13
+ ENTRY_SIZE = 4 # 2 × ushort
+ BUFFER_SIZE = SLOTS * ENTRY_SIZE # 52 bytes
+
+ candidates = []
+
+ for off in range(0, len(psd_data) - BUFFER_SIZE, 4): # 4-byte aligned
+ slots = []
+ non_zero = 0
+ for s in range(SLOTS):
+ entry_off = off + s * ENTRY_SIZE
+ id_lo = struct.unpack("= 2:
+ overlap = raw_overlap
+
+ if overlap >= 2:
+ candidates.append((off, overlap, slots))
+
+ # Sort by overlap descending
+ candidates.sort(key=lambda x: -x[1])
+ return candidates
+
+
+def main():
+ offsets = load_offsets()
+ proc_name = offsets.get("ProcessName", "PathOfExileSteam")
+ print(f"Attaching to {proc_name}...")
+
+ try:
+ pm = pymem.Pymem(proc_name + ".exe")
+ except pymem.exception.ProcessNotFound:
+ # Try without .exe
+ try:
+ pm = pymem.Pymem(proc_name)
+ except Exception as e:
+ print(f"ERROR: Could not attach to process: {e}")
+ sys.exit(1)
+
+ print(f"Attached to PID {pm.process_id}")
+ print()
+
+ # Resolve the full pointer chain
+ print("=== Resolving pointer chain ===")
+ addrs = resolve_pointer_chain(pm, offsets)
+ if "PSD" not in addrs or "LocalPlayer" not in addrs:
+ print("\nFATAL: Could not resolve required addresses")
+ pm.close_process()
+ sys.exit(1)
+
+ print()
+
+ # Find Actor component via ComponentLookup
+ print("=== Finding Actor component ===")
+ actor_comp = find_component_address(pm, addrs["LocalPlayer"], "Actor", offsets, verbose=True)
+ if actor_comp == 0:
+ print("ERROR: Could not find Actor component on local player")
+ pm.close_process()
+ sys.exit(1)
+ print(f"Actor component: 0x{actor_comp:X}")
+ print()
+
+ # Read active skills
+ print("=== Reading ActiveSkills ===")
+ skills = read_active_skills_with_names(pm, actor_comp)
+ if not skills:
+ print("ERROR: No active skills found. Make sure you have skills equipped.")
+ pm.close_process()
+ sys.exit(1)
+
+ print(f"Found {len(skills)} unique skills:")
+ known_ids = set()
+ for s in skills:
+ known_ids.add((s["id"], s["id2"]))
+ name_str = s["name"] or "???"
+ print(f" Id={s['id']:5d} Id2={s['id2']:5d} Raw=0x{s['raw']:08X} {name_str}")
+
+ print()
+
+ # Scan PSD for SkillBarIds
+ print("=== Scanning PSD for SkillBarIds ===")
+ SCAN_SIZE = 0x10000 # 64KB — PSD can be very large
+ print(f"Reading 0x{SCAN_SIZE:X} bytes from PSD at 0x{addrs['PSD']:X}")
+
+ try:
+ psd_data = pm.read_bytes(addrs["PSD"], SCAN_SIZE)
+ except Exception as e:
+ print(f"ERROR reading PSD: {e}")
+ pm.close_process()
+ sys.exit(1)
+ print(f"Read {len(psd_data)} bytes OK")
+
+ # Build sets for matching
+ known_raw = set()
+ for s in skills:
+ known_raw.add(s["raw"])
+
+ # Step 1: Find individual uint32 occurrences in PSD (needle search)
+ print(f"\nStep 1: Searching for {len(known_raw)} known uint32 skill IDs in PSD...")
+ hits = find_uint32_occurrences(psd_data, known_raw)
+ if hits:
+ print(f" Found {len(hits)} individual hits:")
+ id_to_name_raw = {s["raw"]: s["name"] or f"0x{s['raw']:08X}" for s in skills}
+ for off, val in hits[:30]:
+ name = id_to_name_raw.get(val, "?")
+ print(f" PSD+0x{off:X}: 0x{val:08X} ({name})")
+ else:
+ print(" No individual raw uint32 hits found in PSD!")
+ # Also try just the lo16 values
+ known_lo16 = {s["id"] for s in skills if s["id"] > 0}
+ lo16_hits = []
+ for off in range(0, len(psd_data) - 1, 2):
+ val = struct.unpack(" pattern (13 × 4 = 52 bytes)...")
+ candidates = scan_psd_for_skillbar(psd_data, known_ids, known_raw)
+
+ if not candidates:
+ print("No Buffer13 candidates found.")
+ # If individual hits exist, suggest they might use a different buffer layout
+ if hits:
+ print("\nIndividual hits exist — checking for clustered groups near those offsets...")
+ # Check for any 3+ hits within a 52-byte window
+ for i, (base_off, _) in enumerate(hits):
+ window_start = max(0, base_off - 52)
+ window_end = min(len(psd_data), base_off + 52)
+ nearby = [(o, v) for o, v in hits if window_start <= o < window_end]
+ if len(nearby) >= 3:
+ print(f" Cluster at PSD+0x{window_start:X}..0x{window_end:X}: {len(nearby)} hits")
+ for o, v in nearby:
+ print(f" +0x{o:X}: 0x{v:08X}")
+ pm.close_process()
+ sys.exit(1)
+
+ # Display top results
+ SLOT_LABELS = [
+ "LMB", "RMB", "MMB", "Q", "E", "R", "T", "F",
+ "Slot8", "Slot9", "Slot10", "Slot11", "Slot12"
+ ]
+
+ print(f"Found {len(candidates)} candidate offsets (top 10):")
+ print()
+
+ # Build reverse lookup: (id, id2) → name
+ id_to_name = {}
+ for s in skills:
+ id_to_name[(s["id"], s["id2"])] = s["name"] or f"skill_{s['raw']:08X}"
+
+ for rank, (off, overlap, slots) in enumerate(candidates[:10]):
+ print(f" #{rank + 1} PSD+0x{off:X} (overlap: {overlap}/{len(known_ids)} known skills)")
+ for i, (id_lo, id_hi) in enumerate(slots):
+ label = SLOT_LABELS[i] if i < len(SLOT_LABELS) else f"Slot{i}"
+ if id_lo == 0 and id_hi == 0:
+ print(f" [{label:>6}] (empty)")
+ else:
+ match = (id_lo, id_hi) in known_ids
+ name = id_to_name.get((id_lo, id_hi), "")
+ marker = " ✓ MATCH" if match else ""
+ name_str = f" ({name})" if name else ""
+ print(f" [{label:>6}] Id={id_lo:5d} Id2={id_hi:5d}{name_str}{marker}")
+ print()
+
+ # Highlight the best result
+ best_off, best_overlap, best_slots = candidates[0]
+ print("=" * 60)
+ print(f"BEST MATCH: PSD+0x{best_off:X}")
+ print(f" Overlap: {best_overlap}/{len(known_ids)} known skills")
+ print(f" Add to offsets.json: \"SkillBarIdsOffset\": \"0x{best_off:X}\"")
+ print("=" * 60)
+
+ pm.close_process()
+
+
+if __name__ == "__main__":
+ main()