skills working somewhat

This commit is contained in:
Boki 2026-03-04 15:36:20 -05:00
parent a8c43ba7e2
commit 8a0e4bb481
22 changed files with 4227 additions and 161 deletions

View file

@ -2,62 +2,97 @@
"ProcessName": "PathOfExileSteam", "ProcessName": "PathOfExileSteam",
"GameStatePattern": "48 83 EC ?? 48 8B F1 33 ED 48 39 2D ^", "GameStatePattern": "48 83 EC ?? 48 8B F1 33 ED 48 39 2D ^",
"GameStateGlobalOffset": 0, "GameStateGlobalOffset": 0,
"PatternResultAdjust": 24, "PatternResultAdjust": "0x18",
"StatesBeginOffset": 72, "StatesBeginOffset": "0x48",
"StateStride": 16, "StateStride": "0x10",
"StatePointerOffset": 0, "StatePointerOffset": 0,
"StateCount": 12, "StateCount": 12,
"InGameStateIndex": 4, "InGameStateIndex": 4,
"ActiveStatesOffset": 32, "ActiveStatesOffset": "0x20",
"StatesInline": true, "StatesInline": true,
"InGameStateDirectOffset": 528, "InGameStateDirectOffset": "0x210",
"IsLoadingOffset": 832, "IsLoadingOffset": "0x340",
"EscapeStateOffset": 524, "EscapeStateOffset": "0x20C",
"IngameDataFromStateOffset": 656, "IngameDataFromStateOffset": "0x290",
"WorldDataFromStateOffset": 760, "WorldDataFromStateOffset": "0x2F8",
"AreaLevelOffset": 196, "AreaLevelOffset": "0xC4",
"AreaLevelIsByte": true, "AreaLevelIsByte": true,
"AreaLevelStaticOffset": 0, "AreaLevelStaticOffset": 0,
"AreaHashOffset": 236, "AreaHashOffset": "0xEC",
"ServerDataOffset": 2544, "ServerDataOffset": "0xA08",
"LocalPlayerDirectOffset": 2576, "LocalPlayerDirectOffset": "0xA10",
"EntityListOffset": 2896, "EntityListOffset": "0xB50",
"EntityCountInternalOffset": 8, "EntityCountInternalOffset": 8,
"EntityNodeLeftOffset": 0, "EntityNodeLeftOffset": 0,
"EntityNodeParentOffset": 8, "EntityNodeParentOffset": 8,
"EntityNodeRightOffset": 16, "EntityNodeRightOffset": "0x10",
"EntityNodeValueOffset": 40, "EntityNodeValueOffset": "0x28",
"EntityIdOffset": 128, "EntityIdOffset": "0x80",
"EntityFlagsOffset": 132, "EntityFlagsOffset": "0x84",
"EntityDetailsOffset": 8, "EntityDetailsOffset": 8,
"EntityPathStringOffset": 8, "EntityPathStringOffset": 8,
"LocalPlayerOffset": 32, "LocalPlayerOffset": "0x20",
"ComponentListOffset": 16, "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, "EntityHeaderOffset": 8,
"ComponentLookupOffset": 40, "ComponentLookupOffset": "0x28",
"ComponentLookupVec2Offset": 40, "ComponentLookupVec2Offset": "0x28",
"ComponentLookupEntrySize": 16, "ComponentLookupEntrySize": "0x10",
"ComponentLookupNameOffset": 0, "ComponentLookupNameOffset": 0,
"ComponentLookupIndexOffset": 8, "ComponentLookupIndexOffset": 8,
"LifeComponentIndex": -1, "LifeComponentIndex": -1,
"RenderComponentIndex": -1, "RenderComponentIndex": -1,
"LifeComponentOffset1": 1056, "LifeComponentOffset1": "0x420",
"LifeComponentOffset2": 152, "LifeComponentOffset2": "0x98",
"LifeHealthOffset": 424, "LifeHealthOffset": "0x1A8",
"LifeManaOffset": 504, "LifeManaOffset": "0x1F8",
"LifeEsOffset": 560, "LifeEsOffset": "0x230",
"VitalCurrentOffset": 48, "VitalCurrentOffset": "0x30",
"VitalTotalOffset": 44, "VitalTotalOffset": "0x2C",
"PositionXOffset": 312, "PositionXOffset": "0x138",
"PositionYOffset": 316, "PositionYOffset": "0x13C",
"PositionZOffset": 320, "PositionZOffset": "0x140",
"CameraOffset": 776, "CameraOffset": "0x308",
"CameraMatrixOffset": 416, "CameraMatrixOffset": "0x1A0",
"TerrainListOffset": 3264, "TerrainListOffset": "0xCC0",
"TerrainInline": true, "TerrainInline": true,
"TerrainDimensionsOffset": 144, "TerrainDimensionsOffset": "0x90",
"TerrainWalkableGridOffset": 328, "TerrainWalkableGridOffset": "0x148",
"TerrainBytesPerRowOffset": 424, "TerrainBytesPerRowOffset": "0x1A8",
"TerrainGridPtrOffset": 8, "TerrainGridPtrOffset": 8,
"SubTilesPerCell": 23 "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"
}

View file

@ -27,6 +27,10 @@
<ProjectReference Include="..\Roboto.Data\Roboto.Data.csproj" /> <ProjectReference Include="..\Roboto.Data\Roboto.Data.csproj" />
<ProjectReference Include="..\Roboto.Engine\Roboto.Engine.csproj" /> <ProjectReference Include="..\Roboto.Engine\Roboto.Engine.csproj" />
</ItemGroup> </ItemGroup>
<!-- Quest name lookup (generated by tools/dump_quest_names.py) -->
<ItemGroup>
<Content Include="..\..\quest_names.json" Link="quest_names.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\quest_names.json')" />
</ItemGroup>
<!-- Sidekick data files (English only) --> <!-- Sidekick data files (English only) -->
<ItemGroup> <ItemGroup>
<Content Include="..\..\lib\Sidekick\data\poe2\ninja\**\*" Link="wwwroot\data\poe2\ninja\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" /> <Content Include="..\..\lib\Sidekick\data\poe2\ninja\**\*" Link="wwwroot\data\poe2\ninja\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />

View file

@ -102,6 +102,7 @@ public partial class MemoryViewModel : ObservableObject
private MemoryNodeViewModel? _entityTypesNode; private MemoryNodeViewModel? _entityTypesNode;
private MemoryNodeViewModel? _entityListNode; private MemoryNodeViewModel? _entityListNode;
private MemoryNodeViewModel? _skillsNode; private MemoryNodeViewModel? _skillsNode;
private MemoryNodeViewModel? _questsNode;
partial void OnIsEnabledChanged(bool value) partial void OnIsEnabledChanged(bool value)
{ {
@ -208,11 +209,13 @@ public partial class MemoryViewModel : ObservableObject
_playerMana = new MemoryNodeViewModel("Mana:") { Value = "?", ValueColor = "#484f58" }; _playerMana = new MemoryNodeViewModel("Mana:") { Value = "?", ValueColor = "#484f58" };
_playerEs = new MemoryNodeViewModel("ES:") { Value = "?", ValueColor = "#484f58" }; _playerEs = new MemoryNodeViewModel("ES:") { Value = "?", ValueColor = "#484f58" };
_skillsNode = new MemoryNodeViewModel("Skills") { IsExpanded = false }; _skillsNode = new MemoryNodeViewModel("Skills") { IsExpanded = false };
_questsNode = new MemoryNodeViewModel("Quests") { IsExpanded = false };
player.Children.Add(_playerPos); player.Children.Add(_playerPos);
player.Children.Add(_playerLife); player.Children.Add(_playerLife);
player.Children.Add(_playerMana); player.Children.Add(_playerMana);
player.Children.Add(_playerEs); player.Children.Add(_playerEs);
player.Children.Add(_skillsNode); player.Children.Add(_skillsNode);
player.Children.Add(_questsNode);
// Entities // Entities
var entitiesGroup = new MemoryNodeViewModel("Entities"); var entitiesGroup = new MemoryNodeViewModel("Entities");
@ -454,12 +457,13 @@ public partial class MemoryViewModel : ObservableObject
_playerEs!.Set("? (set LifeComponentIndex)", false); _playerEs!.Set("? (set LifeComponentIndex)", false);
} }
// Player skills // Player skills — expandable nodes with full details
if (_skillsNode is not null) if (_skillsNode is not null)
{ {
if (snap.PlayerSkills is { Count: > 0 }) 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"; _skillsNode.ValueColor = "#3fb950";
while (_skillsNode.Children.Count > snap.PlayerSkills.Count) while (_skillsNode.Children.Count > snap.PlayerSkills.Count)
@ -469,31 +473,81 @@ public partial class MemoryViewModel : ObservableObject
{ {
var skill = snap.PlayerSkills[i]; var skill = snap.PlayerSkills[i];
var name = skill.Name ?? $"Skill#{i}"; var name = skill.Name ?? $"Skill#{i}";
var label = $"[{i}] {name}:"; var slotTag = skill.SkillBarSlot >= 0 ? $"bar:{skill.SkillBarSlot}" : "off-bar";
var summary = skill.CanBeUsed ? "Ready" : "Cooldown";
var parts = new List<string>();
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 color = skill.CanBeUsed ? "#3fb950" : "#d29922"; 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) if (i < _skillsNode.Children.Count)
{ {
_skillsNode.Children[i].Name = label; skillNode = _skillsNode.Children[i];
_skillsNode.Children[i].Value = value; skillNode.Name = $"{name} ({slotTag})";
_skillsNode.Children[i].ValueColor = color; skillNode.Value = summary;
skillNode.ValueColor = color;
} }
else else
{ {
var node = new MemoryNodeViewModel(label) { Value = value, ValueColor = color }; skillNode = new MemoryNodeViewModel($"{name} ({slotTag})")
_skillsNode.Children.Add(node); {
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 // Entities
if (snap.Entities is { Count: > 0 }) if (snap.Entities is { Count: > 0 })
{ {
@ -1220,4 +1319,40 @@ public partial class MemoryViewModel : ObservableObject
ScanResult = _reader.Diagnostics!.ScanQuestFlags(); 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();
}
} }

View file

@ -778,6 +778,12 @@
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" /> Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Quest Flags" Command="{Binding ScanQuestFlagsExecuteCommand}" <Button Content="Quest Flags" Command="{Binding ScanQuestFlagsExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" /> Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="UI Elements" Command="{Binding ScanUiElementsExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="UI Text" Command="{Binding ScanUiTextExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Quest Objects" Command="{Binding ScanQuestObjectsExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
</WrapPanel> </WrapPanel>
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas" <TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
FontSize="10" Foreground="#e6edf3" Background="#0d1117" FontSize="10" Foreground="#e6edf3" Background="#0d1117"

View file

@ -2,8 +2,15 @@ namespace Roboto.Core;
public record QuestProgress public record QuestProgress
{ {
/// <summary>QuestState.dat row index (POE2 int_vector mode). 0 if using legacy pointer mode.</summary>
public int QuestStateIndex { get; init; }
public string? QuestName { get; init; } public string? QuestName { get; init; }
/// <summary>Internal quest ID from dat row (e.g. "TreeOfSouls2", "IncursionQuest1_Act1").</summary>
public string? InternalId { get; init; }
/// <summary>Encounter state: 1=locked/not encountered, 2=available/started.</summary>
public byte StateId { get; init; } public byte StateId { get; init; }
/// <summary>True if this quest is the currently tracked/active quest in the UI.</summary>
public bool IsTracked { get; init; }
public string? StateText { get; init; } public string? StateText { get; init; }
public string? ProgressText { get; init; } public string? ProgressText { get; init; }
} }

View file

@ -4,12 +4,26 @@ public record SkillState
{ {
public int SlotIndex { get; init; } public int SlotIndex { get; init; }
public ushort ScanCode { 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? Name { get; init; }
public string? InternalName { 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 ChargesCurrent { get; init; }
public int ChargesMax { get; init; } public int ChargesMax { get; init; }
public float CooldownRemaining { get; init; } public float CooldownRemaining { get; init; }
public bool CanBeUsed { 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;
} }

View file

@ -20,6 +20,7 @@ public sealed class MemoryPoller : IDisposable
private Thread? _thread; private Thread? _thread;
private volatile bool _running; private volatile bool _running;
private bool _disposed; private bool _disposed;
private int _lastQuestCount;
// Cached resolved addresses (re-resolved on each cold tick) // Cached resolved addresses (re-resolved on each cold tick)
private nint _cameraMatrixAddr; private nint _cameraMatrixAddr;
@ -276,12 +277,23 @@ public sealed class MemoryPoller : IDisposable
ManaTotal = snap.ManaTotal, ManaTotal = snap.ManaTotal,
EsCurrent = snap.EsCurrent, EsCurrent = snap.EsCurrent,
EsTotal = snap.EsTotal, 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, SlotIndex = s.SkillBarSlot,
Name = s.Name, 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, CanBeUsed = s.CanBeUsed,
CooldownRemaining = s.ActiveCooldowns > 0 ? s.CooldownTimeMs : 0,
}).ToList() ?? [], }).ToList() ?? [],
}; };
@ -321,11 +333,21 @@ public sealed class MemoryPoller : IDisposable
{ {
state.ActiveQuests = snap.QuestFlags.Select(q => new QuestProgress state.ActiveQuests = snap.QuestFlags.Select(q => new QuestProgress
{ {
QuestStateIndex = q.QuestStateIndex,
QuestName = q.QuestName, QuestName = q.QuestName,
InternalId = q.InternalId,
StateId = q.StateId, StateId = q.StateId,
IsTracked = q.IsTracked,
StateText = q.StateText, StateText = q.StateText,
ProgressText = q.ProgressText, ProgressText = q.ProgressText,
}).ToList(); }).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) if (snap.Terrain is not null)
@ -341,6 +363,14 @@ public sealed class MemoryPoller : IDisposable
return state; 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() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;

View file

@ -27,20 +27,22 @@ public struct ActiveSkillEntry
} }
/// <summary> /// <summary>
/// Details of an active skill, reached by following ActiveSkillEntry.ActiveSkillPtr. /// Details of an active skill. The shared_ptr in the ActiveSkills vector points
/// From ExileCore2 GameOffsets.Objects.Components.ActiveSkillDetails. /// 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.
/// </summary> /// </summary>
[StructLayout(LayoutKind.Explicit, Pack = 1)] [StructLayout(LayoutKind.Explicit, Pack = 1)]
public struct ActiveSkillDetails public struct ActiveSkillDetails
{ {
[FieldOffset(0x00)] public nint Vtable;
[FieldOffset(0x08)] public int UseStage; [FieldOffset(0x08)] public int UseStage;
[FieldOffset(0x0C)] public int CastType; [FieldOffset(0x0C)] public int CastType;
[FieldOffset(0x10)] public uint UnknownIdAndEquipmentInfo; [FieldOffset(0x20)] public uint UnknownIdAndEquipmentInfo;
[FieldOffset(0x18)] public nint GrantedEffectsPerLevelDatRow; [FieldOffset(0x28)] public nint GrantedEffectsPerLevelDatRow;
[FieldOffset(0x20)] public nint ActiveSkillsDatPtr; [FieldOffset(0x30)] public nint ActiveSkillsDatPtr;
[FieldOffset(0x30)] public nint GrantedEffectStatSetsPerLevelDatRow; [FieldOffset(0x40)] public nint GrantedEffectStatSetsPerLevelDatRow;
[FieldOffset(0x98)] public int TotalUses; [FieldOffset(0xA8)] public int TotalUses;
[FieldOffset(0xA8)] public int TotalCooldownTimeInMs; [FieldOffset(0xB8)] public int TotalCooldownTimeInMs;
} }
/// <summary> /// <summary>

View file

@ -41,6 +41,7 @@ public class GameMemoryReader : IDisposable
private RttiResolver? _rtti; private RttiResolver? _rtti;
private SkillReader? _skills; private SkillReader? _skills;
private QuestReader? _quests; private QuestReader? _quests;
private QuestNameLookup? _questNames;
public ObjectRegistry Registry => _registry; public ObjectRegistry Registry => _registry;
public MemoryDiagnostics? Diagnostics { get; private set; } public MemoryDiagnostics? Diagnostics { get; private set; }
@ -100,7 +101,8 @@ public class GameMemoryReader : IDisposable
_entities = new EntityReader(_ctx, _components, _strings); _entities = new EntityReader(_ctx, _components, _strings);
_terrain = new TerrainReader(_ctx); _terrain = new TerrainReader(_ctx);
_skills = new SkillReader(_ctx, _components, _strings); _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); Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
return true; return true;
@ -119,9 +121,21 @@ public class GameMemoryReader : IDisposable
_rtti = null; _rtti = null;
_skills = null; _skills = null;
_quests = null; _quests = null;
// _questNames intentionally kept — reloaded only once
Diagnostics = null; 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() public GameStateSnapshot ReadSnapshot()
{ {
var snap = new GameStateSnapshot(); var snap = new GameStateSnapshot();
@ -223,7 +237,17 @@ public class GameMemoryReader : IDisposable
_components.ReadPlayerVitals(snap); _components.ReadPlayerVitals(snap);
_components.ReadPlayerPosition(snap); _components.ReadPlayerPosition(snap);
snap.CharacterName = _components.ReadPlayerName(snap.LocalPlayerPtr); 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); snap.QuestFlags = _quests!.ReadQuestFlags(snap.ServerDataPtr);
} }

View file

@ -1,15 +1,68 @@
using System.Globalization;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Serilog; using Serilog;
namespace Roboto.Memory; namespace Roboto.Memory;
/// <summary>
/// Reads/writes int as hex strings ("0x1A8") or plain numbers (424).
/// On write, values >= 16 are emitted as "0xHEX", smaller values as plain numbers.
/// </summary>
internal sealed class HexIntConverter : JsonConverter<int>
{
public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Number)
return reader.GetInt32();
var s = reader.GetString();
if (s is null) return 0;
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return int.Parse(s.AsSpan(2), NumberStyles.HexNumber);
return int.Parse(s);
}
public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
{
if (value >= 16)
writer.WriteStringValue($"0x{value:X}");
else
writer.WriteNumberValue(value);
}
}
/// <summary>Same as HexIntConverter but for uint (e.g. QuestTrackedMarker).</summary>
internal sealed class HexUintConverter : JsonConverter<uint>
{
public override uint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Number)
return reader.GetUInt32();
var s = reader.GetString();
if (s is null) return 0;
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return uint.Parse(s.AsSpan(2), NumberStyles.HexNumber);
return uint.Parse(s);
}
public override void Write(Utf8JsonWriter writer, uint value, JsonSerializerOptions options)
{
if (value >= 16)
writer.WriteStringValue($"0x{value:X}");
else
writer.WriteNumberValue(value);
}
}
public sealed class GameOffsets public sealed class GameOffsets
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
WriteIndented = true, WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Converters = { new HexIntConverter(), new HexUintConverter() }
}; };
public string ProcessName { get; set; } = "PathOfExileSteam"; public string ProcessName { get; set; } = "PathOfExileSteam";
@ -60,9 +113,9 @@ public sealed class GameOffsets
public int AreaLevelStaticOffset { get; set; } = 0; public int AreaLevelStaticOffset { get; set; } = 0;
/// <summary>AreaInstance → CurrentAreaHash uint (dump: 0xEC).</summary> /// <summary>AreaInstance → CurrentAreaHash uint (dump: 0xEC).</summary>
public int AreaHashOffset { get; set; } = 0xEC; public int AreaHashOffset { get; set; } = 0xEC;
/// <summary>AreaInstance → ServerData pointer (dump: 0x9F0 via LocalPlayerStruct.ServerDataPtr).</summary> /// <summary>AreaInstance → ServerData pointer. Heap object with vtable, StdVector at +0x50 for PlayerServerData.</summary>
public int ServerDataOffset { get; set; } = 0x9F0; public int ServerDataOffset { get; set; } = 0xA08;
/// <summary>AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr).</summary> /// <summary>AreaInstance → LocalPlayer entity pointer.</summary>
public int LocalPlayerDirectOffset { get; set; } = 0xA10; public int LocalPlayerDirectOffset { get; set; } = 0xA10;
/// <summary>AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50).</summary> /// <summary>AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50).</summary>
public int EntityListOffset { get; set; } = 0xB50; public int EntityListOffset { get; set; } = 0xB50;
@ -90,24 +143,56 @@ public sealed class GameOffsets
// ServerData → fields // ServerData → fields
/// <summary>ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).</summary> /// <summary>ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).</summary>
public int LocalPlayerOffset { get; set; } = 0x20; public int LocalPlayerOffset { get; set; } = 0x20;
/// <summary>ServerData → PlayerServerData pointer (PerPlayerServerData struct).</summary> /// <summary>ServerData → PlayerServerData StdVector (begin/end/cap). ExileCore says 0x50 but current binary has it at 0x48.</summary>
public int PlayerServerDataOffset { get; set; } = 0x50; public int PlayerServerDataOffset { get; set; } = 0x48;
/// <summary>PlayerServerData → QuestFlags container offset (PerPlayerServerDataOffsets: 0x230 = 560).</summary> /// <summary>PSD → SkillBarIds: Buffer13 of (ushort Id, ushort Id2). 13 slots × 4 bytes = 52 bytes. Discovered via skillbar_scanner.py.</summary>
public int QuestFlagsOffset { get; set; } = 0x230; public int SkillBarIdsOffset { get; set; } = 0x71A8;
/// <summary>Size of each quest flag entry in bytes. 0 = disabled (offsets not yet discovered via CE).</summary>
public int QuestFlagEntrySize { get; set; } = 0; /// <summary>PSD → QuestState int32 index vector (StdVector of int32). CE confirmed: 0x308.</summary>
/// <summary>Offset within each quest entry to the Quest.dat row pointer.</summary> public int QuestFlagsOffset { get; set; } = 0x308;
/// <summary>Size of each quest flag entry in bytes. 4 = int32 QuestState.dat row index.</summary>
public int QuestFlagEntrySize { get; set; } = 4;
/// <summary>Offset within each quest entry to the Quest.dat row pointer (legacy pointer mode, 0 = N/A).</summary>
public int QuestEntryQuestPtrOffset { get; set; } = 0; public int QuestEntryQuestPtrOffset { get; set; } = 0;
/// <summary>Offset within each quest entry to the byte state ID.</summary> /// <summary>Offset within each quest entry to the byte state ID (legacy pointer mode, 0 = N/A).</summary>
public int QuestEntryStateIdOffset { get; set; } = 0; public int QuestEntryStateIdOffset { get; set; } = 0;
/// <summary>Offset within each quest entry to the wchar* state text pointer.</summary> /// <summary>Offset within each quest entry to the wchar* state text pointer (legacy pointer mode, 0 = N/A).</summary>
public int QuestEntryStateTextOffset { get; set; } = 0; public int QuestEntryStateTextOffset { get; set; } = 0;
/// <summary>Offset within each quest entry to the wchar* progress text pointer.</summary> /// <summary>Offset within each quest entry to the wchar* progress text pointer (legacy pointer mode, 0 = N/A).</summary>
public int QuestEntryProgressTextOffset { get; set; } = 0; public int QuestEntryProgressTextOffset { get; set; } = 0;
/// <summary>Container type for quest flags: "vector" or "map".</summary> /// <summary>Container type: "int_vector" = flat int32 array of QuestState.dat indices, "vector" = struct entries with pointers.</summary>
public string QuestFlagsContainerType { get; set; } = "vector"; public string QuestFlagsContainerType { get; set; } = "int_vector";
/// <summary>Maximum number of quest entries to read (sanity limit).</summary> /// <summary>Maximum number of quest entries to read (sanity limit).</summary>
public int QuestFlagsMaxEntries { get; set; } = 128; public int QuestFlagsMaxEntries { get; set; } = 256;
/// <summary>PSD offset to quest struct count field (QF+0x020 = PSD+0x250). 0 = disabled.</summary>
public int QuestCountOffset { get; set; } = 0x250;
// ── QuestFlags companion vector (QF+0x018): 24-byte structs ──
// Layout: +0x00 int32 QuestStateId, +0x04 uint32 TrackedFlag, +0x10 ptr QuestStateObj
/// <summary>Offset from QuestFlags to companion StdVector (begin/end/cap). 0x18 = QF+0x018.</summary>
public int QuestCompanionOffset { get; set; } = 0x18;
/// <summary>Size of each companion entry in bytes.</summary>
public int QuestCompanionEntrySize { get; set; } = 24;
/// <summary>Offset within companion entry to the QuestStates.dat row pointer (legacy, broken for POE2).</summary>
public int QuestCompanionDatPtrOffset { get; set; } = 0x10;
/// <summary>Offset within companion entry to the tracked flag uint32. Value == QuestTrackedMarker means tracked. 0x04.</summary>
public int QuestCompanionTrackedOffset { get; set; } = 0x04;
/// <summary>Offset within companion entry to the quest state object pointer. 0x10.</summary>
public int QuestCompanionObjPtrOffset { get; set; } = 0x10;
/// <summary>uint32 marker value indicating a quest is currently tracked in the UI. 0x43020000 = float 130.0.</summary>
public uint QuestTrackedMarker { get; set; } = 0x43020000;
/// <summary>Offset within the quest state object to the encounter state byte (1=locked, 2=started). 0x08.</summary>
public int QuestObjEncounterStateOffset { get; set; } = 0x08;
// ── QuestStates.dat row layout (119 bytes, non-aligned fields) ──
/// <summary>Size of each .dat row in bytes. 0x77 = 119. 0 = name resolution disabled.</summary>
public int QuestDatRowSize { get; set; } = 0x77;
/// <summary>Dat row → Quest display name wchar* pointer.</summary>
public int QuestDatNameOffset { get; set; } = 0x00;
/// <summary>Dat row → Internal quest ID wchar* pointer (e.g. "TreeOfSouls2").</summary>
public int QuestDatInternalIdOffset { get; set; } = 0x6B;
/// <summary>Dat row → Act/phase number int32.</summary>
public int QuestDatActOffset { get; set; } = 0x73;
// ── Entity / Component ── // ── Entity / Component ──
public int ComponentListOffset { get; set; } = 0x10; public int ComponentListOffset { get; set; } = 0x10;
@ -150,6 +235,36 @@ public sealed class GameOffsets
/// <summary>Offset within Camera struct to the Matrix4x4 (64 bytes). 0 = disabled.</summary> /// <summary>Offset within Camera struct to the Matrix4x4 (64 bytes). 0 = disabled.</summary>
public int CameraMatrixOffset { get; set; } = 0x1A0; public int CameraMatrixOffset { get; set; } = 0x1A0;
// ── UiRootStruct (InGameState → UI tree roots) ──
/// <summary>Offset from InGameState to UiRootStruct pointer. GameOverlay2: 0x340.</summary>
public int UiRootStructOffset { get; set; } = 0x340;
/// <summary>Offset within UiRootStruct to UiRoot UIElement pointer. GameOffsetsNew: 0x5B8.</summary>
public int UiRootPtrOffset { get; set; } = 0x5B8;
/// <summary>Offset within UiRootStruct to GameUi UIElement pointer. GameOffsetsNew: 0xBE0.</summary>
public int GameUiPtrOffset { get; set; } = 0xBE0;
/// <summary>Offset within UiRootStruct to GameUiController pointer. GameOffsetsNew: 0xBE8.</summary>
public int GameUiControllerPtrOffset { get; set; } = 0xBE8;
// ── UIElement offsets (GameOffsetsNew UiElementBaseOffset) ──
/// <summary>UIElement → Self pointer (validation: should equal element address). 0x08.</summary>
public int UiElementSelfOffset { get; set; } = 0x08;
/// <summary>UIElement → StdVector of child UIElement pointers (begin/end/cap). 0x10.</summary>
public int UiElementChildrenOffset { get; set; } = 0x10;
/// <summary>UIElement → Parent UIElement pointer. 0xB8.</summary>
public int UiElementParentOffset { get; set; } = 0xB8;
/// <summary>UIElement → StdWString StringId (inline MSVC std::wstring). 0x98.</summary>
public int UiElementStringIdOffset { get; set; } = 0x98;
/// <summary>UIElement → Flags uint32 (visibility at bit 0x0B). 0x180.</summary>
public int UiElementFlagsOffset { get; set; } = 0x180;
/// <summary>Bit position for IsVisible in UIElement Flags. 0x0B = bit 11.</summary>
public int UiElementVisibleBit { get; set; } = 0x0B;
/// <summary>UIElement → UnscaledSize (float, float). 0x288.</summary>
public int UiElementSizeOffset { get; set; } = 0x288;
/// <summary>UIElement → Display text StdWString. 0x448. Not all elements have text.</summary>
public int UiElementTextOffset { get; set; } = 0x448;
/// <summary>How many bytes to scan from InGameState for UIElement pointers (0x1000 = 4KB).</summary>
public int UiElementScanRange { get; set; } = 0x1000;
// ── Terrain (inline in AreaInstance) ── // ── Terrain (inline in AreaInstance) ──
/// <summary>Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).</summary> /// <summary>Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).</summary>
public int TerrainListOffset { get; set; } = 0xCC0; public int TerrainListOffset { get; set; } = 0xCC0;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,67 @@
using System.Text.Json;
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Loads quest name mappings from a JSON file (generated by tools/dump_quest_names.py).
/// Provides QuestStateId → (name, internalId, act) lookup.
/// </summary>
public sealed class QuestNameLookup
{
private readonly Dictionary<int, QuestNameEntry> _entries = new();
private bool _loaded;
public record QuestNameEntry(string? Name, string? InternalId, int Act);
/// <summary>
/// Tries to load quest names from the given JSON path.
/// File format: { "0": { "name": "...", "internalId": "...", "act": 1 }, ... }
/// </summary>
public void Load(string path)
{
_entries.Clear();
_loaded = false;
if (!File.Exists(path))
{
Log.Debug("Quest names file not found: {Path}", path);
return;
}
try
{
var json = File.ReadAllText(path);
using var doc = JsonDocument.Parse(json);
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!int.TryParse(prop.Name, out var idx))
continue;
var obj = prop.Value;
var name = obj.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String
? n.GetString() : null;
var internalId = obj.TryGetProperty("internalId", out var id) && id.ValueKind == JsonValueKind.String
? id.GetString() : null;
var act = obj.TryGetProperty("act", out var a) && a.ValueKind == JsonValueKind.Number
? a.GetInt32() : 0;
_entries[idx] = new QuestNameEntry(name, internalId, act);
}
_loaded = true;
Log.Information("Loaded {Count} quest names from {Path}", _entries.Count, path);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to load quest names from {Path}", path);
}
}
public bool IsLoaded => _loaded;
public int Count => _entries.Count;
public bool TryGet(int questStateIndex, out QuestNameEntry? entry)
=> _entries.TryGetValue(questStateIndex, out entry);
}

View file

@ -8,32 +8,42 @@ namespace Roboto.Memory;
/// </summary> /// </summary>
public sealed class QuestSnapshot public sealed class QuestSnapshot
{ {
/// <summary>QuestState.dat row index (int_vector mode) or 0 (pointer mode).</summary>
public int QuestStateIndex { get; init; }
public nint QuestDatPtr { get; init; } public nint QuestDatPtr { get; init; }
public string? QuestName { get; init; } public string? QuestName { get; init; }
/// <summary>Internal quest ID from dat row (e.g. "TreeOfSouls2", "IncursionQuest1_Act1").</summary>
public string? InternalId { get; init; }
/// <summary>Encounter state from quest state object: 1=locked/not encountered, 2=available/started.</summary>
public byte StateId { get; init; } public byte StateId { get; init; }
/// <summary>True if this quest is the currently tracked/active quest in the UI.</summary>
public bool IsTracked { get; init; }
public string? StateText { get; init; } public string? StateText { get; init; }
public string? ProgressText { get; init; } public string? ProgressText { get; init; }
} }
/// <summary> /// <summary>
/// Reads quest flags from ServerData → PlayerServerData → QuestFlags vector. /// Reads quest flags from ServerData → PlayerServerData → QuestFlags.
/// Follows the same pattern as SkillReader: bulk-reads vector data, resolves /// Supports two modes:
/// quest names by following dat row pointers, caches results. /// - "int_vector": flat StdVector of int32 QuestState.dat row indices (POE2)
/// When QuestFlagEntrySize == 0 (offsets not yet discovered), gracefully returns null. /// - "vector": struct entries with dat row pointers and string fields (POE1/legacy)
/// When QuestFlagEntrySize == 0, gracefully returns null.
/// </summary> /// </summary>
public sealed class QuestReader public sealed class QuestReader
{ {
private readonly MemoryContext _ctx; private readonly MemoryContext _ctx;
private readonly MsvcStringReader _strings; private readonly MsvcStringReader _strings;
private readonly QuestNameLookup? _nameLookup;
// Name cache — quest names are static, only refresh on ServerData change // Name cache — quest names are static, only refresh on ServerData change
private readonly Dictionary<nint, string?> _nameCache = new(); private readonly Dictionary<nint, string?> _nameCache = new();
private nint _lastServerData; private nint _lastPsd;
public QuestReader(MemoryContext ctx, MsvcStringReader strings) public QuestReader(MemoryContext ctx, MsvcStringReader strings, QuestNameLookup? nameLookup = null)
{ {
_ctx = ctx; _ctx = ctx;
_strings = strings; _strings = strings;
_nameLookup = nameLookup;
} }
/// <summary> /// <summary>
@ -45,43 +55,181 @@ public sealed class QuestReader
if (serverDataPtr == 0) return null; if (serverDataPtr == 0) return null;
var offsets = _ctx.Offsets; var offsets = _ctx.Offsets;
// Guard: entry size 0 means offsets not yet discovered via CE
if (offsets.QuestFlagEntrySize <= 0) return null; if (offsets.QuestFlagEntrySize <= 0) return null;
var mem = _ctx.Memory; var mem = _ctx.Memory;
// ServerData+0x50 is a StdVector of pointers to PerPlayerServerData structs. // ServerData → PlayerServerData StdVector (vector of pointers, deref [0])
// Read vector begin, then dereference to get the first entry.
var psdVecBegin = mem.ReadPointer(serverDataPtr + offsets.PlayerServerDataOffset); var psdVecBegin = mem.ReadPointer(serverDataPtr + offsets.PlayerServerDataOffset);
if (psdVecBegin == 0) return null; if (psdVecBegin == 0) return null;
// Dereference: vector[0] is a pointer to the actual PerPlayerServerData struct
var playerServerData = mem.ReadPointer(psdVecBegin); var playerServerData = mem.ReadPointer(psdVecBegin);
if (playerServerData == 0) return null; if (playerServerData == 0) return null;
// Invalidate name cache on ServerData change (area transition) // Invalidate cache on PSD change (area transition)
if (playerServerData != _lastServerData) if (playerServerData != _lastPsd)
{ {
_nameCache.Clear(); _nameCache.Clear();
_lastServerData = playerServerData; _lastPsd = playerServerData;
} }
// PerPlayerServerData → QuestFlags (+0x230)
var questFlagsAddr = playerServerData + offsets.QuestFlagsOffset; var questFlagsAddr = playerServerData + offsets.QuestFlagsOffset;
if (offsets.QuestFlagsContainerType == "vector") if (offsets.QuestFlagsContainerType == "int_vector")
return ReadVectorQuests(questFlagsAddr, offsets); return ReadIntVectorQuests(questFlagsAddr, offsets);
if (offsets.QuestFlagsContainerType == "vector")
return ReadStructVectorQuests(questFlagsAddr, offsets);
// Future: "map" container type
return null; return null;
} }
private List<QuestSnapshot>? ReadVectorQuests(nint questFlagsAddr, GameOffsets offsets) /// <summary>
/// POE2 mode: reads a StdVector of int32 QuestState.dat row indices (QF+0x000),
/// plus a companion StdVector of 24-byte structs (QF+0x018) that contain:
/// +0x00 int32: QuestStateId (= .dat row index, same as int32 vector value)
/// +0x04 uint32: TrackedFlag (0x43020000 = currently tracked quest in UI, 0 = not tracked)
/// +0x10 ptr: Quest state object (runtime object, NOT a .dat row)
/// The quest state object has encounter state at +0x008 (byte: 1=locked, 2=started).
/// Quest names are resolved from the .dat table base if configured.
/// </summary>
private List<QuestSnapshot>? ReadIntVectorQuests(nint questFlagsAddr, GameOffsets offsets)
{
var mem = _ctx.Memory;
// Read int32 index vector (QF+0x000)
var vecBegin = mem.ReadPointer(questFlagsAddr);
var vecEnd = mem.ReadPointer(questFlagsAddr + 8);
if (vecBegin == 0 || vecEnd <= vecBegin) return null;
var totalBytes = (int)(vecEnd - vecBegin);
var entryCount = totalBytes / 4; // int32 = 4 bytes
if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null;
var vecData = mem.ReadBytes(vecBegin, totalBytes);
if (vecData is null) return null;
// Read companion vector (QF+0x018) for quest state objects
byte[]? compData = null;
var compEntryCount = 0;
if (offsets.QuestCompanionOffset > 0 && offsets.QuestCompanionEntrySize > 0)
{
var compBegin = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset);
var compEnd = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset + 8);
if (compBegin != 0 && compEnd > compBegin)
{
var compBytes = (int)(compEnd - compBegin);
compEntryCount = compBytes / offsets.QuestCompanionEntrySize;
if (compEntryCount > 0 && compBytes < 0x100000)
compData = mem.ReadBytes(compBegin, compBytes);
}
}
// Find .dat table base if configured (for quest name resolution)
var datTableBase = FindDatTableBase(offsets);
var result = new List<QuestSnapshot>(entryCount);
for (var i = 0; i < entryCount; i++)
{
var idx = BitConverter.ToInt32(vecData, i * 4);
string? questName = null;
string? internalId = null;
byte stateId = 0;
bool isTracked = false;
nint questObjPtr = 0;
if (compData is not null && i < compEntryCount)
{
var compOff = i * offsets.QuestCompanionEntrySize;
// Read tracked flag from companion +0x04
if (offsets.QuestCompanionTrackedOffset > 0 &&
compOff + offsets.QuestCompanionTrackedOffset + 4 <= compData.Length)
{
var trackedVal = BitConverter.ToUInt32(compData, compOff + offsets.QuestCompanionTrackedOffset);
isTracked = trackedVal == offsets.QuestTrackedMarker;
}
// Read quest state object pointer from companion +0x10
if (compOff + offsets.QuestCompanionObjPtrOffset + 8 <= compData.Length)
{
questObjPtr = (nint)BitConverter.ToInt64(compData, compOff + offsets.QuestCompanionObjPtrOffset);
// Read encounter state byte from quest state object +0x008
if (questObjPtr != 0 && ((ulong)questObjPtr >> 32) is > 0 and < 0x7FFF
&& offsets.QuestObjEncounterStateOffset > 0)
{
var stateByte = mem.ReadBytes(questObjPtr + offsets.QuestObjEncounterStateOffset, 1);
if (stateByte is { Length: 1 })
stateId = stateByte[0];
}
}
}
// Resolve quest name: try .dat table first, then JSON lookup fallback
if (datTableBase != 0 && offsets.QuestDatRowSize > 0)
{
var rowAddr = datTableBase + idx * offsets.QuestDatRowSize;
questName = ResolveDatString(rowAddr + offsets.QuestDatNameOffset);
internalId = ResolveDatString(rowAddr + offsets.QuestDatInternalIdOffset);
}
else if (_nameLookup is not null && _nameLookup.TryGet(idx, out var entry))
{
questName = entry?.Name;
internalId = entry?.InternalId;
}
result.Add(new QuestSnapshot
{
QuestStateIndex = idx,
QuestDatPtr = questObjPtr,
QuestName = questName,
InternalId = internalId,
StateId = stateId,
IsTracked = isTracked,
});
}
return result;
}
/// <summary>
/// Finds the QuestStates.dat row table base address.
/// Uses QuestDatTableBase offset from PSD if configured, otherwise returns 0.
/// </summary>
private nint FindDatTableBase(GameOffsets offsets)
{
if (offsets.QuestDatRowSize <= 0) return 0;
// Future: auto-discover table base by scanning for known patterns
// For now, table base must be found externally and is not resolved here
return 0;
}
/// <summary>Reads a wchar* pointer at the given address and returns the string.</summary>
private string? ResolveDatString(nint fieldAddr)
{
if (_nameCache.TryGetValue(fieldAddr, out var cached))
return cached;
var mem = _ctx.Memory;
var strPtr = mem.ReadPointer(fieldAddr);
string? result = null;
if (strPtr != 0 && ((ulong)strPtr >> 32) is > 0 and < 0x7FFF)
result = _strings.ReadNullTermWString(strPtr);
_nameCache[fieldAddr] = result;
return result;
}
/// <summary>
/// Legacy/POE1 mode: reads struct entries with dat row pointers and string fields.
/// </summary>
private List<QuestSnapshot>? ReadStructVectorQuests(nint questFlagsAddr, GameOffsets offsets)
{ {
var mem = _ctx.Memory; var mem = _ctx.Memory;
// StdVector: begin, end, capacity (3 pointers)
var vecBegin = mem.ReadPointer(questFlagsAddr); var vecBegin = mem.ReadPointer(questFlagsAddr);
var vecEnd = mem.ReadPointer(questFlagsAddr + 8); var vecEnd = mem.ReadPointer(questFlagsAddr + 8);
if (vecBegin == 0 || vecEnd <= vecBegin) return null; if (vecBegin == 0 || vecEnd <= vecBegin) return null;
@ -91,7 +239,6 @@ public sealed class QuestReader
var entryCount = totalBytes / entrySize; var entryCount = totalBytes / entrySize;
if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null; if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null;
// Bulk read all entries
var vecData = mem.ReadBytes(vecBegin, totalBytes); var vecData = mem.ReadBytes(vecBegin, totalBytes);
if (vecData is null) return null; if (vecData is null) return null;
@ -101,20 +248,16 @@ public sealed class QuestReader
{ {
var entryOffset = i * entrySize; var entryOffset = i * entrySize;
// Read quest dat pointer
nint questDatPtr = 0; nint questDatPtr = 0;
if (entryOffset + offsets.QuestEntryQuestPtrOffset + 8 <= vecData.Length) if (entryOffset + offsets.QuestEntryQuestPtrOffset + 8 <= vecData.Length)
questDatPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryQuestPtrOffset); questDatPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryQuestPtrOffset);
// Read state ID byte
byte stateId = 0; byte stateId = 0;
if (entryOffset + offsets.QuestEntryStateIdOffset < vecData.Length) if (entryOffset + offsets.QuestEntryStateIdOffset < vecData.Length)
stateId = vecData[entryOffset + offsets.QuestEntryStateIdOffset]; stateId = vecData[entryOffset + offsets.QuestEntryStateIdOffset];
// Resolve quest name from dat pointer (cached)
var questName = ResolveQuestName(questDatPtr); var questName = ResolveQuestName(questDatPtr);
// Read state text pointer and resolve
string? stateText = null; string? stateText = null;
if (offsets.QuestEntryStateTextOffset > 0 && if (offsets.QuestEntryStateTextOffset > 0 &&
entryOffset + offsets.QuestEntryStateTextOffset + 8 <= vecData.Length) entryOffset + offsets.QuestEntryStateTextOffset + 8 <= vecData.Length)
@ -124,7 +267,6 @@ public sealed class QuestReader
stateText = _strings.ReadNullTermWString(stateTextPtr); stateText = _strings.ReadNullTermWString(stateTextPtr);
} }
// Read progress text pointer and resolve
string? progressText = null; string? progressText = null;
if (offsets.QuestEntryProgressTextOffset > 0 && if (offsets.QuestEntryProgressTextOffset > 0 &&
entryOffset + offsets.QuestEntryProgressTextOffset + 8 <= vecData.Length) entryOffset + offsets.QuestEntryProgressTextOffset + 8 <= vecData.Length)
@ -147,10 +289,6 @@ public sealed class QuestReader
return result; return result;
} }
/// <summary>
/// Resolves quest name by following QuestDatPtr → dat row → wchar* name.
/// Results are cached since quest names don't change.
/// </summary>
private string? ResolveQuestName(nint questDatPtr) private string? ResolveQuestName(nint questDatPtr)
{ {
if (questDatPtr == 0) return null; if (questDatPtr == 0) return null;
@ -161,7 +299,6 @@ public sealed class QuestReader
var mem = _ctx.Memory; var mem = _ctx.Memory;
string? name = null; string? name = null;
// Follow the dat row pointer — first field is typically a wchar* name
var high = (ulong)questDatPtr >> 32; var high = (ulong)questDatPtr >> 32;
if (high is > 0 and < 0x7FFF) if (high is > 0 and < 0x7FFF)
{ {
@ -178,6 +315,6 @@ public sealed class QuestReader
public void InvalidateCache() public void InvalidateCache()
{ {
_nameCache.Clear(); _nameCache.Clear();
_lastServerData = 0; _lastPsd = 0;
} }
} }

View file

@ -9,6 +9,11 @@ namespace Roboto.Memory;
public sealed class SkillSnapshot public sealed class SkillSnapshot
{ {
public string? Name { get; init; } public string? Name { get; init; }
public string? InternalName { get; init; }
/// <summary>Address of ActiveSkillPtr in game memory (for CE inspection).</summary>
public nint Address { get; init; }
/// <summary>Raw bytes at ActiveSkillPtr for offset discovery.</summary>
public byte[]? RawBytes { get; init; }
public bool CanBeUsed { get; init; } public bool CanBeUsed { get; init; }
public int UseStage { get; init; } public int UseStage { get; init; }
public int CastType { get; init; } public int CastType { get; init; }
@ -19,6 +24,13 @@ public sealed class SkillSnapshot
public int ActiveCooldowns { get; init; } public int ActiveCooldowns { get; init; }
/// <summary>From Cooldowns vector — max uses (charges) for the skill.</summary> /// <summary>From Cooldowns vector — max uses (charges) for the skill.</summary>
public int MaxUses { get; init; } public int MaxUses { get; init; }
/// <summary>Low 16 bits of UnknownIdAndEquipmentInfo — skill ID used for SkillBarIds matching.</summary>
public ushort Id { get; init; }
/// <summary>High 16 bits of UnknownIdAndEquipmentInfo — equipment slot / secondary ID.</summary>
public ushort Id2 { get; init; }
/// <summary>Skill bar slot index (0-12) from SkillBarIds, or -1 if not on the skill bar.</summary>
public int SkillBarSlot { get; init; } = -1;
} }
/// <summary> /// <summary>
@ -44,7 +56,7 @@ public sealed class SkillReader
_strings = strings; _strings = strings;
} }
public List<SkillSnapshot>? ReadPlayerSkills(nint localPlayerPtr) public List<SkillSnapshot>? ReadPlayerSkills(nint localPlayerPtr, nint psdPtr = 0)
{ {
if (localPlayerPtr == 0) return null; if (localPlayerPtr == 0) return null;
var mem = _ctx.Memory; var mem = _ctx.Memory;
@ -59,6 +71,9 @@ public sealed class SkillReader
_lastActorComp = actorComp; _lastActorComp = actorComp;
} }
// Read SkillBarIds from PSD if offset is configured
var skillBarIds = ReadSkillBarIds(psdPtr);
// Read ActiveSkills vector at Actor+0xB00 // Read ActiveSkills vector at Actor+0xB00
var vecFirst = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector); var vecFirst = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector);
var vecLast = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector + 8); var vecLast = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector + 8);
@ -87,8 +102,8 @@ public sealed class SkillReader
var high = (ulong)activeSkillPtr >> 32; var high = (ulong)activeSkillPtr >> 32;
if (high == 0 || high >= 0x7FFF) continue; if (high == 0 || high >= 0x7FFF) continue;
// Read ActiveSkillDetails struct // Read ActiveSkillDetails struct — ptr points 0x10 into the object (past vtable+header)
var details = mem.Read<ActiveSkillDetails>(activeSkillPtr); var details = mem.Read<ActiveSkillDetails>(activeSkillPtr - 0x10);
// Resolve skill name via GEPL FK chain (cached) // Resolve skill name via GEPL FK chain (cached)
var name = ResolveSkillName(activeSkillPtr, details); var name = ResolveSkillName(activeSkillPtr, details);
@ -99,6 +114,24 @@ public sealed class SkillReader
// Deduplicate by UnknownIdAndEquipmentInfo // Deduplicate by UnknownIdAndEquipmentInfo
if (!seen.Add(details.UnknownIdAndEquipmentInfo)) continue; 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 // Match cooldown entry by UnknownIdAndEquipmentInfo
var canBeUsed = true; var canBeUsed = true;
var activeCooldowns = 0; 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 result.Add(new SkillSnapshot
{ {
Name = name, Name = name,
InternalName = name,
Address = activeSkillPtr - 0x10,
RawBytes = rawBytes,
CanBeUsed = canBeUsed, CanBeUsed = canBeUsed,
UseStage = details.UseStage, UseStage = details.UseStage,
CastType = details.CastType, CastType = details.CastType,
@ -127,12 +166,41 @@ public sealed class SkillReader
CooldownTimeMs = details.TotalCooldownTimeInMs, CooldownTimeMs = details.TotalCooldownTimeInMs,
ActiveCooldowns = activeCooldowns, ActiveCooldowns = activeCooldowns,
MaxUses = cdMaxUses, MaxUses = cdMaxUses,
Id = id,
Id2 = id2,
SkillBarSlot = slot,
}); });
} }
return result; return result;
} }
/// <summary>
/// Reads SkillBarIds from PlayerServerData: Buffer13 of (ushort Id, ushort Id2).
/// 13 slots × 4 bytes = 52 bytes total.
/// </summary>
private (ushort Id, ushort Id2)[]? ReadSkillBarIds(nint psdPtr)
{
var offset = _ctx.Offsets.SkillBarIdsOffset;
if (offset <= 0 || psdPtr == 0) return null;
const int slotCount = 13;
const int bufferSize = slotCount * 4; // 52 bytes
var data = _ctx.Memory.ReadBytes(psdPtr + offset, bufferSize);
if (data is null) return null;
var slots = new (ushort Id, ushort Id2)[slotCount];
for (var i = 0; i < slotCount; i++)
{
var off = i * 4;
slots[i] = (
BitConverter.ToUInt16(data, off),
BitConverter.ToUInt16(data, off + 2)
);
}
return slots;
}
/// <summary> /// <summary>
/// Reads the Cooldowns vector at Actor+0xB18. /// Reads the Cooldowns vector at Actor+0xB18.
/// Each entry is an ActiveSkillCooldown struct (0x48 bytes). /// Each entry is an ActiveSkillCooldown struct (0x48 bytes).

View file

@ -220,11 +220,8 @@ public class CombatSystem : ISystem
foreach (var ms in memorySkills) foreach (var ms in memorySkills)
{ {
if (ms.Name is null) continue; if (ms.Name is null) continue;
// Memory names have "Player" suffix, profile names don't // Name is already stripped of "Player" suffix by MemoryPoller
var cleanName = ms.Name.EndsWith("Player", StringComparison.Ordinal) if (string.Equals(ms.Name, profileSkillName, StringComparison.OrdinalIgnoreCase))
? ms.Name[..^6]
: ms.Name;
if (string.Equals(cleanName, profileSkillName, StringComparison.OrdinalIgnoreCase))
return ms; return ms;
} }
return null; return null;

View file

@ -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<MemoryViewerSettings>
{
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<string, AddressInfo> 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<MemoryViewerSettings>(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<byte>(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<string, AddressInfo>()
};
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<IntPtr>(validationData.GameStatesAddress);
var inGameStatePtr = reader.ReadMemory<IntPtr>(new IntPtr(gameStatesPtr.ToInt64() + (4 * 8)));
if (ValidateAddress(inGameStatePtr))
{
validationData.InGameStateAddress = inGameStatePtr;
try
{
var ingameDataPtr = reader.ReadMemory<IntPtr>(new IntPtr(inGameStatePtr.ToInt64() + 0x370));
if (ValidateAddress(ingameDataPtr))
{
validationData.IngameDataAddress = ingameDataPtr;
try
{
var localPlayerPtr = reader.ReadMemory<IntPtr>(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<byte>(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());
}
}
}

View file

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ImGui.NET" Version="1.91.6.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\GameHelper\GameHelper.csproj">
<Private>false</Private>
<CopyLocalSatelliteAssemblies>false</CopyLocalSatelliteAssemblies>
</ProjectReference>
</ItemGroup>
<Target Name="CopyFiles" AfterTargets="Build">
<Copy SourceFiles="$(OutDir)$(TargetName)$(TargetExt); $(OutDir)$(TargetName).pdb;" DestinationFolder="..\..\GameHelper\$(OutDir)Plugins\$(ProjectName)" SkipUnchangedFiles="true" />
</Target>
</Project>

View file

@ -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;
}
}

View file

@ -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<PatternToolSettings>
{
private string _dumpDirectory;
private string _customPatternInput = string.Empty;
private string _lastScanResult = string.Empty;
private readonly List<PatternScanResult> _scanResults = new();
private readonly Dictionary<string, OffsetInfo> _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<PatternToolSettings>(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<byte>(baseAddress, scanSize);
if (buffer == null || buffer.Length < pattern.Data.Length)
{
_lastScanResult = "ERROR: Cannot read process memory";
return;
}
var matches = new List<IntPtr>();
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<string>();
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";
}
}
}
}

View file

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ImGui.NET" Version="1.91.6.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\GameHelper\GameHelper.csproj">
<Private>false</Private>
<CopyLocalSatelliteAssemblies>false</CopyLocalSatelliteAssemblies>
</ProjectReference>
</ItemGroup>
<Target Name="CopyFiles" AfterTargets="Build">
<Copy SourceFiles="$(OutDir)$(TargetName)$(TargetExt); $(OutDir)$(TargetName).pdb;" DestinationFolder="..\..\GameHelper\$(OutDir)Plugins\$(ProjectName)" SkipUnchangedFiles="true" />
</Target>
</Project>

View file

@ -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;
}
}

791
tools/skillbar_scanner.py Normal file
View file

@ -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("<Q", pm.read_bytes(addr, 8))[0]
except Exception:
return 0
def read_i32(pm: pymem.Pymem, addr: int) -> int:
try:
return struct.unpack("<i", pm.read_bytes(addr, 4))[0]
except Exception:
return 0
def read_u32(pm: pymem.Pymem, addr: int) -> int:
try:
return struct.unpack("<I", pm.read_bytes(addr, 4))[0]
except Exception:
return 0
def is_valid_ptr(addr: int) -> 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("<Q", data[off:off + 8])[0]
if not is_valid_ptr(name_ptr):
continue
name = read_ascii_string(pm, name_ptr)
if name:
found_names.append(name)
if name == component_name:
target_index = struct.unpack("<i", data[off + 8:off + 12])[0]
break
if verbose:
print(f" Found components: {', '.join(found_names[:20])}")
if target_index < 0:
if verbose:
print(f" ERROR: '{component_name}' not found in lookup entries")
return 0
if verbose:
print(f" '{component_name}' → index {target_index}")
# Read component list: Entity+0x10 → begin pointer → [index * 8]
comp_list_begin = read_ptr(pm, entity_ptr + offsets["ComponentListOffset"])
if not is_valid_ptr(comp_list_begin):
if verbose:
print(" ERROR: Invalid component list pointer")
return 0
comp_addr = read_ptr(pm, comp_list_begin + target_index * 8)
return comp_addr if is_valid_ptr(comp_addr) else 0
def resolve_pointer_chain(pm: pymem.Pymem, offsets: dict) -> 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("<i", data[disp_offset:disp_offset + 4])[0]
# RIP = module_base + chunk_off + disp_offset + 4 (after the 4-byte displacement)
rip = module_base + chunk_off + disp_offset + 4
game_state_base = rip + disp + offsets.get("PatternResultAdjust", 0)
print(f"Pattern matched at module+0x{chunk_off + i:X}, disp=0x{disp & 0xFFFFFFFF:X}")
break
if game_state_base:
break
if game_state_base == 0:
gso = offsets.get("GameStateGlobalOffset", 0)
if gso > 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('<Q', raw[row + j:row + j + 8])[0]:X}" for j in range(0, 16, 8))
print(f" +0x{row:02X}: {hex_str} [{ptrs}]")
except Exception:
pass
print("ERROR: Could not find valid LocalPlayer entity")
return result
print(f"LocalPlayer: 0x{local_player:X}")
result["LocalPlayer"] = local_player
return result
def read_active_skills(pm: pymem.Pymem, actor_comp: int) -> 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("<Q", data[i * entry_size:i * entry_size + 8])[0]
if not is_valid_ptr(active_skill_ptr):
continue
# Read UnknownIdAndEquipmentInfo at ActiveSkillDetails+0x10
uid = read_u32(pm, active_skill_ptr + 0x10)
if uid == 0:
continue
id_lo = uid & 0xFFFF
id_hi = (uid >> 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("<H", raw[j:j + 2])[0]
if c == 0:
break
if c > 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("<Q", data[i * entry_size:i * entry_size + 8])[0]
if not is_valid_ptr(asp):
continue
uid = read_u32(pm, asp + 0x10)
if uid == 0 or uid in seen:
continue
seen.add(uid)
id_lo = uid & 0xFFFF
id_hi = (uid >> 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("<I", psd_data[off:off + 4])[0]
if val in target_values:
hits.append((off, val))
return hits
def scan_psd_for_skillbar(psd_data: bytes, known_ids: set[tuple[int, int]],
known_raw: set[int]) -> 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("<H", psd_data[entry_off:entry_off + 2])[0]
id_hi = struct.unpack("<H", psd_data[entry_off + 2:entry_off + 4])[0]
slots.append((id_lo, id_hi))
if id_lo != 0 or id_hi != 0:
non_zero += 1
if non_zero < 2:
continue
# Count overlap with known skill IDs (as ushort pairs)
overlap = 0
for pair in slots:
if pair != (0, 0) and pair in known_ids:
overlap += 1
# Also try matching raw uint32 values
if overlap < 2:
raw_overlap = 0
for s in range(SLOTS):
entry_off = off + s * ENTRY_SIZE
raw_val = struct.unpack("<I", psd_data[entry_off:entry_off + 4])[0]
if raw_val != 0 and raw_val in known_raw:
raw_overlap += 1
if raw_overlap >= 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("<H", psd_data[off:off + 2])[0]
if val in known_lo16:
lo16_hits.append((off, val))
if lo16_hits:
print(f" But found {len(lo16_hits)} lo16 (ushort) hits — showing first 20:")
for off, val in lo16_hits[:20]:
print(f" PSD+0x{off:X}: {val}")
else:
print(" No lo16 hits either — skill IDs may not be stored in PSD at this address")
print(" (Possibly the PSD vector element is different, or scan range too small)")
# Step 2: Buffer13 pattern scan
print(f"\nStep 2: Scanning for Buffer13<(ushort, ushort)> 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()