#!/usr/bin/env python3 """ SkillBar Discovery Tool — finds the SkillBarIds offset within PlayerServerData. ExileCore uses ServerData.SkillBarIds: a Buffer13<(ushort Id, ushort Id2)> — 13 skill bar slots, each a pair of ushort values (4 bytes per slot, 52 bytes total). This script: 1. Attaches to the game process via pymem 2. Follows the pointer chain to read Actor component's ActiveSkills 3. Extracts (Id, Id2) ushort pairs from each skill's UnknownIdAndEquipmentInfo 4. Reads PSD memory and scans for a 52-byte region containing those IDs 5. Reports the PSD offset and dumps the skill bar mapping Usage: pip install pymem python tools/skillbar_scanner.py """ import json import struct import sys from pathlib import Path try: import pymem import pymem.process except ImportError: print("ERROR: pymem not installed. Run: pip install pymem") sys.exit(1) def load_offsets() -> dict: """Load offsets.json from project root.""" p = Path(__file__).resolve().parent.parent / "offsets.json" if not p.exists(): print(f"ERROR: offsets.json not found at {p}") sys.exit(1) with open(p) as f: data = json.load(f) # Convert hex strings to int result = {} for k, v in data.items(): if isinstance(v, str) and v.startswith("0x"): result[k] = int(v, 16) elif isinstance(v, (int, float)): result[k] = int(v) else: result[k] = v return result def read_ptr(pm: pymem.Pymem, addr: int) -> int: """Read a 64-bit pointer. Returns 0 on failure.""" try: return struct.unpack(" int: try: return struct.unpack(" int: try: return struct.unpack(" bool: """Check if address looks like a valid 64-bit heap pointer.""" if addr == 0: return False high = (addr >> 32) & 0xFFFFFFFF return 0 < high < 0x7FFF def read_std_vector(pm: pymem.Pymem, vec_addr: int) -> tuple[int, int]: """Read begin/end pointers of an MSVC std::vector.""" begin = read_ptr(pm, vec_addr) end = read_ptr(pm, vec_addr + 8) return begin, end def read_ascii_string(pm: pymem.Pymem, addr: int, max_len: int = 64) -> str | None: """Read a null-terminated ASCII string.""" try: data = pm.read_bytes(addr, max_len) null_idx = data.index(0) return data[:null_idx].decode("ascii", errors="replace") except Exception: return None def find_component_address(pm: pymem.Pymem, entity_ptr: int, component_name: str, offsets: dict, verbose: bool = False) -> int: """ Find a named component's address via the ComponentLookup system. Entity+0x08 → EntityDetails → +0x28 → ComponentLookup → +0x28 → Vec2 (name entries) Each entry: { char* name (8), int32 index (4), int32 flags (4) } = 16 bytes Then: Entity+0x10 → component pointer list → [index] → component address """ details_ptr = read_ptr(pm, entity_ptr + offsets["EntityDetailsOffset"]) if verbose: print(f" Entity+0x{offsets['EntityDetailsOffset']:X} → EntityDetails: 0x{details_ptr:X}") if not is_valid_ptr(details_ptr): if verbose: print(" ERROR: Invalid EntityDetails pointer") return 0 lookup_ptr = read_ptr(pm, details_ptr + offsets["ComponentLookupOffset"]) if verbose: print(f" EntityDetails+0x{offsets['ComponentLookupOffset']:X} → ComponentLookup: 0x{lookup_ptr:X}") if not is_valid_ptr(lookup_ptr): if verbose: print(" ERROR: Invalid ComponentLookup pointer") return 0 vec_begin, vec_end = read_std_vector(pm, lookup_ptr + offsets["ComponentLookupVec2Offset"]) if verbose: print(f" ComponentLookup+0x{offsets['ComponentLookupVec2Offset']:X} → Vec2: 0x{vec_begin:X}..0x{vec_end:X}") if not is_valid_ptr(vec_begin) or vec_end <= vec_begin: if verbose: print(" ERROR: Invalid Vec2 range") return 0 entry_size = offsets["ComponentLookupEntrySize"] # 16 total = vec_end - vec_begin count = total // entry_size if verbose: print(f" Entries: {count} (entry_size={entry_size})") if count <= 0 or count > 128: if verbose: print(f" ERROR: Bad entry count: {count}") return 0 # Read all entries at once try: data = pm.read_bytes(vec_begin, total) except Exception: return 0 target_index = -1 found_names = [] for i in range(count): off = i * entry_size name_ptr = struct.unpack(" dict: """ Follow the full pointer chain from module base to all needed addresses. Returns dict with AreaInstance, ServerData, PSD, LocalPlayer, etc. """ result = {} # Find module base module = pymem.process.module_from_name(pm.process_handle, offsets["ProcessName"] + ".exe") if module is None: print("ERROR: Could not find game module") return result module_base = module.lpBaseOfDll module_size = module.SizeOfImage print(f"Module base: 0x{module_base:X} size: 0x{module_size:X}") # Pattern scan for GameState base pattern = offsets.get("GameStatePattern", "") game_state_base = 0 if pattern and "^" in pattern: # Parse pattern: bytes before ^ are prefix, ^ marks the RIP displacement position, # bytes after the 4 ?? wildcards are suffix (must also match) parts = pattern.split() caret_idx = parts.index("^") # Build prefix (before ^) prefix_bytes = [] prefix_mask = [] for p in parts[:caret_idx]: if p == "??": prefix_bytes.append(0) prefix_mask.append(False) else: prefix_bytes.append(int(p, 16)) prefix_mask.append(True) prefix_len = len(prefix_bytes) # Count wildcards after ^ (the displacement bytes) disp_wildcards = 0 suffix_start = caret_idx + 1 while suffix_start < len(parts) and parts[suffix_start] == "??": disp_wildcards += 1 suffix_start += 1 if disp_wildcards == 0: disp_wildcards = 4 # default: 4-byte RIP displacement # Build suffix (after the wildcards) suffix_bytes = [] suffix_mask = [] for p in parts[suffix_start:]: if p == "??": suffix_bytes.append(0) suffix_mask.append(False) else: suffix_bytes.append(int(p, 16)) suffix_mask.append(True) suffix_len = len(suffix_bytes) total_pattern_len = prefix_len + disp_wildcards + suffix_len # Scan module memory in chunks CHUNK = 0x100000 for chunk_off in range(0, module_size, CHUNK): try: chunk_size = min(CHUNK + 256, module_size - chunk_off) if chunk_size <= total_pattern_len: continue data = pm.read_bytes(module_base + chunk_off, chunk_size) except Exception: continue for i in range(len(data) - total_pattern_len): # Match prefix match = True for j in range(prefix_len): if prefix_mask[j] and data[i + j] != prefix_bytes[j]: match = False break if not match: continue # Match suffix (after displacement bytes) suffix_off = i + prefix_len + disp_wildcards for j in range(suffix_len): if suffix_mask[j] and data[suffix_off + j] != suffix_bytes[j]: match = False break if not match: continue # Read RIP-relative displacement at caret position disp_offset = i + prefix_len disp = struct.unpack(" 0: game_state_base = module_base + gso print(f"GameState base (manual): 0x{game_state_base:X}") else: print("ERROR: Could not find GameState base via pattern or manual offset") return result print(f"GameState base: 0x{game_state_base:X}") result["GameStateBase"] = game_state_base # GameState → ReadPointer → Controller controller = read_ptr(pm, game_state_base) if not is_valid_ptr(controller): print("ERROR: Invalid controller pointer") return result print(f"Controller: 0x{controller:X}") result["Controller"] = controller # Controller → InGameState (direct offset) igs_off = offsets.get("InGameStateDirectOffset", 0x210) in_game_state = read_ptr(pm, controller + igs_off) print(f" Controller+0x{igs_off:X} → raw=0x{in_game_state:X}") if not is_valid_ptr(in_game_state): # Try inline state array fallback: controller + StatesBeginOffset + InGameStateIndex * StateStride states_begin = offsets.get("StatesBeginOffset", 0x48) igs_index = offsets.get("InGameStateIndex", 4) stride = offsets.get("StateStride", 0x10) state_ptr_off = offsets.get("StatePointerOffset", 0) inline_off = states_begin + igs_index * stride + state_ptr_off in_game_state = read_ptr(pm, controller + inline_off) print(f" Fallback: Controller+0x{inline_off:X} → raw=0x{in_game_state:X}") if not is_valid_ptr(in_game_state): print("ERROR: Invalid InGameState pointer (tried direct + inline fallback)") print(" Make sure you are logged in and in-game (not at login screen)") return result print(f"InGameState: 0x{in_game_state:X}") result["InGameState"] = in_game_state # InGameState → AreaInstance area_instance = read_ptr(pm, in_game_state + offsets["IngameDataFromStateOffset"]) if not is_valid_ptr(area_instance): print("ERROR: Invalid AreaInstance pointer") return result print(f"AreaInstance: 0x{area_instance:X}") result["AreaInstance"] = area_instance # AreaInstance → ServerData server_data = read_ptr(pm, area_instance + offsets["ServerDataOffset"]) if not is_valid_ptr(server_data): print("ERROR: Invalid ServerData pointer") return result print(f"ServerData: 0x{server_data:X}") result["ServerData"] = server_data # ServerData → PlayerServerData vector → PSD[0] psd_vec_begin, psd_vec_end = read_std_vector(pm, server_data + offsets["PlayerServerDataOffset"]) if not is_valid_ptr(psd_vec_begin): print("ERROR: Invalid PSD vector") return result psd_ptr = read_ptr(pm, psd_vec_begin) if not is_valid_ptr(psd_ptr): print("ERROR: Invalid PSD[0] pointer") return result print(f"PSD[0]: 0x{psd_ptr:X}") result["PSD"] = psd_ptr # AreaInstance → LocalPlayer entity # Try direct offset first, then scan nearby offsets to auto-discover local_player = 0 lp_direct_off = offsets["LocalPlayerDirectOffset"] lp_candidate = read_ptr(pm, area_instance + lp_direct_off) print(f" AreaInstance+0x{lp_direct_off:X} → 0x{lp_candidate:X}") # Validate: a real entity has vtable in module range at +0x00 and a heap pointer at +0x08 def is_entity(addr: int) -> bool: if not is_valid_ptr(addr): return False vtable = read_ptr(pm, addr) details = read_ptr(pm, addr + 8) return module_base <= vtable < module_base + module_size and is_valid_ptr(details) if is_entity(lp_candidate): local_player = lp_candidate else: # Maybe it's an intermediate struct — try deref + small offsets for sub_off in [0x00, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30]: candidate = read_ptr(pm, lp_candidate + sub_off) if is_valid_ptr(lp_candidate) else 0 if is_entity(candidate): print(f" Found entity via deref+0x{sub_off:X}: 0x{candidate:X}") local_player = candidate break if local_player == 0: # Scan AreaInstance offsets near 0xA10 for any entity pointer print(" Scanning AreaInstance offsets 0xA00..0xA30 for entity pointer...") for scan_off in range(0xA00, 0xA30, 8): candidate = read_ptr(pm, area_instance + scan_off) if is_entity(candidate): print(f" Found entity at AreaInstance+0x{scan_off:X}: 0x{candidate:X}") local_player = candidate break if local_player == 0 and is_valid_ptr(server_data): # Fallback: ServerData + LocalPlayerOffset lp_sd = read_ptr(pm, server_data + offsets.get("LocalPlayerOffset", 0x20)) if is_entity(lp_sd): local_player = lp_sd print(f" Found entity via ServerData+0x{offsets.get('LocalPlayerOffset', 0x20):X}: 0x{local_player:X}") if local_player == 0: # Dump first 0x20 bytes at the direct candidate for debugging if is_valid_ptr(lp_candidate): try: raw = pm.read_bytes(lp_candidate, 0x40) print(f" DEBUG: Raw bytes at 0x{lp_candidate:X}:") for row in range(0, 0x40, 16): hex_str = " ".join(f"{b:02X}" for b in raw[row:row + 16]) ptrs = " ".join(f"0x{struct.unpack(' list[tuple[int, int, int]]: """ Read ActiveSkills vector from Actor component. Returns list of (raw_uint32, id_lo16, id_hi16) tuples. """ ACTIVE_SKILLS_OFFSET = 0xB00 vec_begin, vec_end = read_std_vector(pm, actor_comp + ACTIVE_SKILLS_OFFSET) if not is_valid_ptr(vec_begin) or vec_end <= vec_begin: print(" No active skills found") return [] total = vec_end - vec_begin entry_size = 0x10 # ActiveSkillEntry: 2 pointers count = total // entry_size if count <= 0 or count > 128: print(f" Bad skill count: {count}") return [] try: data = pm.read_bytes(vec_begin, total) except Exception: return [] skills = [] for i in range(count): # Follow ptr1 (ActiveSkillPtr) active_skill_ptr = struct.unpack("> 16) & 0xFFFF # Also try to read the skill name for display skills.append((uid, id_lo, id_hi)) return skills def read_wchar_string(pm: pymem.Pymem, addr: int, max_bytes: int = 128) -> str | None: """Read null-terminated wchar_t (UTF-16LE) string.""" if not is_valid_ptr(addr): return None try: raw = pm.read_bytes(addr, max_bytes) chars = [] for j in range(0, len(raw) - 1, 2): c = struct.unpack(" 0xFFFF: return None chars.append(chr(c)) name = "".join(chars) return name if name and len(name) > 1 and all(32 <= ord(c) < 127 for c in name) else None except Exception: return None def resolve_skill_name(pm: pymem.Pymem, active_skill_ptr: int) -> str | None: """ Try to resolve a skill name via multiple paths: 1. ActiveSkillsDatPtr (+0x20) → wchar* (direct dat row string) 2. ActiveSkillsDatPtr (+0x20) → ptr → wchar* (indirect via pointer in dat row) 3. GEPL FK chain: +0x18 → GEPL row → +0x00 FK → GE row → +0x00 → wchar* """ # Path 1: Direct wchar* at +0x20 dat_ptr = read_ptr(pm, active_skill_ptr + 0x20) if is_valid_ptr(dat_ptr): name = read_wchar_string(pm, dat_ptr) if name: return name # Path 2: Indirect — dat_ptr is a dat row, first field is a pointer to wchar* str_ptr = read_ptr(pm, dat_ptr) if is_valid_ptr(str_ptr): name = read_wchar_string(pm, str_ptr) if name: return name # Path 3: GEPL FK chain gepl_ptr = read_ptr(pm, active_skill_ptr + 0x18) if is_valid_ptr(gepl_ptr): ge_fk = read_ptr(pm, gepl_ptr) if is_valid_ptr(ge_fk): # GE+0x00 → ActiveSkills.dat row → wchar* as_dat = read_ptr(pm, ge_fk) if is_valid_ptr(as_dat): name = read_wchar_string(pm, as_dat) if name: return name # Try indirect str_ptr = read_ptr(pm, as_dat) if is_valid_ptr(str_ptr): name = read_wchar_string(pm, str_ptr) if name: return name return None def read_active_skills_with_names(pm: pymem.Pymem, actor_comp: int) -> list[dict]: """Read ActiveSkills with name resolution. Returns list of skill dicts.""" ACTIVE_SKILLS_OFFSET = 0xB00 vec_begin, vec_end = read_std_vector(pm, actor_comp + ACTIVE_SKILLS_OFFSET) if not is_valid_ptr(vec_begin) or vec_end <= vec_begin: return [] total = vec_end - vec_begin entry_size = 0x10 count = total // entry_size if count <= 0 or count > 128: return [] try: data = pm.read_bytes(vec_begin, total) except Exception: return [] skills = [] seen = set() for i in range(count): asp = struct.unpack("> 16) & 0xFFFF name = resolve_skill_name(pm, asp) skills.append({ "raw": uid, "id": id_lo, "id2": id_hi, "name": name, "ptr": asp, }) return skills def find_uint32_occurrences(psd_data: bytes, target_values: set[int]) -> list[tuple[int, int]]: """Find all 4-byte-aligned occurrences of any target uint32 in PSD data. Returns list of (offset, value).""" hits = [] for off in range(0, len(psd_data) - 3, 4): val = struct.unpack(" list[tuple[int, int, list]]: """ Scan PSD memory for a Buffer13<(ushort Id, ushort Id2)> pattern. 13 slots × 4 bytes = 52 bytes. We look for 4-byte-aligned offsets where reading 13 × (ushort, ushort) produces overlap with known skill IDs. Returns list of (offset, overlap_count, slot_data) sorted by overlap desc. """ SLOTS = 13 ENTRY_SIZE = 4 # 2 × ushort BUFFER_SIZE = SLOTS * ENTRY_SIZE # 52 bytes candidates = [] for off in range(0, len(psd_data) - BUFFER_SIZE, 4): # 4-byte aligned slots = [] non_zero = 0 for s in range(SLOTS): entry_off = off + s * ENTRY_SIZE id_lo = struct.unpack("= 2: overlap = raw_overlap if overlap >= 2: candidates.append((off, overlap, slots)) # Sort by overlap descending candidates.sort(key=lambda x: -x[1]) return candidates def main(): offsets = load_offsets() proc_name = offsets.get("ProcessName", "PathOfExileSteam") print(f"Attaching to {proc_name}...") try: pm = pymem.Pymem(proc_name + ".exe") except pymem.exception.ProcessNotFound: # Try without .exe try: pm = pymem.Pymem(proc_name) except Exception as e: print(f"ERROR: Could not attach to process: {e}") sys.exit(1) print(f"Attached to PID {pm.process_id}") print() # Resolve the full pointer chain print("=== Resolving pointer chain ===") addrs = resolve_pointer_chain(pm, offsets) if "PSD" not in addrs or "LocalPlayer" not in addrs: print("\nFATAL: Could not resolve required addresses") pm.close_process() sys.exit(1) print() # Find Actor component via ComponentLookup print("=== Finding Actor component ===") actor_comp = find_component_address(pm, addrs["LocalPlayer"], "Actor", offsets, verbose=True) if actor_comp == 0: print("ERROR: Could not find Actor component on local player") pm.close_process() sys.exit(1) print(f"Actor component: 0x{actor_comp:X}") print() # Read active skills print("=== Reading ActiveSkills ===") skills = read_active_skills_with_names(pm, actor_comp) if not skills: print("ERROR: No active skills found. Make sure you have skills equipped.") pm.close_process() sys.exit(1) print(f"Found {len(skills)} unique skills:") known_ids = set() for s in skills: known_ids.add((s["id"], s["id2"])) name_str = s["name"] or "???" print(f" Id={s['id']:5d} Id2={s['id2']:5d} Raw=0x{s['raw']:08X} {name_str}") print() # Scan PSD for SkillBarIds print("=== Scanning PSD for SkillBarIds ===") SCAN_SIZE = 0x10000 # 64KB — PSD can be very large print(f"Reading 0x{SCAN_SIZE:X} bytes from PSD at 0x{addrs['PSD']:X}") try: psd_data = pm.read_bytes(addrs["PSD"], SCAN_SIZE) except Exception as e: print(f"ERROR reading PSD: {e}") pm.close_process() sys.exit(1) print(f"Read {len(psd_data)} bytes OK") # Build sets for matching known_raw = set() for s in skills: known_raw.add(s["raw"]) # Step 1: Find individual uint32 occurrences in PSD (needle search) print(f"\nStep 1: Searching for {len(known_raw)} known uint32 skill IDs in PSD...") hits = find_uint32_occurrences(psd_data, known_raw) if hits: print(f" Found {len(hits)} individual hits:") id_to_name_raw = {s["raw"]: s["name"] or f"0x{s['raw']:08X}" for s in skills} for off, val in hits[:30]: name = id_to_name_raw.get(val, "?") print(f" PSD+0x{off:X}: 0x{val:08X} ({name})") else: print(" No individual raw uint32 hits found in PSD!") # Also try just the lo16 values known_lo16 = {s["id"] for s in skills if s["id"] > 0} lo16_hits = [] for off in range(0, len(psd_data) - 1, 2): val = struct.unpack(" pattern (13 × 4 = 52 bytes)...") candidates = scan_psd_for_skillbar(psd_data, known_ids, known_raw) if not candidates: print("No Buffer13 candidates found.") # If individual hits exist, suggest they might use a different buffer layout if hits: print("\nIndividual hits exist — checking for clustered groups near those offsets...") # Check for any 3+ hits within a 52-byte window for i, (base_off, _) in enumerate(hits): window_start = max(0, base_off - 52) window_end = min(len(psd_data), base_off + 52) nearby = [(o, v) for o, v in hits if window_start <= o < window_end] if len(nearby) >= 3: print(f" Cluster at PSD+0x{window_start:X}..0x{window_end:X}: {len(nearby)} hits") for o, v in nearby: print(f" +0x{o:X}: 0x{v:08X}") pm.close_process() sys.exit(1) # Display top results SLOT_LABELS = [ "LMB", "RMB", "MMB", "Q", "E", "R", "T", "F", "Slot8", "Slot9", "Slot10", "Slot11", "Slot12" ] print(f"Found {len(candidates)} candidate offsets (top 10):") print() # Build reverse lookup: (id, id2) → name id_to_name = {} for s in skills: id_to_name[(s["id"], s["id2"])] = s["name"] or f"skill_{s['raw']:08X}" for rank, (off, overlap, slots) in enumerate(candidates[:10]): print(f" #{rank + 1} PSD+0x{off:X} (overlap: {overlap}/{len(known_ids)} known skills)") for i, (id_lo, id_hi) in enumerate(slots): label = SLOT_LABELS[i] if i < len(SLOT_LABELS) else f"Slot{i}" if id_lo == 0 and id_hi == 0: print(f" [{label:>6}] (empty)") else: match = (id_lo, id_hi) in known_ids name = id_to_name.get((id_lo, id_hi), "") marker = " ✓ MATCH" if match else "" name_str = f" ({name})" if name else "" print(f" [{label:>6}] Id={id_lo:5d} Id2={id_hi:5d}{name_str}{marker}") print() # Highlight the best result best_off, best_overlap, best_slots = candidates[0] print("=" * 60) print(f"BEST MATCH: PSD+0x{best_off:X}") print(f" Overlap: {best_overlap}/{len(known_ids)} known skills") print(f" Add to offsets.json: \"SkillBarIdsOffset\": \"0x{best_off:X}\"") print("=" * 60) pm.close_process() if __name__ == "__main__": main()