skills working somewhat
This commit is contained in:
parent
a8c43ba7e2
commit
8a0e4bb481
22 changed files with 4227 additions and 161 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue