skills working somewhat
This commit is contained in:
parent
a8c43ba7e2
commit
8a0e4bb481
22 changed files with 4227 additions and 161 deletions
115
offsets.json
115
offsets.json
|
|
@ -2,62 +2,97 @@
|
|||
"ProcessName": "PathOfExileSteam",
|
||||
"GameStatePattern": "48 83 EC ?? 48 8B F1 33 ED 48 39 2D ^",
|
||||
"GameStateGlobalOffset": 0,
|
||||
"PatternResultAdjust": 24,
|
||||
"StatesBeginOffset": 72,
|
||||
"StateStride": 16,
|
||||
"PatternResultAdjust": "0x18",
|
||||
"StatesBeginOffset": "0x48",
|
||||
"StateStride": "0x10",
|
||||
"StatePointerOffset": 0,
|
||||
"StateCount": 12,
|
||||
"InGameStateIndex": 4,
|
||||
"ActiveStatesOffset": 32,
|
||||
"ActiveStatesOffset": "0x20",
|
||||
"StatesInline": true,
|
||||
"InGameStateDirectOffset": 528,
|
||||
"IsLoadingOffset": 832,
|
||||
"EscapeStateOffset": 524,
|
||||
"IngameDataFromStateOffset": 656,
|
||||
"WorldDataFromStateOffset": 760,
|
||||
"AreaLevelOffset": 196,
|
||||
"InGameStateDirectOffset": "0x210",
|
||||
"IsLoadingOffset": "0x340",
|
||||
"EscapeStateOffset": "0x20C",
|
||||
"IngameDataFromStateOffset": "0x290",
|
||||
"WorldDataFromStateOffset": "0x2F8",
|
||||
"AreaLevelOffset": "0xC4",
|
||||
"AreaLevelIsByte": true,
|
||||
"AreaLevelStaticOffset": 0,
|
||||
"AreaHashOffset": 236,
|
||||
"ServerDataOffset": 2544,
|
||||
"LocalPlayerDirectOffset": 2576,
|
||||
"EntityListOffset": 2896,
|
||||
"AreaHashOffset": "0xEC",
|
||||
"ServerDataOffset": "0xA08",
|
||||
"LocalPlayerDirectOffset": "0xA10",
|
||||
"EntityListOffset": "0xB50",
|
||||
"EntityCountInternalOffset": 8,
|
||||
"EntityNodeLeftOffset": 0,
|
||||
"EntityNodeParentOffset": 8,
|
||||
"EntityNodeRightOffset": 16,
|
||||
"EntityNodeValueOffset": 40,
|
||||
"EntityIdOffset": 128,
|
||||
"EntityFlagsOffset": 132,
|
||||
"EntityNodeRightOffset": "0x10",
|
||||
"EntityNodeValueOffset": "0x28",
|
||||
"EntityIdOffset": "0x80",
|
||||
"EntityFlagsOffset": "0x84",
|
||||
"EntityDetailsOffset": 8,
|
||||
"EntityPathStringOffset": 8,
|
||||
"LocalPlayerOffset": 32,
|
||||
"ComponentListOffset": 16,
|
||||
"LocalPlayerOffset": "0x20",
|
||||
"PlayerServerDataOffset": "0x48",
|
||||
"QuestFlagsOffset": "0x308",
|
||||
"QuestFlagEntrySize": 4,
|
||||
"QuestEntryQuestPtrOffset": 0,
|
||||
"QuestEntryStateIdOffset": 0,
|
||||
"QuestEntryStateTextOffset": 0,
|
||||
"QuestEntryProgressTextOffset": 0,
|
||||
"QuestFlagsContainerType": "int_vector",
|
||||
"QuestFlagsMaxEntries": "0x100",
|
||||
"SkillBarIdsOffset": "0x71A8",
|
||||
"QuestCountOffset": "0x250",
|
||||
"QuestCompanionOffset": "0x18",
|
||||
"QuestCompanionEntrySize": "0x18",
|
||||
"QuestCompanionDatPtrOffset": "0x10",
|
||||
"QuestCompanionTrackedOffset": 4,
|
||||
"QuestCompanionObjPtrOffset": "0x10",
|
||||
"QuestTrackedMarker": "0x43020000",
|
||||
"QuestObjEncounterStateOffset": 8,
|
||||
"QuestDatRowSize": "0x77",
|
||||
"QuestDatNameOffset": 0,
|
||||
"QuestDatInternalIdOffset": "0x6B",
|
||||
"QuestDatActOffset": "0x73",
|
||||
"ComponentListOffset": "0x10",
|
||||
"EntityHeaderOffset": 8,
|
||||
"ComponentLookupOffset": 40,
|
||||
"ComponentLookupVec2Offset": 40,
|
||||
"ComponentLookupEntrySize": 16,
|
||||
"ComponentLookupOffset": "0x28",
|
||||
"ComponentLookupVec2Offset": "0x28",
|
||||
"ComponentLookupEntrySize": "0x10",
|
||||
"ComponentLookupNameOffset": 0,
|
||||
"ComponentLookupIndexOffset": 8,
|
||||
"LifeComponentIndex": -1,
|
||||
"RenderComponentIndex": -1,
|
||||
"LifeComponentOffset1": 1056,
|
||||
"LifeComponentOffset2": 152,
|
||||
"LifeHealthOffset": 424,
|
||||
"LifeManaOffset": 504,
|
||||
"LifeEsOffset": 560,
|
||||
"VitalCurrentOffset": 48,
|
||||
"VitalTotalOffset": 44,
|
||||
"PositionXOffset": 312,
|
||||
"PositionYOffset": 316,
|
||||
"PositionZOffset": 320,
|
||||
"CameraOffset": 776,
|
||||
"CameraMatrixOffset": 416,
|
||||
"TerrainListOffset": 3264,
|
||||
"LifeComponentOffset1": "0x420",
|
||||
"LifeComponentOffset2": "0x98",
|
||||
"LifeHealthOffset": "0x1A8",
|
||||
"LifeManaOffset": "0x1F8",
|
||||
"LifeEsOffset": "0x230",
|
||||
"VitalCurrentOffset": "0x30",
|
||||
"VitalTotalOffset": "0x2C",
|
||||
"PositionXOffset": "0x138",
|
||||
"PositionYOffset": "0x13C",
|
||||
"PositionZOffset": "0x140",
|
||||
"CameraOffset": "0x308",
|
||||
"CameraMatrixOffset": "0x1A0",
|
||||
"TerrainListOffset": "0xCC0",
|
||||
"TerrainInline": true,
|
||||
"TerrainDimensionsOffset": 144,
|
||||
"TerrainWalkableGridOffset": 328,
|
||||
"TerrainBytesPerRowOffset": 424,
|
||||
"TerrainDimensionsOffset": "0x90",
|
||||
"TerrainWalkableGridOffset": "0x148",
|
||||
"TerrainBytesPerRowOffset": "0x1A8",
|
||||
"TerrainGridPtrOffset": 8,
|
||||
"SubTilesPerCell": 23
|
||||
"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"
|
||||
}
|
||||
|
|
@ -27,6 +27,10 @@
|
|||
<ProjectReference Include="..\Roboto.Data\Roboto.Data.csproj" />
|
||||
<ProjectReference Include="..\Roboto.Engine\Roboto.Engine.csproj" />
|
||||
</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) -->
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\lib\Sidekick\data\poe2\ninja\**\*" Link="wwwroot\data\poe2\ninja\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
private MemoryNodeViewModel? _entityTypesNode;
|
||||
private MemoryNodeViewModel? _entityListNode;
|
||||
private MemoryNodeViewModel? _skillsNode;
|
||||
private MemoryNodeViewModel? _questsNode;
|
||||
|
||||
partial void OnIsEnabledChanged(bool value)
|
||||
{
|
||||
|
|
@ -208,11 +209,13 @@ public partial class MemoryViewModel : ObservableObject
|
|||
_playerMana = new MemoryNodeViewModel("Mana:") { Value = "?", ValueColor = "#484f58" };
|
||||
_playerEs = new MemoryNodeViewModel("ES:") { Value = "?", ValueColor = "#484f58" };
|
||||
_skillsNode = new MemoryNodeViewModel("Skills") { IsExpanded = false };
|
||||
_questsNode = new MemoryNodeViewModel("Quests") { IsExpanded = false };
|
||||
player.Children.Add(_playerPos);
|
||||
player.Children.Add(_playerLife);
|
||||
player.Children.Add(_playerMana);
|
||||
player.Children.Add(_playerEs);
|
||||
player.Children.Add(_skillsNode);
|
||||
player.Children.Add(_questsNode);
|
||||
|
||||
// Entities
|
||||
var entitiesGroup = new MemoryNodeViewModel("Entities");
|
||||
|
|
@ -454,12 +457,13 @@ public partial class MemoryViewModel : ObservableObject
|
|||
_playerEs!.Set("? (set LifeComponentIndex)", false);
|
||||
}
|
||||
|
||||
// Player skills
|
||||
// Player skills — expandable nodes with full details
|
||||
if (_skillsNode is not null)
|
||||
{
|
||||
if (snap.PlayerSkills is { Count: > 0 })
|
||||
{
|
||||
_skillsNode.Value = $"{snap.PlayerSkills.Count} skills";
|
||||
var onBar = snap.PlayerSkills.Count(s => s.SkillBarSlot >= 0);
|
||||
_skillsNode.Value = $"{snap.PlayerSkills.Count} skills ({onBar} on bar)";
|
||||
_skillsNode.ValueColor = "#3fb950";
|
||||
|
||||
while (_skillsNode.Children.Count > snap.PlayerSkills.Count)
|
||||
|
|
@ -469,31 +473,81 @@ public partial class MemoryViewModel : ObservableObject
|
|||
{
|
||||
var skill = snap.PlayerSkills[i];
|
||||
var name = skill.Name ?? $"Skill#{i}";
|
||||
var label = $"[{i}] {name}:";
|
||||
|
||||
var parts = new List<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 slotTag = skill.SkillBarSlot >= 0 ? $"bar:{skill.SkillBarSlot}" : "off-bar";
|
||||
var summary = skill.CanBeUsed ? "Ready" : "Cooldown";
|
||||
var color = skill.CanBeUsed ? "#3fb950" : "#d29922";
|
||||
|
||||
// Build detail fields
|
||||
var detailsList = new List<(string Label, string Value)>
|
||||
{
|
||||
("Address", $"0x{skill.Address:X}"),
|
||||
("InternalName", skill.InternalName ?? "—"),
|
||||
("Id", $"0x{skill.Id:X4}"),
|
||||
("Id2", $"0x{skill.Id2:X4}"),
|
||||
("SkillBarSlot", skill.SkillBarSlot >= 0 ? skill.SkillBarSlot.ToString() : "-1"),
|
||||
("CanBeUsed", skill.CanBeUsed.ToString()),
|
||||
("UseStage", skill.UseStage.ToString()),
|
||||
("CastType", skill.CastType.ToString()),
|
||||
("CooldownTimeMs", skill.CooldownTimeMs > 0 ? $"{skill.CooldownTimeMs}ms" : "0"),
|
||||
("MaxUses", skill.MaxUses.ToString()),
|
||||
("ActiveCooldowns", skill.ActiveCooldowns.ToString()),
|
||||
("TotalUses", skill.TotalUses.ToString()),
|
||||
};
|
||||
|
||||
// Add raw hex dump rows (16 bytes per line, from true object base)
|
||||
if (skill.RawBytes is { Length: > 0 })
|
||||
{
|
||||
detailsList.Add(("———", $"Raw dump (base=0x{skill.Address:X})"));
|
||||
for (var off = 0; off < skill.RawBytes.Length; off += 16)
|
||||
{
|
||||
var len = Math.Min(16, skill.RawBytes.Length - off);
|
||||
var hex = BitConverter.ToString(skill.RawBytes, off, len).Replace('-', ' ');
|
||||
var i32 = off + 4 <= skill.RawBytes.Length
|
||||
? BitConverter.ToInt32(skill.RawBytes, off) : 0;
|
||||
var i64 = off + 8 <= skill.RawBytes.Length
|
||||
? BitConverter.ToInt64(skill.RawBytes, off) : 0L;
|
||||
var interp = $"i32={i32} i64=0x{i64:X}";
|
||||
detailsList.Add(($"+0x{off:X2}", $"{hex} ({interp})"));
|
||||
}
|
||||
}
|
||||
|
||||
var details = detailsList.ToArray();
|
||||
|
||||
MemoryNodeViewModel skillNode;
|
||||
if (i < _skillsNode.Children.Count)
|
||||
{
|
||||
_skillsNode.Children[i].Name = label;
|
||||
_skillsNode.Children[i].Value = value;
|
||||
_skillsNode.Children[i].ValueColor = color;
|
||||
skillNode = _skillsNode.Children[i];
|
||||
skillNode.Name = $"{name} ({slotTag})";
|
||||
skillNode.Value = summary;
|
||||
skillNode.ValueColor = color;
|
||||
}
|
||||
else
|
||||
{
|
||||
var node = new MemoryNodeViewModel(label) { Value = value, ValueColor = color };
|
||||
_skillsNode.Children.Add(node);
|
||||
skillNode = new MemoryNodeViewModel($"{name} ({slotTag})")
|
||||
{
|
||||
Value = summary, ValueColor = color, IsExpanded = false
|
||||
};
|
||||
_skillsNode.Children.Add(skillNode);
|
||||
}
|
||||
|
||||
// Update detail children
|
||||
while (skillNode.Children.Count > details.Length)
|
||||
skillNode.Children.RemoveAt(skillNode.Children.Count - 1);
|
||||
|
||||
for (var d = 0; d < details.Length; d++)
|
||||
{
|
||||
var (label, val) = details[d];
|
||||
if (d < skillNode.Children.Count)
|
||||
{
|
||||
skillNode.Children[d].Name = $"{label}:";
|
||||
skillNode.Children[d].Value = val;
|
||||
skillNode.Children[d].ValueColor = "#8b949e";
|
||||
}
|
||||
else
|
||||
{
|
||||
skillNode.Children.Add(new MemoryNodeViewModel($"{label}:")
|
||||
{ Value = val, ValueColor = "#8b949e" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -505,6 +559,51 @@ public partial class MemoryViewModel : ObservableObject
|
|||
}
|
||||
}
|
||||
|
||||
// Quest states with rich info from companion vector
|
||||
if (_questsNode is not null)
|
||||
{
|
||||
if (snap.QuestFlags is { Count: > 0 })
|
||||
{
|
||||
var named = snap.QuestFlags.Count(q => q.QuestName is not null);
|
||||
_questsNode.Value = $"{snap.QuestFlags.Count} quest states ({named} named)";
|
||||
_questsNode.ValueColor = "#3fb950";
|
||||
|
||||
while (_questsNode.Children.Count > snap.QuestFlags.Count)
|
||||
_questsNode.Children.RemoveAt(_questsNode.Children.Count - 1);
|
||||
|
||||
for (var i = 0; i < snap.QuestFlags.Count; i++)
|
||||
{
|
||||
var q = snap.QuestFlags[i];
|
||||
var trackedPrefix = q.IsTracked ? "[T] " : "";
|
||||
var stateLabel = q.StateId switch { 1 => "locked", 2 => "started", _ => $"s{q.StateId}" };
|
||||
var label = $"{trackedPrefix}{q.QuestName ?? (q.QuestStateIndex > 0 ? $"#{q.QuestStateIndex}" : $"[{i}]")}";
|
||||
var value = q.InternalId is not null
|
||||
? $"idx={q.QuestStateIndex} {stateLabel} id={q.InternalId}"
|
||||
: $"idx={q.QuestStateIndex} {stateLabel}";
|
||||
|
||||
var color = q.IsTracked ? "#58a6ff" : q.StateId == 2 ? "#8b949e" : "#484f58";
|
||||
|
||||
if (i < _questsNode.Children.Count)
|
||||
{
|
||||
_questsNode.Children[i].Name = label;
|
||||
_questsNode.Children[i].Value = value;
|
||||
_questsNode.Children[i].ValueColor = color;
|
||||
}
|
||||
else
|
||||
{
|
||||
var node = new MemoryNodeViewModel(label) { Value = value, ValueColor = color };
|
||||
_questsNode.Children.Add(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_questsNode.Value = "—";
|
||||
_questsNode.ValueColor = "#484f58";
|
||||
_questsNode.Children.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Entities
|
||||
if (snap.Entities is { Count: > 0 })
|
||||
{
|
||||
|
|
@ -1220,4 +1319,40 @@ public partial class MemoryViewModel : ObservableObject
|
|||
|
||||
ScanResult = _reader.Diagnostics!.ScanQuestFlags();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ScanUiElementsExecute()
|
||||
{
|
||||
if (_reader is null || !_reader.IsAttached)
|
||||
{
|
||||
ScanResult = "Error: not attached";
|
||||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.Diagnostics!.ScanUiElements();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ScanUiTextExecute()
|
||||
{
|
||||
if (_reader is null || !_reader.IsAttached)
|
||||
{
|
||||
ScanResult = "Error: not attached";
|
||||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.Diagnostics!.ScanUiElementText();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ScanQuestObjectsExecute()
|
||||
{
|
||||
if (_reader is null || !_reader.IsAttached)
|
||||
{
|
||||
ScanResult = "Error: not attached";
|
||||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.Diagnostics!.ScanQuestStateObjects();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -778,6 +778,12 @@
|
|||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Quest Flags" Command="{Binding ScanQuestFlagsExecuteCommand}"
|
||||
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>
|
||||
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
|
||||
FontSize="10" Foreground="#e6edf3" Background="#0d1117"
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@ namespace Roboto.Core;
|
|||
|
||||
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; }
|
||||
/// <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; }
|
||||
/// <summary>True if this quest is the currently tracked/active quest in the UI.</summary>
|
||||
public bool IsTracked { get; init; }
|
||||
public string? StateText { get; init; }
|
||||
public string? ProgressText { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,26 @@ public record SkillState
|
|||
{
|
||||
public int SlotIndex { get; init; }
|
||||
public ushort ScanCode { get; init; }
|
||||
public short SkillId { get; init; }
|
||||
public ushort SkillId { get; init; }
|
||||
public ushort Id2 { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? InternalName { get; init; }
|
||||
public int UseStage { get; init; }
|
||||
public int CastType { get; init; }
|
||||
public int CooldownTimeMs { get; init; }
|
||||
public int SkillBarSlot { get; init; } = -1;
|
||||
public int ChargesCurrent { get; init; }
|
||||
public int ChargesMax { get; init; }
|
||||
public float CooldownRemaining { get; init; }
|
||||
public bool CanBeUsed { get; init; }
|
||||
public bool CanUse => CanBeUsed && CooldownRemaining <= 0;
|
||||
|
||||
// Derived properties
|
||||
public bool IsUsing => UseStage >= 2;
|
||||
public bool IsUsingOrCharging => UseStage >= 1;
|
||||
public bool IsChanneling => CastType == 10;
|
||||
public bool IsOnSkillBar => SkillBarSlot >= 0;
|
||||
public bool IsOnCooldown => ChargesMax > 0 && ChargesCurrent <= 0;
|
||||
public int RemainingUses => ChargesMax > 0 ? ChargesCurrent : 0;
|
||||
public float CooldownSeconds => CooldownTimeMs / 1000f;
|
||||
public bool CanUse => CanBeUsed && !IsOnCooldown;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public sealed class MemoryPoller : IDisposable
|
|||
private Thread? _thread;
|
||||
private volatile bool _running;
|
||||
private bool _disposed;
|
||||
private int _lastQuestCount;
|
||||
|
||||
// Cached resolved addresses (re-resolved on each cold tick)
|
||||
private nint _cameraMatrixAddr;
|
||||
|
|
@ -276,12 +277,23 @@ public sealed class MemoryPoller : IDisposable
|
|||
ManaTotal = snap.ManaTotal,
|
||||
EsCurrent = snap.EsCurrent,
|
||||
EsTotal = snap.EsTotal,
|
||||
Skills = snap.PlayerSkills?.Select((s, i) => new SkillState
|
||||
Skills = snap.PlayerSkills?
|
||||
.Where(s => s.SkillBarSlot >= 0)
|
||||
.Select(s => new SkillState
|
||||
{
|
||||
SlotIndex = i,
|
||||
Name = s.Name,
|
||||
SlotIndex = s.SkillBarSlot,
|
||||
SkillId = s.Id,
|
||||
Id2 = s.Id2,
|
||||
Name = StripPlayerSuffix(s.Name),
|
||||
InternalName = s.InternalName,
|
||||
UseStage = s.UseStage,
|
||||
CastType = s.CastType,
|
||||
CooldownTimeMs = s.CooldownTimeMs,
|
||||
SkillBarSlot = s.SkillBarSlot,
|
||||
ChargesCurrent = Math.Max(0, s.MaxUses - s.ActiveCooldowns),
|
||||
ChargesMax = s.MaxUses,
|
||||
CooldownRemaining = s.ActiveCooldowns > 0 ? s.CooldownTimeMs / 1000f : 0f,
|
||||
CanBeUsed = s.CanBeUsed,
|
||||
CooldownRemaining = s.ActiveCooldowns > 0 ? s.CooldownTimeMs : 0,
|
||||
}).ToList() ?? [],
|
||||
};
|
||||
|
||||
|
|
@ -321,11 +333,21 @@ public sealed class MemoryPoller : IDisposable
|
|||
{
|
||||
state.ActiveQuests = snap.QuestFlags.Select(q => new QuestProgress
|
||||
{
|
||||
QuestStateIndex = q.QuestStateIndex,
|
||||
QuestName = q.QuestName,
|
||||
InternalId = q.InternalId,
|
||||
StateId = q.StateId,
|
||||
IsTracked = q.IsTracked,
|
||||
StateText = q.StateText,
|
||||
ProgressText = q.ProgressText,
|
||||
}).ToList();
|
||||
|
||||
if (_lastQuestCount != snap.QuestFlags.Count)
|
||||
{
|
||||
var indices = string.Join(", ", snap.QuestFlags.Select(q => q.QuestStateIndex));
|
||||
Log.Debug("Quest state indices ({Count}): [{Indices}]", snap.QuestFlags.Count, indices);
|
||||
_lastQuestCount = snap.QuestFlags.Count;
|
||||
}
|
||||
}
|
||||
|
||||
if (snap.Terrain is not null)
|
||||
|
|
@ -341,6 +363,14 @@ public sealed class MemoryPoller : IDisposable
|
|||
return state;
|
||||
}
|
||||
|
||||
private static string? StripPlayerSuffix(string? name)
|
||||
{
|
||||
if (name is null) return null;
|
||||
if (name.EndsWith("Player", StringComparison.Ordinal))
|
||||
return name[..^6];
|
||||
return name;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
|
|
|||
|
|
@ -27,20 +27,22 @@ public struct ActiveSkillEntry
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of an active skill, reached by following ActiveSkillEntry.ActiveSkillPtr.
|
||||
/// From ExileCore2 GameOffsets.Objects.Components.ActiveSkillDetails.
|
||||
/// Details of an active skill. The shared_ptr in the ActiveSkills vector points
|
||||
/// 0x10 bytes into the object (past vtable + UseStage/CastType), so we read from
|
||||
/// ActiveSkillPtr - 0x10 and all offsets are relative to the true object base.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Explicit, Pack = 1)]
|
||||
public struct ActiveSkillDetails
|
||||
{
|
||||
[FieldOffset(0x00)] public nint Vtable;
|
||||
[FieldOffset(0x08)] public int UseStage;
|
||||
[FieldOffset(0x0C)] public int CastType;
|
||||
[FieldOffset(0x10)] public uint UnknownIdAndEquipmentInfo;
|
||||
[FieldOffset(0x18)] public nint GrantedEffectsPerLevelDatRow;
|
||||
[FieldOffset(0x20)] public nint ActiveSkillsDatPtr;
|
||||
[FieldOffset(0x30)] public nint GrantedEffectStatSetsPerLevelDatRow;
|
||||
[FieldOffset(0x98)] public int TotalUses;
|
||||
[FieldOffset(0xA8)] public int TotalCooldownTimeInMs;
|
||||
[FieldOffset(0x20)] public uint UnknownIdAndEquipmentInfo;
|
||||
[FieldOffset(0x28)] public nint GrantedEffectsPerLevelDatRow;
|
||||
[FieldOffset(0x30)] public nint ActiveSkillsDatPtr;
|
||||
[FieldOffset(0x40)] public nint GrantedEffectStatSetsPerLevelDatRow;
|
||||
[FieldOffset(0xA8)] public int TotalUses;
|
||||
[FieldOffset(0xB8)] public int TotalCooldownTimeInMs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public class GameMemoryReader : IDisposable
|
|||
private RttiResolver? _rtti;
|
||||
private SkillReader? _skills;
|
||||
private QuestReader? _quests;
|
||||
private QuestNameLookup? _questNames;
|
||||
|
||||
public ObjectRegistry Registry => _registry;
|
||||
public MemoryDiagnostics? Diagnostics { get; private set; }
|
||||
|
|
@ -100,7 +101,8 @@ public class GameMemoryReader : IDisposable
|
|||
_entities = new EntityReader(_ctx, _components, _strings);
|
||||
_terrain = new TerrainReader(_ctx);
|
||||
_skills = new SkillReader(_ctx, _components, _strings);
|
||||
_quests = new QuestReader(_ctx, _strings);
|
||||
_questNames ??= LoadQuestNames();
|
||||
_quests = new QuestReader(_ctx, _strings, _questNames);
|
||||
Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
|
||||
|
||||
return true;
|
||||
|
|
@ -119,9 +121,21 @@ public class GameMemoryReader : IDisposable
|
|||
_rtti = null;
|
||||
_skills = null;
|
||||
_quests = null;
|
||||
// _questNames intentionally kept — reloaded only once
|
||||
Diagnostics = null;
|
||||
}
|
||||
|
||||
private static QuestNameLookup? LoadQuestNames()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "quest_names.json");
|
||||
if (!File.Exists(path))
|
||||
path = "quest_names.json"; // fallback to working directory
|
||||
|
||||
var lookup = new QuestNameLookup();
|
||||
lookup.Load(path);
|
||||
return lookup.IsLoaded ? lookup : null;
|
||||
}
|
||||
|
||||
public GameStateSnapshot ReadSnapshot()
|
||||
{
|
||||
var snap = new GameStateSnapshot();
|
||||
|
|
@ -223,7 +237,17 @@ public class GameMemoryReader : IDisposable
|
|||
_components.ReadPlayerVitals(snap);
|
||||
_components.ReadPlayerPosition(snap);
|
||||
snap.CharacterName = _components.ReadPlayerName(snap.LocalPlayerPtr);
|
||||
snap.PlayerSkills = _skills!.ReadPlayerSkills(snap.LocalPlayerPtr);
|
||||
|
||||
// Resolve PSD for skill bar + quest reads
|
||||
nint psdPtr = 0;
|
||||
if (snap.ServerDataPtr != 0)
|
||||
{
|
||||
var psdVecBegin = mem.ReadPointer(snap.ServerDataPtr + offsets.PlayerServerDataOffset);
|
||||
if (psdVecBegin != 0)
|
||||
psdPtr = mem.ReadPointer(psdVecBegin);
|
||||
}
|
||||
|
||||
snap.PlayerSkills = _skills!.ReadPlayerSkills(snap.LocalPlayerPtr, psdPtr);
|
||||
snap.QuestFlags = _quests!.ReadQuestFlags(snap.ServerDataPtr);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,68 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Serilog;
|
||||
|
||||
namespace Roboto.Memory;
|
||||
|
||||
/// <summary>
|
||||
/// Reads/writes int as hex strings ("0x1A8") or plain numbers (424).
|
||||
/// On write, values >= 16 are emitted as "0xHEX", smaller values as plain numbers.
|
||||
/// </summary>
|
||||
internal sealed class HexIntConverter : JsonConverter<int>
|
||||
{
|
||||
public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Number)
|
||||
return reader.GetInt32();
|
||||
|
||||
var s = reader.GetString();
|
||||
if (s is null) return 0;
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return int.Parse(s.AsSpan(2), NumberStyles.HexNumber);
|
||||
return int.Parse(s);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value >= 16)
|
||||
writer.WriteStringValue($"0x{value:X}");
|
||||
else
|
||||
writer.WriteNumberValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Same as HexIntConverter but for uint (e.g. QuestTrackedMarker).</summary>
|
||||
internal sealed class HexUintConverter : JsonConverter<uint>
|
||||
{
|
||||
public override uint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Number)
|
||||
return reader.GetUInt32();
|
||||
|
||||
var s = reader.GetString();
|
||||
if (s is null) return 0;
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return uint.Parse(s.AsSpan(2), NumberStyles.HexNumber);
|
||||
return uint.Parse(s);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, uint value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value >= 16)
|
||||
writer.WriteStringValue($"0x{value:X}");
|
||||
else
|
||||
writer.WriteNumberValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GameOffsets
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Converters = { new HexIntConverter(), new HexUintConverter() }
|
||||
};
|
||||
|
||||
public string ProcessName { get; set; } = "PathOfExileSteam";
|
||||
|
|
@ -60,9 +113,9 @@ public sealed class GameOffsets
|
|||
public int AreaLevelStaticOffset { get; set; } = 0;
|
||||
/// <summary>AreaInstance → CurrentAreaHash uint (dump: 0xEC).</summary>
|
||||
public int AreaHashOffset { get; set; } = 0xEC;
|
||||
/// <summary>AreaInstance → ServerData pointer (dump: 0x9F0 via LocalPlayerStruct.ServerDataPtr).</summary>
|
||||
public int ServerDataOffset { get; set; } = 0x9F0;
|
||||
/// <summary>AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr).</summary>
|
||||
/// <summary>AreaInstance → ServerData pointer. Heap object with vtable, StdVector at +0x50 for PlayerServerData.</summary>
|
||||
public int ServerDataOffset { get; set; } = 0xA08;
|
||||
/// <summary>AreaInstance → LocalPlayer entity pointer.</summary>
|
||||
public int LocalPlayerDirectOffset { get; set; } = 0xA10;
|
||||
/// <summary>AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50).</summary>
|
||||
public int EntityListOffset { get; set; } = 0xB50;
|
||||
|
|
@ -90,24 +143,56 @@ public sealed class GameOffsets
|
|||
// ServerData → fields
|
||||
/// <summary>ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).</summary>
|
||||
public int LocalPlayerOffset { get; set; } = 0x20;
|
||||
/// <summary>ServerData → PlayerServerData pointer (PerPlayerServerData struct).</summary>
|
||||
public int PlayerServerDataOffset { get; set; } = 0x50;
|
||||
/// <summary>PlayerServerData → QuestFlags container offset (PerPlayerServerDataOffsets: 0x230 = 560).</summary>
|
||||
public int QuestFlagsOffset { get; set; } = 0x230;
|
||||
/// <summary>Size of each quest flag entry in bytes. 0 = disabled (offsets not yet discovered via CE).</summary>
|
||||
public int QuestFlagEntrySize { get; set; } = 0;
|
||||
/// <summary>Offset within each quest entry to the Quest.dat row pointer.</summary>
|
||||
/// <summary>ServerData → PlayerServerData StdVector (begin/end/cap). ExileCore says 0x50 but current binary has it at 0x48.</summary>
|
||||
public int PlayerServerDataOffset { get; set; } = 0x48;
|
||||
/// <summary>PSD → SkillBarIds: Buffer13 of (ushort Id, ushort Id2). 13 slots × 4 bytes = 52 bytes. Discovered via skillbar_scanner.py.</summary>
|
||||
public int SkillBarIdsOffset { get; set; } = 0x71A8;
|
||||
|
||||
/// <summary>PSD → QuestState int32 index vector (StdVector of int32). CE confirmed: 0x308.</summary>
|
||||
public int QuestFlagsOffset { get; set; } = 0x308;
|
||||
/// <summary>Size of each quest flag entry in bytes. 4 = int32 QuestState.dat row index.</summary>
|
||||
public int QuestFlagEntrySize { get; set; } = 4;
|
||||
/// <summary>Offset within each quest entry to the Quest.dat row pointer (legacy pointer mode, 0 = N/A).</summary>
|
||||
public int QuestEntryQuestPtrOffset { get; set; } = 0;
|
||||
/// <summary>Offset within each quest entry to the byte state ID.</summary>
|
||||
/// <summary>Offset within each quest entry to the byte state ID (legacy pointer mode, 0 = N/A).</summary>
|
||||
public int QuestEntryStateIdOffset { get; set; } = 0;
|
||||
/// <summary>Offset within each quest entry to the wchar* state text pointer.</summary>
|
||||
/// <summary>Offset within each quest entry to the wchar* state text pointer (legacy pointer mode, 0 = N/A).</summary>
|
||||
public int QuestEntryStateTextOffset { get; set; } = 0;
|
||||
/// <summary>Offset within each quest entry to the wchar* progress text pointer.</summary>
|
||||
/// <summary>Offset within each quest entry to the wchar* progress text pointer (legacy pointer mode, 0 = N/A).</summary>
|
||||
public int QuestEntryProgressTextOffset { get; set; } = 0;
|
||||
/// <summary>Container type for quest flags: "vector" or "map".</summary>
|
||||
public string QuestFlagsContainerType { get; set; } = "vector";
|
||||
/// <summary>Container type: "int_vector" = flat int32 array of QuestState.dat indices, "vector" = struct entries with pointers.</summary>
|
||||
public string QuestFlagsContainerType { get; set; } = "int_vector";
|
||||
/// <summary>Maximum number of quest entries to read (sanity limit).</summary>
|
||||
public int QuestFlagsMaxEntries { get; set; } = 128;
|
||||
public int QuestFlagsMaxEntries { get; set; } = 256;
|
||||
/// <summary>PSD offset to quest struct count field (QF+0x020 = PSD+0x250). 0 = disabled.</summary>
|
||||
public int QuestCountOffset { get; set; } = 0x250;
|
||||
|
||||
// ── QuestFlags companion vector (QF+0x018): 24-byte structs ──
|
||||
// Layout: +0x00 int32 QuestStateId, +0x04 uint32 TrackedFlag, +0x10 ptr QuestStateObj
|
||||
/// <summary>Offset from QuestFlags to companion StdVector (begin/end/cap). 0x18 = QF+0x018.</summary>
|
||||
public int QuestCompanionOffset { get; set; } = 0x18;
|
||||
/// <summary>Size of each companion entry in bytes.</summary>
|
||||
public int QuestCompanionEntrySize { get; set; } = 24;
|
||||
/// <summary>Offset within companion entry to the QuestStates.dat row pointer (legacy, broken for POE2).</summary>
|
||||
public int QuestCompanionDatPtrOffset { get; set; } = 0x10;
|
||||
/// <summary>Offset within companion entry to the tracked flag uint32. Value == QuestTrackedMarker means tracked. 0x04.</summary>
|
||||
public int QuestCompanionTrackedOffset { get; set; } = 0x04;
|
||||
/// <summary>Offset within companion entry to the quest state object pointer. 0x10.</summary>
|
||||
public int QuestCompanionObjPtrOffset { get; set; } = 0x10;
|
||||
/// <summary>uint32 marker value indicating a quest is currently tracked in the UI. 0x43020000 = float 130.0.</summary>
|
||||
public uint QuestTrackedMarker { get; set; } = 0x43020000;
|
||||
/// <summary>Offset within the quest state object to the encounter state byte (1=locked, 2=started). 0x08.</summary>
|
||||
public int QuestObjEncounterStateOffset { get; set; } = 0x08;
|
||||
|
||||
// ── QuestStates.dat row layout (119 bytes, non-aligned fields) ──
|
||||
/// <summary>Size of each .dat row in bytes. 0x77 = 119. 0 = name resolution disabled.</summary>
|
||||
public int QuestDatRowSize { get; set; } = 0x77;
|
||||
/// <summary>Dat row → Quest display name wchar* pointer.</summary>
|
||||
public int QuestDatNameOffset { get; set; } = 0x00;
|
||||
/// <summary>Dat row → Internal quest ID wchar* pointer (e.g. "TreeOfSouls2").</summary>
|
||||
public int QuestDatInternalIdOffset { get; set; } = 0x6B;
|
||||
/// <summary>Dat row → Act/phase number int32.</summary>
|
||||
public int QuestDatActOffset { get; set; } = 0x73;
|
||||
|
||||
// ── Entity / Component ──
|
||||
public int ComponentListOffset { get; set; } = 0x10;
|
||||
|
|
@ -150,6 +235,36 @@ public sealed class GameOffsets
|
|||
/// <summary>Offset within Camera struct to the Matrix4x4 (64 bytes). 0 = disabled.</summary>
|
||||
public int CameraMatrixOffset { get; set; } = 0x1A0;
|
||||
|
||||
// ── UiRootStruct (InGameState → UI tree roots) ──
|
||||
/// <summary>Offset from InGameState to UiRootStruct pointer. GameOverlay2: 0x340.</summary>
|
||||
public int UiRootStructOffset { get; set; } = 0x340;
|
||||
/// <summary>Offset within UiRootStruct to UiRoot UIElement pointer. GameOffsetsNew: 0x5B8.</summary>
|
||||
public int UiRootPtrOffset { get; set; } = 0x5B8;
|
||||
/// <summary>Offset within UiRootStruct to GameUi UIElement pointer. GameOffsetsNew: 0xBE0.</summary>
|
||||
public int GameUiPtrOffset { get; set; } = 0xBE0;
|
||||
/// <summary>Offset within UiRootStruct to GameUiController pointer. GameOffsetsNew: 0xBE8.</summary>
|
||||
public int GameUiControllerPtrOffset { get; set; } = 0xBE8;
|
||||
|
||||
// ── UIElement offsets (GameOffsetsNew UiElementBaseOffset) ──
|
||||
/// <summary>UIElement → Self pointer (validation: should equal element address). 0x08.</summary>
|
||||
public int UiElementSelfOffset { get; set; } = 0x08;
|
||||
/// <summary>UIElement → StdVector of child UIElement pointers (begin/end/cap). 0x10.</summary>
|
||||
public int UiElementChildrenOffset { get; set; } = 0x10;
|
||||
/// <summary>UIElement → Parent UIElement pointer. 0xB8.</summary>
|
||||
public int UiElementParentOffset { get; set; } = 0xB8;
|
||||
/// <summary>UIElement → StdWString StringId (inline MSVC std::wstring). 0x98.</summary>
|
||||
public int UiElementStringIdOffset { get; set; } = 0x98;
|
||||
/// <summary>UIElement → Flags uint32 (visibility at bit 0x0B). 0x180.</summary>
|
||||
public int UiElementFlagsOffset { get; set; } = 0x180;
|
||||
/// <summary>Bit position for IsVisible in UIElement Flags. 0x0B = bit 11.</summary>
|
||||
public int UiElementVisibleBit { get; set; } = 0x0B;
|
||||
/// <summary>UIElement → UnscaledSize (float, float). 0x288.</summary>
|
||||
public int UiElementSizeOffset { get; set; } = 0x288;
|
||||
/// <summary>UIElement → Display text StdWString. 0x448. Not all elements have text.</summary>
|
||||
public int UiElementTextOffset { get; set; } = 0x448;
|
||||
/// <summary>How many bytes to scan from InGameState for UIElement pointers (0x1000 = 4KB).</summary>
|
||||
public int UiElementScanRange { get; set; } = 0x1000;
|
||||
|
||||
// ── Terrain (inline in AreaInstance) ──
|
||||
/// <summary>Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).</summary>
|
||||
public int TerrainListOffset { get; set; } = 0xCC0;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
67
src/Roboto.Memory/QuestNameLookup.cs
Normal file
67
src/Roboto.Memory/QuestNameLookup.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Text.Json;
|
||||
using Serilog;
|
||||
|
||||
namespace Roboto.Memory;
|
||||
|
||||
/// <summary>
|
||||
/// Loads quest name mappings from a JSON file (generated by tools/dump_quest_names.py).
|
||||
/// Provides QuestStateId → (name, internalId, act) lookup.
|
||||
/// </summary>
|
||||
public sealed class QuestNameLookup
|
||||
{
|
||||
private readonly Dictionary<int, QuestNameEntry> _entries = new();
|
||||
private bool _loaded;
|
||||
|
||||
public record QuestNameEntry(string? Name, string? InternalId, int Act);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to load quest names from the given JSON path.
|
||||
/// File format: { "0": { "name": "...", "internalId": "...", "act": 1 }, ... }
|
||||
/// </summary>
|
||||
public void Load(string path)
|
||||
{
|
||||
_entries.Clear();
|
||||
_loaded = false;
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Debug("Quest names file not found: {Path}", path);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!int.TryParse(prop.Name, out var idx))
|
||||
continue;
|
||||
|
||||
var obj = prop.Value;
|
||||
var name = obj.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String
|
||||
? n.GetString() : null;
|
||||
var internalId = obj.TryGetProperty("internalId", out var id) && id.ValueKind == JsonValueKind.String
|
||||
? id.GetString() : null;
|
||||
var act = obj.TryGetProperty("act", out var a) && a.ValueKind == JsonValueKind.Number
|
||||
? a.GetInt32() : 0;
|
||||
|
||||
_entries[idx] = new QuestNameEntry(name, internalId, act);
|
||||
}
|
||||
|
||||
_loaded = true;
|
||||
Log.Information("Loaded {Count} quest names from {Path}", _entries.Count, path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to load quest names from {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLoaded => _loaded;
|
||||
public int Count => _entries.Count;
|
||||
|
||||
public bool TryGet(int questStateIndex, out QuestNameEntry? entry)
|
||||
=> _entries.TryGetValue(questStateIndex, out entry);
|
||||
}
|
||||
|
|
@ -8,32 +8,42 @@ namespace Roboto.Memory;
|
|||
/// </summary>
|
||||
public sealed class QuestSnapshot
|
||||
{
|
||||
/// <summary>QuestState.dat row index (int_vector mode) or 0 (pointer mode).</summary>
|
||||
public int QuestStateIndex { get; init; }
|
||||
public nint QuestDatPtr { get; init; }
|
||||
public string? QuestName { get; init; }
|
||||
/// <summary>Internal quest ID from dat row (e.g. "TreeOfSouls2", "IncursionQuest1_Act1").</summary>
|
||||
public string? InternalId { get; init; }
|
||||
/// <summary>Encounter state from quest state object: 1=locked/not encountered, 2=available/started.</summary>
|
||||
public byte StateId { get; init; }
|
||||
/// <summary>True if this quest is the currently tracked/active quest in the UI.</summary>
|
||||
public bool IsTracked { get; init; }
|
||||
public string? StateText { get; init; }
|
||||
public string? ProgressText { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads quest flags from ServerData → PlayerServerData → QuestFlags vector.
|
||||
/// Follows the same pattern as SkillReader: bulk-reads vector data, resolves
|
||||
/// quest names by following dat row pointers, caches results.
|
||||
/// When QuestFlagEntrySize == 0 (offsets not yet discovered), gracefully returns null.
|
||||
/// Reads quest flags from ServerData → PlayerServerData → QuestFlags.
|
||||
/// Supports two modes:
|
||||
/// - "int_vector": flat StdVector of int32 QuestState.dat row indices (POE2)
|
||||
/// - "vector": struct entries with dat row pointers and string fields (POE1/legacy)
|
||||
/// When QuestFlagEntrySize == 0, gracefully returns null.
|
||||
/// </summary>
|
||||
public sealed class QuestReader
|
||||
{
|
||||
private readonly MemoryContext _ctx;
|
||||
private readonly MsvcStringReader _strings;
|
||||
private readonly QuestNameLookup? _nameLookup;
|
||||
|
||||
// Name cache — quest names are static, only refresh on ServerData change
|
||||
private readonly Dictionary<nint, string?> _nameCache = new();
|
||||
private nint _lastServerData;
|
||||
private nint _lastPsd;
|
||||
|
||||
public QuestReader(MemoryContext ctx, MsvcStringReader strings)
|
||||
public QuestReader(MemoryContext ctx, MsvcStringReader strings, QuestNameLookup? nameLookup = null)
|
||||
{
|
||||
_ctx = ctx;
|
||||
_strings = strings;
|
||||
_nameLookup = nameLookup;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -45,43 +55,181 @@ public sealed class QuestReader
|
|||
if (serverDataPtr == 0) return null;
|
||||
|
||||
var offsets = _ctx.Offsets;
|
||||
|
||||
// Guard: entry size 0 means offsets not yet discovered via CE
|
||||
if (offsets.QuestFlagEntrySize <= 0) return null;
|
||||
|
||||
var mem = _ctx.Memory;
|
||||
|
||||
// ServerData+0x50 is a StdVector of pointers to PerPlayerServerData structs.
|
||||
// Read vector begin, then dereference to get the first entry.
|
||||
// ServerData → PlayerServerData StdVector (vector of pointers, deref [0])
|
||||
var psdVecBegin = mem.ReadPointer(serverDataPtr + offsets.PlayerServerDataOffset);
|
||||
if (psdVecBegin == 0) return null;
|
||||
|
||||
// Dereference: vector[0] is a pointer to the actual PerPlayerServerData struct
|
||||
var playerServerData = mem.ReadPointer(psdVecBegin);
|
||||
if (playerServerData == 0) return null;
|
||||
|
||||
// Invalidate name cache on ServerData change (area transition)
|
||||
if (playerServerData != _lastServerData)
|
||||
// Invalidate cache on PSD change (area transition)
|
||||
if (playerServerData != _lastPsd)
|
||||
{
|
||||
_nameCache.Clear();
|
||||
_lastServerData = playerServerData;
|
||||
_lastPsd = playerServerData;
|
||||
}
|
||||
|
||||
// PerPlayerServerData → QuestFlags (+0x230)
|
||||
var questFlagsAddr = playerServerData + offsets.QuestFlagsOffset;
|
||||
|
||||
if (offsets.QuestFlagsContainerType == "vector")
|
||||
return ReadVectorQuests(questFlagsAddr, offsets);
|
||||
if (offsets.QuestFlagsContainerType == "int_vector")
|
||||
return ReadIntVectorQuests(questFlagsAddr, offsets);
|
||||
|
||||
if (offsets.QuestFlagsContainerType == "vector")
|
||||
return ReadStructVectorQuests(questFlagsAddr, offsets);
|
||||
|
||||
// Future: "map" container type
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<QuestSnapshot>? ReadVectorQuests(nint questFlagsAddr, GameOffsets offsets)
|
||||
/// <summary>
|
||||
/// POE2 mode: reads a StdVector of int32 QuestState.dat row indices (QF+0x000),
|
||||
/// plus a companion StdVector of 24-byte structs (QF+0x018) that contain:
|
||||
/// +0x00 int32: QuestStateId (= .dat row index, same as int32 vector value)
|
||||
/// +0x04 uint32: TrackedFlag (0x43020000 = currently tracked quest in UI, 0 = not tracked)
|
||||
/// +0x10 ptr: Quest state object (runtime object, NOT a .dat row)
|
||||
/// The quest state object has encounter state at +0x008 (byte: 1=locked, 2=started).
|
||||
/// Quest names are resolved from the .dat table base if configured.
|
||||
/// </summary>
|
||||
private List<QuestSnapshot>? ReadIntVectorQuests(nint questFlagsAddr, GameOffsets offsets)
|
||||
{
|
||||
var mem = _ctx.Memory;
|
||||
|
||||
// Read int32 index vector (QF+0x000)
|
||||
var vecBegin = mem.ReadPointer(questFlagsAddr);
|
||||
var vecEnd = mem.ReadPointer(questFlagsAddr + 8);
|
||||
if (vecBegin == 0 || vecEnd <= vecBegin) return null;
|
||||
|
||||
var totalBytes = (int)(vecEnd - vecBegin);
|
||||
var entryCount = totalBytes / 4; // int32 = 4 bytes
|
||||
if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null;
|
||||
|
||||
var vecData = mem.ReadBytes(vecBegin, totalBytes);
|
||||
if (vecData is null) return null;
|
||||
|
||||
// Read companion vector (QF+0x018) for quest state objects
|
||||
byte[]? compData = null;
|
||||
var compEntryCount = 0;
|
||||
if (offsets.QuestCompanionOffset > 0 && offsets.QuestCompanionEntrySize > 0)
|
||||
{
|
||||
var compBegin = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset);
|
||||
var compEnd = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset + 8);
|
||||
if (compBegin != 0 && compEnd > compBegin)
|
||||
{
|
||||
var compBytes = (int)(compEnd - compBegin);
|
||||
compEntryCount = compBytes / offsets.QuestCompanionEntrySize;
|
||||
if (compEntryCount > 0 && compBytes < 0x100000)
|
||||
compData = mem.ReadBytes(compBegin, compBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Find .dat table base if configured (for quest name resolution)
|
||||
var datTableBase = FindDatTableBase(offsets);
|
||||
|
||||
var result = new List<QuestSnapshot>(entryCount);
|
||||
|
||||
for (var i = 0; i < entryCount; i++)
|
||||
{
|
||||
var idx = BitConverter.ToInt32(vecData, i * 4);
|
||||
string? questName = null;
|
||||
string? internalId = null;
|
||||
byte stateId = 0;
|
||||
bool isTracked = false;
|
||||
nint questObjPtr = 0;
|
||||
|
||||
if (compData is not null && i < compEntryCount)
|
||||
{
|
||||
var compOff = i * offsets.QuestCompanionEntrySize;
|
||||
|
||||
// Read tracked flag from companion +0x04
|
||||
if (offsets.QuestCompanionTrackedOffset > 0 &&
|
||||
compOff + offsets.QuestCompanionTrackedOffset + 4 <= compData.Length)
|
||||
{
|
||||
var trackedVal = BitConverter.ToUInt32(compData, compOff + offsets.QuestCompanionTrackedOffset);
|
||||
isTracked = trackedVal == offsets.QuestTrackedMarker;
|
||||
}
|
||||
|
||||
// Read quest state object pointer from companion +0x10
|
||||
if (compOff + offsets.QuestCompanionObjPtrOffset + 8 <= compData.Length)
|
||||
{
|
||||
questObjPtr = (nint)BitConverter.ToInt64(compData, compOff + offsets.QuestCompanionObjPtrOffset);
|
||||
|
||||
// Read encounter state byte from quest state object +0x008
|
||||
if (questObjPtr != 0 && ((ulong)questObjPtr >> 32) is > 0 and < 0x7FFF
|
||||
&& offsets.QuestObjEncounterStateOffset > 0)
|
||||
{
|
||||
var stateByte = mem.ReadBytes(questObjPtr + offsets.QuestObjEncounterStateOffset, 1);
|
||||
if (stateByte is { Length: 1 })
|
||||
stateId = stateByte[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve quest name: try .dat table first, then JSON lookup fallback
|
||||
if (datTableBase != 0 && offsets.QuestDatRowSize > 0)
|
||||
{
|
||||
var rowAddr = datTableBase + idx * offsets.QuestDatRowSize;
|
||||
questName = ResolveDatString(rowAddr + offsets.QuestDatNameOffset);
|
||||
internalId = ResolveDatString(rowAddr + offsets.QuestDatInternalIdOffset);
|
||||
}
|
||||
else if (_nameLookup is not null && _nameLookup.TryGet(idx, out var entry))
|
||||
{
|
||||
questName = entry?.Name;
|
||||
internalId = entry?.InternalId;
|
||||
}
|
||||
|
||||
result.Add(new QuestSnapshot
|
||||
{
|
||||
QuestStateIndex = idx,
|
||||
QuestDatPtr = questObjPtr,
|
||||
QuestName = questName,
|
||||
InternalId = internalId,
|
||||
StateId = stateId,
|
||||
IsTracked = isTracked,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the QuestStates.dat row table base address.
|
||||
/// Uses QuestDatTableBase offset from PSD if configured, otherwise returns 0.
|
||||
/// </summary>
|
||||
private nint FindDatTableBase(GameOffsets offsets)
|
||||
{
|
||||
if (offsets.QuestDatRowSize <= 0) return 0;
|
||||
// Future: auto-discover table base by scanning for known patterns
|
||||
// For now, table base must be found externally and is not resolved here
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Reads a wchar* pointer at the given address and returns the string.</summary>
|
||||
private string? ResolveDatString(nint fieldAddr)
|
||||
{
|
||||
if (_nameCache.TryGetValue(fieldAddr, out var cached))
|
||||
return cached;
|
||||
|
||||
var mem = _ctx.Memory;
|
||||
var strPtr = mem.ReadPointer(fieldAddr);
|
||||
string? result = null;
|
||||
|
||||
if (strPtr != 0 && ((ulong)strPtr >> 32) is > 0 and < 0x7FFF)
|
||||
result = _strings.ReadNullTermWString(strPtr);
|
||||
|
||||
_nameCache[fieldAddr] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy/POE1 mode: reads struct entries with dat row pointers and string fields.
|
||||
/// </summary>
|
||||
private List<QuestSnapshot>? ReadStructVectorQuests(nint questFlagsAddr, GameOffsets offsets)
|
||||
{
|
||||
var mem = _ctx.Memory;
|
||||
|
||||
// StdVector: begin, end, capacity (3 pointers)
|
||||
var vecBegin = mem.ReadPointer(questFlagsAddr);
|
||||
var vecEnd = mem.ReadPointer(questFlagsAddr + 8);
|
||||
if (vecBegin == 0 || vecEnd <= vecBegin) return null;
|
||||
|
|
@ -91,7 +239,6 @@ public sealed class QuestReader
|
|||
var entryCount = totalBytes / entrySize;
|
||||
if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null;
|
||||
|
||||
// Bulk read all entries
|
||||
var vecData = mem.ReadBytes(vecBegin, totalBytes);
|
||||
if (vecData is null) return null;
|
||||
|
||||
|
|
@ -101,20 +248,16 @@ public sealed class QuestReader
|
|||
{
|
||||
var entryOffset = i * entrySize;
|
||||
|
||||
// Read quest dat pointer
|
||||
nint questDatPtr = 0;
|
||||
if (entryOffset + offsets.QuestEntryQuestPtrOffset + 8 <= vecData.Length)
|
||||
questDatPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryQuestPtrOffset);
|
||||
|
||||
// Read state ID byte
|
||||
byte stateId = 0;
|
||||
if (entryOffset + offsets.QuestEntryStateIdOffset < vecData.Length)
|
||||
stateId = vecData[entryOffset + offsets.QuestEntryStateIdOffset];
|
||||
|
||||
// Resolve quest name from dat pointer (cached)
|
||||
var questName = ResolveQuestName(questDatPtr);
|
||||
|
||||
// Read state text pointer and resolve
|
||||
string? stateText = null;
|
||||
if (offsets.QuestEntryStateTextOffset > 0 &&
|
||||
entryOffset + offsets.QuestEntryStateTextOffset + 8 <= vecData.Length)
|
||||
|
|
@ -124,7 +267,6 @@ public sealed class QuestReader
|
|||
stateText = _strings.ReadNullTermWString(stateTextPtr);
|
||||
}
|
||||
|
||||
// Read progress text pointer and resolve
|
||||
string? progressText = null;
|
||||
if (offsets.QuestEntryProgressTextOffset > 0 &&
|
||||
entryOffset + offsets.QuestEntryProgressTextOffset + 8 <= vecData.Length)
|
||||
|
|
@ -147,10 +289,6 @@ public sealed class QuestReader
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves quest name by following QuestDatPtr → dat row → wchar* name.
|
||||
/// Results are cached since quest names don't change.
|
||||
/// </summary>
|
||||
private string? ResolveQuestName(nint questDatPtr)
|
||||
{
|
||||
if (questDatPtr == 0) return null;
|
||||
|
|
@ -161,7 +299,6 @@ public sealed class QuestReader
|
|||
var mem = _ctx.Memory;
|
||||
string? name = null;
|
||||
|
||||
// Follow the dat row pointer — first field is typically a wchar* name
|
||||
var high = (ulong)questDatPtr >> 32;
|
||||
if (high is > 0 and < 0x7FFF)
|
||||
{
|
||||
|
|
@ -178,6 +315,6 @@ public sealed class QuestReader
|
|||
public void InvalidateCache()
|
||||
{
|
||||
_nameCache.Clear();
|
||||
_lastServerData = 0;
|
||||
_lastPsd = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ namespace Roboto.Memory;
|
|||
public sealed class SkillSnapshot
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? InternalName { get; init; }
|
||||
/// <summary>Address of ActiveSkillPtr in game memory (for CE inspection).</summary>
|
||||
public nint Address { get; init; }
|
||||
/// <summary>Raw bytes at ActiveSkillPtr for offset discovery.</summary>
|
||||
public byte[]? RawBytes { get; init; }
|
||||
public bool CanBeUsed { get; init; }
|
||||
public int UseStage { get; init; }
|
||||
public int CastType { get; init; }
|
||||
|
|
@ -19,6 +24,13 @@ public sealed class SkillSnapshot
|
|||
public int ActiveCooldowns { get; init; }
|
||||
/// <summary>From Cooldowns vector — max uses (charges) for the skill.</summary>
|
||||
public int MaxUses { get; init; }
|
||||
|
||||
/// <summary>Low 16 bits of UnknownIdAndEquipmentInfo — skill ID used for SkillBarIds matching.</summary>
|
||||
public ushort Id { get; init; }
|
||||
/// <summary>High 16 bits of UnknownIdAndEquipmentInfo — equipment slot / secondary ID.</summary>
|
||||
public ushort Id2 { get; init; }
|
||||
/// <summary>Skill bar slot index (0-12) from SkillBarIds, or -1 if not on the skill bar.</summary>
|
||||
public int SkillBarSlot { get; init; } = -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -44,7 +56,7 @@ public sealed class SkillReader
|
|||
_strings = strings;
|
||||
}
|
||||
|
||||
public List<SkillSnapshot>? ReadPlayerSkills(nint localPlayerPtr)
|
||||
public List<SkillSnapshot>? ReadPlayerSkills(nint localPlayerPtr, nint psdPtr = 0)
|
||||
{
|
||||
if (localPlayerPtr == 0) return null;
|
||||
var mem = _ctx.Memory;
|
||||
|
|
@ -59,6 +71,9 @@ public sealed class SkillReader
|
|||
_lastActorComp = actorComp;
|
||||
}
|
||||
|
||||
// Read SkillBarIds from PSD if offset is configured
|
||||
var skillBarIds = ReadSkillBarIds(psdPtr);
|
||||
|
||||
// Read ActiveSkills vector at Actor+0xB00
|
||||
var vecFirst = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector);
|
||||
var vecLast = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector + 8);
|
||||
|
|
@ -87,8 +102,8 @@ public sealed class SkillReader
|
|||
var high = (ulong)activeSkillPtr >> 32;
|
||||
if (high == 0 || high >= 0x7FFF) continue;
|
||||
|
||||
// Read ActiveSkillDetails struct
|
||||
var details = mem.Read<ActiveSkillDetails>(activeSkillPtr);
|
||||
// Read ActiveSkillDetails struct — ptr points 0x10 into the object (past vtable+header)
|
||||
var details = mem.Read<ActiveSkillDetails>(activeSkillPtr - 0x10);
|
||||
|
||||
// Resolve skill name via GEPL FK chain (cached)
|
||||
var name = ResolveSkillName(activeSkillPtr, details);
|
||||
|
|
@ -99,6 +114,24 @@ public sealed class SkillReader
|
|||
// Deduplicate by UnknownIdAndEquipmentInfo
|
||||
if (!seen.Add(details.UnknownIdAndEquipmentInfo)) continue;
|
||||
|
||||
// Extract Id/Id2 from UnknownIdAndEquipmentInfo
|
||||
var id = (ushort)(details.UnknownIdAndEquipmentInfo & 0xFFFF);
|
||||
var id2 = (ushort)(details.UnknownIdAndEquipmentInfo >> 16);
|
||||
|
||||
// Match to skill bar slot
|
||||
var slot = -1;
|
||||
if (skillBarIds is not null)
|
||||
{
|
||||
for (var s = 0; s < skillBarIds.Length; s++)
|
||||
{
|
||||
if (skillBarIds[s].Id == id && skillBarIds[s].Id2 == id2)
|
||||
{
|
||||
slot = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match cooldown entry by UnknownIdAndEquipmentInfo
|
||||
var canBeUsed = true;
|
||||
var activeCooldowns = 0;
|
||||
|
|
@ -117,9 +150,15 @@ public sealed class SkillReader
|
|||
}
|
||||
}
|
||||
|
||||
// Read raw bytes for offset discovery (from true object base)
|
||||
var rawBytes = mem.ReadBytes(activeSkillPtr - 0x10, 0xC0);
|
||||
|
||||
result.Add(new SkillSnapshot
|
||||
{
|
||||
Name = name,
|
||||
InternalName = name,
|
||||
Address = activeSkillPtr - 0x10,
|
||||
RawBytes = rawBytes,
|
||||
CanBeUsed = canBeUsed,
|
||||
UseStage = details.UseStage,
|
||||
CastType = details.CastType,
|
||||
|
|
@ -127,12 +166,41 @@ public sealed class SkillReader
|
|||
CooldownTimeMs = details.TotalCooldownTimeInMs,
|
||||
ActiveCooldowns = activeCooldowns,
|
||||
MaxUses = cdMaxUses,
|
||||
Id = id,
|
||||
Id2 = id2,
|
||||
SkillBarSlot = slot,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads SkillBarIds from PlayerServerData: Buffer13 of (ushort Id, ushort Id2).
|
||||
/// 13 slots × 4 bytes = 52 bytes total.
|
||||
/// </summary>
|
||||
private (ushort Id, ushort Id2)[]? ReadSkillBarIds(nint psdPtr)
|
||||
{
|
||||
var offset = _ctx.Offsets.SkillBarIdsOffset;
|
||||
if (offset <= 0 || psdPtr == 0) return null;
|
||||
|
||||
const int slotCount = 13;
|
||||
const int bufferSize = slotCount * 4; // 52 bytes
|
||||
var data = _ctx.Memory.ReadBytes(psdPtr + offset, bufferSize);
|
||||
if (data is null) return null;
|
||||
|
||||
var slots = new (ushort Id, ushort Id2)[slotCount];
|
||||
for (var i = 0; i < slotCount; i++)
|
||||
{
|
||||
var off = i * 4;
|
||||
slots[i] = (
|
||||
BitConverter.ToUInt16(data, off),
|
||||
BitConverter.ToUInt16(data, off + 2)
|
||||
);
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Cooldowns vector at Actor+0xB18.
|
||||
/// Each entry is an ActiveSkillCooldown struct (0x48 bytes).
|
||||
|
|
|
|||
|
|
@ -220,11 +220,8 @@ public class CombatSystem : ISystem
|
|||
foreach (var ms in memorySkills)
|
||||
{
|
||||
if (ms.Name is null) continue;
|
||||
// Memory names have "Player" suffix, profile names don't
|
||||
var cleanName = ms.Name.EndsWith("Player", StringComparison.Ordinal)
|
||||
? ms.Name[..^6]
|
||||
: ms.Name;
|
||||
if (string.Equals(cleanName, profileSkillName, StringComparison.OrdinalIgnoreCase))
|
||||
// Name is already stripped of "Player" suffix by MemoryPoller
|
||||
if (string.Equals(ms.Name, profileSkillName, StringComparison.OrdinalIgnoreCase))
|
||||
return ms;
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
506
tools/MemoryViewer/MemoryViewer.cs
Normal file
506
tools/MemoryViewer/MemoryViewer.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
29
tools/MemoryViewer/MemoryViewer.csproj
Normal file
29
tools/MemoryViewer/MemoryViewer.csproj
Normal 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>
|
||||
23
tools/MemoryViewer/MemoryViewerSettings.cs
Normal file
23
tools/MemoryViewer/MemoryViewerSettings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
621
tools/PatternTool/PatternTool.cs
Normal file
621
tools/PatternTool/PatternTool.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
tools/PatternTool/PatternTool.csproj
Normal file
32
tools/PatternTool/PatternTool.csproj
Normal 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>
|
||||
24
tools/PatternTool/PatternToolSettings.cs
Normal file
24
tools/PatternTool/PatternToolSettings.cs
Normal 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
791
tools/skillbar_scanner.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue