791 lines
28 KiB
Python
791 lines
28 KiB
Python
#!/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()
|