#!/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()
