PH-PB PXH-PXB Inazuma Eleven Sound Extractor.py

 avatar
plain_text
11 hours ago
16.2 KB
9
Indexable
import os
import struct
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext

VALID_EXT = (".SED", ".SWD", ".SMD")

# ============================================================
# Utilities
# ============================================================
def leer_u32(data, pos):
    return struct.unpack_from("<I", data, pos)[0]

def nombre_valido(nombre):
    if len(nombre) == 0 or len(nombre) > 24:
        return False
    if "." not in nombre:
        return False
    ext = os.path.splitext(nombre)[1].upper()
    if ext not in VALID_EXT:
        return False
    for c in nombre:
        o = ord(c)
        if o < 32 or o > 126:
            return False
    return True

def nombre_imprimible(nombre):
    if len(nombre) == 0 or len(nombre) > 24:
        return False
    for c in nombre:
        o = ord(c)
        if o < 32 or o > 126:
            return False
    return True

def leer_nombre_interno(data, offset):
    """Read internal name from SMD/SED/SWD file at data[offset+0x20]."""
    name_off = offset + 0x20
    if name_off + 24 > len(data):
        return None
    raw = data[name_off:name_off+24].split(b"\x00")[0]
    try:
        candidate = raw.decode("ascii")
        if candidate and len(candidate) > 4 and "." in candidate:
            cext = os.path.splitext(candidate)[1].upper()
            if cext in [".SMD",".SED",".SWD",".SAD"]:
                return candidate
    except:
        pass
    return None

def make_output_folder(index_path):
    """Build output folder name including the index extension to avoid IE1/IE2/IE3 mixing."""
    base = os.path.splitext(index_path)[0]
    ext = os.path.splitext(index_path)[1].lstrip(".").lower()
    return base + "_" + ext + "_extracted"

# ============================================================
# PH/PB (Inazuma Eleven 1)
# ============================================================
def buscar_tabla_ph(ph):
    limite = min(len(ph), 0x2000)
    for pos in range(limite):
        for largo in range(8, 25):
            if pos + largo + 8 > len(ph):
                continue
            raw = ph[pos:pos+largo]
            if b"\x00" not in raw:
                continue
            raw = raw.split(b"\x00")[0]
            try:
                nombre = raw.decode("ascii")
            except:
                continue
            if not nombre_valido(nombre):
                continue
            base = pos
            off = leer_u32(ph, base + 24)
            size = leer_u32(ph, base + 28)
            if off == 0 and size > 0:
                return base
    return None

def leer_tabla_ph(ph, pb_size, log=None):
    def _log(msg):
        if log: log(msg)
    inicio = buscar_tabla_ph(ph)
    if inicio is None:
        raise Exception("PH table not found.")
    entradas = []
    omitidas = []
    pos = inicio
    while True:
        if pos + 32 > len(ph):
            break
        nombre_raw = ph[pos:pos+24].split(b"\x00")[0]
        try:
            nombre = nombre_raw.decode("ascii")
        except:
            _log("[END] Non-decodable name at 0x%X, end of table." % pos)
            break
        if not nombre_imprimible(nombre):
            _log("[END] Empty/non-printable name at 0x%X, end of table." % pos)
            break
        offset = leer_u32(ph, pos + 24)
        size = leer_u32(ph, pos + 28)
        if size == 0:
            omitidas.append({"nombre": nombre, "offset": offset, "size": size, "motivo": "size==0"})
            pos += 32
            continue
        if offset + size > pb_size:
            omitidas.append({"nombre": nombre, "offset": offset, "size": size, "motivo": "out of range"})
            pos += 32
            continue
        entradas.append({"nombre": nombre, "offset": offset, "size": size})
        pos += 32
    return inicio, pos, entradas, omitidas

# ============================================================
# PKH/PKB (Inazuma Eleven 2 / 3)
# ============================================================
def detectar_formato(ph_data):
    """Detect if index is PH (IE1) or PKH (IE2/IE3)."""
    for pos in range(min(len(ph_data), 0x200)):
        for largo in range(8, 25):
            if pos + largo > len(ph_data):
                continue
            raw = ph_data[pos:pos+largo]
            if b"\x00" not in raw:
                continue
            raw = raw.split(b"\x00")[0]
            try:
                nombre = raw.decode("ascii")
            except:
                continue
            if nombre_valido(nombre):
                return "PH"
    return "PKH"

def leer_tabla_pkh(pkh, pkb_data, log=None):
    """Read PKH as 16-byte entries, extract SED/SMD + SWD pairs."""
    def _log(msg):
        if log: log(msg)

    n16 = len(pkh) // 16
    _log("PKH entries: %d" % n16)

    # Scan magic bytes in PKB
    magic_pos = []
    for magic, ftype in [(b"smdl","SMD"),(b"sedl","SED"),(b"swdl","SWD")]:
        start = 0
        while True:
            idx = pkb_data.find(magic, start)
            if idx == -1:
                break
            magic_pos.append((idx, ftype))
            start = idx + 1
    magic_pos.sort()

    entradas = []
    errores = 0

    for i in range(n16):
        off = i * 16
        f0, f1, f2, f3 = struct.unpack_from("<IIII", pkh, off)
        seq_off = f1 + 0x10

        if seq_off >= len(pkb_data):
            errores += 1
            continue

        magic = pkb_data[seq_off:seq_off+4]
        if magic not in (b"smdl", b"sedl"):
            errores += 1
            continue

        # SED/SMD name
        seq_name = leer_nombre_interno(pkb_data, seq_off)
        if seq_name is None:
            if magic == b"smdl": seq_name = "SMD_%03d.SMD" % i
            else: seq_name = "SED_%03d.SED" % i

        # Find paired SWD
        swd_off = None
        for pos, ft in magic_pos:
            if pos > seq_off and ft == "SWD":
                swd_off = pos
                break

        # SED/SMD size
        if swd_off is not None:
            seq_size = swd_off - seq_off
        else:
            seq_size = min(f2, len(pkb_data) - seq_off)
        seq_size = max(seq_size, 0x50)

        entradas.append({"nombre": seq_name, "offset": seq_off, "size": seq_size})

        # Paired SWD
        if swd_off is not None:
            swd_name = leer_nombre_interno(pkb_data, swd_off)
            if swd_name is None:
                swd_name = os.path.splitext(seq_name)[0] + ".SWD"

            next_after_swd = len(pkb_data)
            for pos, ft in magic_pos:
                if pos > swd_off:
                    next_after_swd = pos
                    break
            pair_end = max(seq_off + f2, next_after_swd)
            pair_end = min(pair_end, len(pkb_data))
            swd_size = pair_end - swd_off
            swd_size = max(swd_size, 0x50)
            entradas.append({"nombre": swd_name, "offset": swd_off, "size": swd_size})

    if errores > 0:
        _log("PKH: %d entries with errors" % errores)
    return entradas

# ============================================================
# Extract
# ============================================================
def extraer(ph_path, pb_path, log):
    with open(ph_path, "rb") as f:
        ph = f.read()
    with open(pb_path, "rb") as f:
        pb = f.read()

    log("=" * 70)
    log("Index : " + os.path.basename(ph_path))
    log("Data  : " + os.path.basename(pb_path))
    log("Index size = %d" % len(ph))
    log("Data  size = %d" % len(pb))
    log("=" * 70)
    log("")

    formato = detectar_formato(ph)
    log("Detected format: %s" % formato)
    log("")

    if formato == "PH":
        inicio, fin, entradas, omitidas = leer_tabla_ph(ph, len(pb), log=log)
        log("Table start : %d" % inicio)
        log("Table end   : %d" % fin)
    else:
        entradas = leer_tabla_pkh(ph, pb, log=log)
        omitidas = []

    log("Entries     : %d" % len(entradas))
    log("Skipped     : %d" % len(omitidas))
    log("")

    carpeta = make_output_folder(ph_path)
    os.makedirs(carpeta, exist_ok=True)
    log("Output folder: %s" % carpeta)
    log("")

    usados = {}
    total = 0
    conteo_ext = {}

    for e in entradas:
        nombre = e["nombre"]
        if nombre in usados:
            usados[nombre] += 1
            base, ext = os.path.splitext(nombre)
            nombre = "%s_%d%s" % (base, usados[nombre], ext)
        else:
            usados[nombre] = 0
        ruta = os.path.join(carpeta, nombre)
        with open(ruta, "wb") as f:
            ini = e["offset"]
            fin_ = ini + e["size"]
            f.write(pb[ini:fin_])
        log("%-24s off=%10d size=%d" % (nombre, e["offset"], e["size"]))
        ext_key = os.path.splitext(nombre)[1].upper()
        conteo_ext[ext_key] = conteo_ext.get(ext_key, 0) + 1
        total += 1

    log("")
    log("Extracted %d files." % total)
    log("Summary by extension: %s" % conteo_ext)
    if omitidas:
        log("")
        log("Skipped entries (not extracted):")
        for o in omitidas:
            log("  %-20s off=%10d size=%d  (%s)" % (o["nombre"], o["offset"], o["size"], o["motivo"]))
    return total

# ============================================================
# GUI
# ============================================================
class App:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Extractor PH/PB + PKH/PKB v3.0 (IE1 + IE2 + IE3)")
        self.root.geometry("900x700")
        top = tk.Frame(self.root)
        top.pack(fill="x", padx=10, pady=10)
        tk.Button(top, text="Open PH / PKH", width=25, command=self.abrir).pack(side="left")
        tk.Button(top, text="Clear", width=15, command=self.limpiar).pack(side="left", padx=5)
        self.txt = scrolledtext.ScrolledText(self.root, width=120, height=40)
        self.txt.pack(fill="both", expand=True, padx=10, pady=10)
        self.log("Extractor PH/PB + PKH/PKB v3.0")
        self.log("")
        self.log("IE1: PH + PB   (ASCII index, 32 bytes/entry)")
        self.log("IE2: PKH + PKB (binary index, 16 bytes/entry, field1+0x10=offset)")
        self.log("IE3: PKH + PKB (same format as IE2)")
        self.log("")
        self.log("Select a .PH or .PKH file")
        self.root.mainloop()

    def limpiar(self):
        self.txt.delete("1.0", tk.END)

    def log(self, texto):
        self.txt.insert(tk.END, texto + "\n")
        self.txt.see(tk.END)
        self.root.update_idletasks()

    def abrir(self):
        ph = filedialog.askopenfilename(
            title="Select PH or PKH file",
            filetypes=[
                ("PH/PKH", "*.ph *.PH *.pkh *.PKH"),
                ("All files", "*.*")
            ]
        )
        if not ph:
            return

        base = os.path.splitext(ph)[0]
        ext_idx = os.path.splitext(ph)[1].lower()

        # Search for data file based on index extension
        if ext_idx in (".pkh",):
            candidatos = [base + ".PKB", base + ".pkb", base + ".Pkb", base + ".pKb"]
        elif ext_idx in (".ph",):
            candidatos = [base + ".PB", base + ".pb", base + ".Pb", base + ".pB"]
        else:
            candidatos = [base + ".PKB", base + ".pkb", base + ".PB", base + ".pb"]

        pb = None
        for f in candidatos:
            if os.path.exists(f):
                pb = f
                break

        if pb is None:
            messagebox.showinfo("Data file", "Please select the PB/PKB data file.")
            pb = filedialog.askopenfilename(
                title="Select PB or PKB file",
                filetypes=[
                    ("PB/PKB", "*.pb *.PB *.pkb *.PKB"),
                    ("All files", "*.*")
                ]
            )
        if not pb:
            return

        self.limpiar()
        try:
            total = extraer(ph, pb, self.log)
            messagebox.showinfo("Done", "Extracted %d files." % total)
        except Exception as e:
            messagebox.showerror("Error", str(e))
            self.log("")
            self.log("ERROR:")
            self.log(str(e))

def main():
    App()

if __name__ == "__main__":
    main()
Editor is loading...
Leave a Comment