#!/usr/bin/env python3
"""
Model extraction: FRAGMENT module -> geometry, textures, skeleton, animations.

Self-contained copy of tools/model_viewer/extract_model.py, taking raw bytes so
it can be fed straight from the ROM. See ../README.md for the format notes.
"""
import json, os, struct, sys, zlib

BASE = 0x8FF00000

# ---------------------------------------------------------------- geo layout

CMD_SIZES = {
    0x00:0x08, 0x01:0x04, 0x02:0x08, 0x03:0x08, 0x04:0x04, 0x05:0x04, 0x06:0x04,
    0x07:0x08, 0x08:0x0C, 0x09:0x04, 0x0A:0x08, 0x0B:0x18, 0x0C:0x04, 0x0D:0x04,
    0x0E:0x04, 0x0F:0x04, 0x10:0x04, 0x11:0x04, 0x12:0x04, 0x13:0x08, 0x14:0x0C,
    0x15:0x0C, 0x16:0x04, 0x17:0x14, 0x18:0x08, 0x19:0x08, 0x1A:0x04, 0x1B:0x10,
    0x1C:0x10, 0x1D:0x1C, 0x1E:0x08, 0x1F:0x18, 0x20:0x14, 0x21:0x10, 0x22:0x08,
    0x23:0x10, 0x24:0x04, 0x25:0x04, 0x26:0x14,
}


class Fragment:
    def __init__(self, data, name='<bytes>'):
        self.d = data if isinstance(data, (bytes, bytearray)) else open(data, 'rb').read()
        self.name = name if isinstance(data, (bytes, bytearray)) else str(data)
        if self.d[8:0x10] != b'FRAGMENT':
            raise ValueError(f'{self.name}: not a FRAGMENT module')
        self.hdrSize, self.relocOff, self.sizeRom, self.sizeRam = struct.unpack_from('>4I', self.d, 0x10)

    def off(self, ptr):
        return None if ptr == 0 else ptr - BASE

    def u8(self, o):  return self.d[o]
    def s8(self, o):  return struct.unpack_from('>b', self.d, o)[0]
    def u16(self, o): return struct.unpack_from('>H', self.d, o)[0]
    def s16(self, o): return struct.unpack_from('>h', self.d, o)[0]
    def u32(self, o): return struct.unpack_from('>I', self.d, o)[0]
    def s32(self, o): return struct.unpack_from('>i', self.d, o)[0]
    def ptr(self, o): return self.off(self.u32(o))

    def root(self):
        """The entry stub ends with `lui rX, hi; addiu rX, rX, lo` loading the root struct."""
        for o in range(0x20, 0x80, 4):
            w = self.u32(o)
            if (w >> 26) != 0x0F:                       # lui
                continue
            reg = (w >> 16) & 0x1F
            w2 = self.u32(o + 4)
            if (w2 >> 26) == 0x09 and ((w2 >> 21) & 0x1F) == reg:   # addiu rX, rX, imm
                return ((self.u16(o + 2) << 16) + self.s16(o + 6)) - BASE
        raise RuntimeError('could not locate root struct')

    def ptr_list(self, o):
        out = []
        while True:
            p = self.ptr(o)
            if p is None:
                return out
            out.append(p)
            o += 4


# --------------------------------------------------------------- F3DEX2 exec

def signed(v, bits):
    m = 1 << (bits - 1)
    return (v ^ m) - m


class Model:
    """Walks the geo layout, executes the display lists, accumulates draw data."""

    def __init__(self, frag):
        self.f = frag
        r = frag.root()
        self.species = frag.u16(r)
        self.geoLayouts = frag.ptr_list(frag.ptr(r + 0x08))
        self.anims     = frag.ptr_list(frag.ptr(r + 0x0C))
        self.auxAnims  = frag.ptr_list(frag.ptr(r + 0x10))

        self.textures = []      # {fmt, siz, w, h, texels, data}
        self.tluts = []         # palettes: {count, data, dl}
        self.bones = []         # {parent, boneId, chan, t, r, s}
        self.boneById = {}
        self.prims = []         # {tex, cull, verts:[...], tris:[...]}
        self.primsByKey = {}
        self.vtxBase = None
        self.rootScale = [1.0, 1.0, 1.0]
        self.fx = []            # geo cmd 0x08 procedural effect nodes
        self.warnings = []

    # ---- textures -------------------------------------------------------
    def read_texture_table(self, off, count):
        f = self.f
        for i in range(count):
            o = off + i * 0xC
            self.textures.append(dict(
                fmt=f.u8(o), siz=f.u8(o + 1), w=f.s16(o + 2),
                h=f.u16(o + 4), texels=f.u16(o + 6), data=f.ptr(o + 8)))

    def read_tlut_table(self, off, count):
        """Palettes reuse the texture-record layout: the count sits in the width
        field, the palette data in the next word, and unk_08 is the DL that loads
        it (src/12D80.c func_80015B20). The DL is authoritative, so run it."""
        f = self.f
        for i in range(count):
            o = off + i * 0xC
            rec = dict(count=f.u16(o + 2), data=f.ptr(o + 4), dl=f.ptr(o + 8))
            dl = rec['dl']
            if dl is not None:
                for _ in range(16):
                    w0, w1 = struct.unpack_from('>II', f.d, dl)
                    op = w0 >> 24
                    if op == 0xFD:                          # G_SETTIMG
                        rec['data'] = f.off(w1)
                    elif op == 0xF0:                        # G_LOADTLUT
                        rec['count'] = ((w1 >> 14) & 0x3FF) + 1
                    elif op == 0xDF:
                        break
                    dl += 8
            self.tluts.append(rec)

    # ---- geo layout -----------------------------------------------------
    def build(self):
        self.curTex = -1
        self.curTlut = -1
        self.curMat = None
        self.curTexAnim = -1
        # Mirrors gCurGraphNodeList in src/geo_layout.c: stack[-1] is the slot the
        # next node command writes to, and a node's parent -- and the bone whose
        # matrix is live -- is the slot *below* it (func_80017AC4).
        self.stack = [-1]
        # The RSP vertex cache persists across display lists: a bone's list often
        # preloads verts that the *next* bone's list then indexes, which is how
        # these models get blended joints. Each slot remembers the bone whose
        # matrix was current when it was loaded.
        self.vbuf = [None] * 64
        self.walk(self.geoLayouts[0])

    def walk(self, o, depth=0):
        f = self.f
        if depth > 32:
            return
        while True:
            cmd = f.u8(o)
            size = CMD_SIZES.get(cmd)
            if size is None:
                self.warnings.append(f'unknown geo cmd {cmd:#04x} at {o:#x}')
                return
            if cmd == 0x01 or cmd == 0x04:              # end / return
                return
            if cmd in (0x00, 0x03):                     # branch (with return)
                self.walk(f.ptr(o + 4), depth + 1)
            elif cmd == 0x02:                           # jump (no return)
                o = f.ptr(o + 4)
                continue
            elif cmd == 0x05:                           # open node
                self.stack.append(self.stack[-1])
            elif cmd == 0x06:                           # close node
                self.stack.pop()
            elif cmd == 0x17:                           # model header
                self.read_texture_table(f.ptr(o + 8), f.s16(o + 2))
                if f.ptr(o + 0xC):
                    self.read_tlut_table(f.ptr(o + 0xC), f.s16(o + 4))
                self.vtxBase = f.ptr(o + 0x10)
                self.nVerts = f.s16(o + 6)
            elif cmd == 0x08:                           # procedural effect callback
                self.fx.append(dict(bone=self.curBone(), callback=f.u32(o + 4),
                                    arg=f.ptr(o + 8)))
            elif cmd == 0x1C:                           # uniform scale node
                self.rootScale = [f.s32(o + 4) / 65536.0, f.s32(o + 8) / 65536.0,
                                  f.s32(o + 0xC) / 65536.0]
            elif cmd == 0x1D:                           # bone / joint node
                idx = len(self.bones)
                self.bones.append(dict(
                    parent=self.curBone(), boneId=f.u8(o + 1), flags=f.u8(o + 2),
                    chan=f.s8(o + 3),
                    t=[f.s16(o + 4), f.s16(o + 6), f.s16(o + 8)],
                    r=[f.s16(o + 0xA), f.s16(o + 0xC), f.s16(o + 0xE)],
                    s=[f.s32(o + 0x10) / 65536.0, f.s32(o + 0x14) / 65536.0,
                       f.s32(o + 0x18) / 65536.0]))
                self.boneById[f.u8(o + 1)] = idx
                self.stack[-1] = idx
            elif cmd == 0x23:                           # set texture / material
                self.curTex = f.s16(o + 8)
                self.curTlut = f.s16(o + 0xA)
                self.curMat = f.ptr(o + 4)
                # offset 0x02 is the texture-animation channel; -1 means static.
                # func_800176DC swaps this material's texture per frame from the
                # auxiliary animation's channel stream.
                self.curTexAnim = f.s16(o + 2)
            elif cmd == 0x22:                           # display list on current bone
                self.run_dl(f.ptr(o + 4), self.curBone())
            elif cmd == 0x1E:                           # display list on named bone
                self.run_dl(f.ptr(o + 4), self.boneById.get(f.s16(o + 2), self.curBone()))
            elif cmd in (0x20, 0x21):                   # display list + own transform
                self.run_dl(f.ptr(o + (0x10 if cmd == 0x20 else 0xC)), self.curBone())
            o += size

    def curBone(self):
        return self.stack[-2] if len(self.stack) >= 2 else -1

    # ---- display lists --------------------------------------------------
    def run_dl(self, o, bone, depth=0):
        if o is None or depth > 8:
            return
        f = self.f
        vbuf = self.vbuf
        cull = 0x400
        while True:
            w0, w1 = struct.unpack_from('>II', f.d, o)
            op = w0 >> 24
            o += 8
            if op == 0xDF:                              # G_ENDDL
                return
            if op == 0xDE:                              # G_DL
                self.run_dl(f.off(w1), bone, depth + 1)
                if (w0 >> 16) & 0xFF:                   # branch, not call
                    return
                continue
            if op == 0x01:                              # G_VTX
                n = (w0 >> 12) & 0xFF
                v0 = ((w0 & 0xFFF) >> 1) - n
                a = f.off(w1)
                for i in range(n):
                    p = a + i * 0x10
                    if 0 <= v0 + i < len(vbuf):
                        vbuf[v0 + i] = (
                            f.s16(p), f.s16(p + 2), f.s16(p + 4),       # position
                            f.s16(p + 8) / 32.0, f.s16(p + 10) / 32.0,  # s, t (S10.5)
                            f.s8(p + 12), f.s8(p + 13), f.s8(p + 14),   # normal
                            f.u8(p + 15),                               # alpha
                            bone)                                       # owning bone
                continue
            if op == 0xD9:                              # G_GEOMETRYMODE
                cull = (cull & (w0 & 0xFFFFFF)) | w1
                continue
            if op in (0x05, 0x06):                      # G_TRI1 / G_TRI2
                prim = self.prim_for(self.curTex, self.curTlut, self.curMat,
                                     self.curTexAnim, cull & 0x600)

                def emit(a, b, c):
                    tri = []
                    for idx in (a, b, c):
                        v = vbuf[idx] if idx < len(vbuf) else None
                        if v is None:
                            return
                        j = prim['_remap'].get(v)
                        if j is None:
                            j = len(prim['verts'])
                            prim['_remap'][v] = j
                            prim['verts'].append(v)
                        tri.append(j)
                    if (cull & 0x200) and not (cull & 0x400):
                        tri.reverse()
                    prim['tris'].append(tri)

                emit(((w0 >> 16) & 0xFF) // 2, ((w0 >> 8) & 0xFF) // 2, (w0 & 0xFF) // 2)
                if op == 0x06:
                    emit(((w1 >> 16) & 0xFF) // 2, ((w1 >> 8) & 0xFF) // 2, (w1 & 0xFF) // 2)
                continue
            # everything else (SETTILE / sync / ...) is state we reconstruct
            # from the texture table instead, so it is skipped.

    def prim_for(self, tex, tlut, mat, texAnim, cull):
        key = (tex, tlut, mat, texAnim, cull)
        p = self.primsByKey.get(key)
        if p is None:
            p = dict(tex=tex, tlut=tlut, mat=mat, texAnim=texAnim, cull=cull,
                     verts=[], tris=[], _remap={})
            self.primsByKey[key] = p
            self.prims.append(p)
        return p

    def tile_palette(self, mat):
        """CI4 selects a 16-entry block of the TLUT via the render tile's palette
        field; read it from the material display list's final G_SETTILE."""
        if mat is None:
            return 0
        pal = 0
        for _ in range(16):
            w0, w1 = struct.unpack_from('>II', self.f.d, mat)
            op = w0 >> 24
            if op == 0xF5 and ((w1 >> 24) & 7) == 0:        # G_SETTILE, render tile
                pal = (w1 >> 20) & 0xF
            elif op == 0xDF:
                break
            mat += 8
        return pal


# ---------------------------------------------------------------- animations

def bitfield(f, base, index, bits):
    """src/F420.c func_80010500: signed `bits`-wide field at bit index*bits.

    C integer division truncates toward zero; Python's floors. That only differs
    for negative indices, which is exactly what an empty channel produces, so the
    truncating form is used here to match the hardware."""
    bitpos = index * bits
    word = bitpos // 16 if bitpos >= 0 else -((-bitpos) // 16)   # C: trunc to zero
    rem = bitpos - word * 16                                     # C: sign follows bitpos
    o = base + word * 2
    v = (f.u16(o) << 16) | f.u16(o + 2)
    v = (v << (rem & 31)) & 0xFFFFFFFF          # MIPS masks the shift to 5 bits
    return signed(v >> (32 - bits), bits)


class Animation:
    """src/17300.c. Two sampling modes: packed per-frame streams (default) and
    hermite keyframes (flags & 8)."""

    def __init__(self, frag, off):
        f = self.f = frag
        self.off = off
        self.flags     = f.u8(off)
        self.startFrame= f.u16(off + 4)
        self.loopStart = f.u16(off + 6)
        self.nChannels = f.u16(off + 8)
        self.nFrames   = f.u16(off + 0xA)
        self.chanTable = f.ptr(off + 0xC)
        self.scaleData = f.ptr(off + 0x10)
        self.rotData   = f.ptr(off + 0x14)
        self.transData = f.ptr(off + 0x18)

    def chan(self, i):
        o = self.chanTable + i * 0xA
        f = self.f
        return dict(nScale=f.u8(o), nRot=f.u8(o + 1), nTrans=f.u8(o + 2),
                    interp=f.u8(o + 3), oScale=f.u16(o + 4),
                    oRot=f.u16(o + 6), oTrans=f.u16(o + 8))

    # -- packed stream sampling (flags & 8 == 0) --------------------------
    # A count of 0 means the component has no stream at all. The game's index
    # arithmetic (offset + count - 1) then runs off the front of the array -- for
    # Tangela's idle the scale "array" is two entries long and the computed index
    # is 99 -- so an empty channel is treated as "keep the bind-pose value".
    def _trans_packed(self, c, frame):
        if c['nTrans'] == 0:
            return None
        bits = 16 if (self.flags & 4) else 12
        if c['nTrans'] == 1:
            return float(c['oTrans'] if (self.flags & 4) else signed((c['oTrans'] * 16) & 0xFFFF, 16) >> 4)
        i = c['oTrans'] + min(frame, c['nTrans'] - 1)
        return float(bitfield(self.f, self.transData, i, bits))

    def _rot_packed(self, c, frame):
        if c['nRot'] == 0:
            return None
        if c['nRot'] == 1:
            return signed((c['oRot'] * 16) & 0xFFFF, 16)
        i = c['oRot'] + min(frame, c['nRot'] - 1)
        return signed((bitfield(self.f, self.rotData, i, 12) * 16) & 0xFFFF, 16)

    def _scale_packed(self, c, frame):
        if c['nScale'] == 0:
            return None
        if c['nScale'] == 1:
            return c['oScale'] / 1000.0
        i = c['oScale'] + min(frame, c['nScale'] - 1)
        return self.f.s16(self.scaleData + i * 2) / 1000.0

    # -- hermite keyframe sampling (flags & 8) ----------------------------
    def _hermite(self, base, n, frame, wide):
        f = self.f
        stride = 8 if wide else 6

        def key(i):
            o = base + i * stride
            return (f.s16(o), f.s16(o + 2), f.s16(o + 4),
                    f.s16(o + 6) if wide else f.s16(o + 4))

        k0 = key(0)
        if k0[0] >= frame:
            return float(k0[1])
        last = key(n - 1)
        if frame >= last[0]:
            return float(last[1])
        i = 0
        while i < n - 2:
            if frame < key(i + 1)[0]:
                break
            i += 1
        a, b = key(i), key(i + 1)
        x = (frame - a[0]) / 30.0
        y = 30.0 / (b[0] - a[0])
        x2, x3 = x * x, x * x * x
        y2, y3 = y * y, y * y * y
        return (a[1] * (2 * x3 * y3 - 3 * x2 * y2 + 1)
                + b[1] * (-2 * x3 * y3 + 3 * x2 * y2)
                + (a[3] if wide else a[2]) * (x3 * y2 - 2 * x2 * y + x)
                + b[2] * (x3 * y2 - x2 * y))

    def _trans_key(self, c, frame):
        if c['nTrans'] < 2:
            return float(signed(c['oTrans'], 16))
        return self._hermite(self.transData + c['oTrans'] * 2, c['nTrans'], frame, c['interp'] & 1)

    def _rot_key(self, c, frame):
        if c['nRot'] < 2:
            deg = signed(c['oRot'], 16) / 10.0
        else:
            deg = self._hermite(self.rotData + c['oRot'] * 2, c['nRot'], frame, c['interp'] & 2) / 10.0
        deg %= 360.0
        return int(deg / 360.0 * 65536.0)

    def _scale_key(self, c, frame):
        if c['nScale'] < 2:
            return signed(c['oScale'], 16) / 100.0
        return self._hermite(self.scaleData + c['oScale'] * 2, c['nScale'], frame, c['interp'] & 4) / 100.0

    def sample_trs(self, chanIndex, frame, bind=None):
        """Returns (translation, rotation, scale) triples for one bone. Components
        whose channel carries no data fall back to the bone's bind value."""
        base = chanIndex * 3
        if base < 0 or base + 2 >= self.nChannels:
            return None
        cs = [self.chan(base + i) for i in range(3)]
        if self.flags & 8:
            out = ([self._trans_key(c, frame) for c in cs],
                   [self._rot_key(c, frame) for c in cs],
                   [self._scale_key(c, frame) for c in cs])
        else:
            out = ([self._trans_packed(c, frame) for c in cs],
                   [self._rot_packed(c, frame) for c in cs],
                   [self._scale_packed(c, frame) for c in cs])
        if bind is None:
            bind = ([0, 0, 0], [0, 0, 0], [1.0, 1.0, 1.0])
        return tuple([v if v is not None else bind[k][i] for i, v in enumerate(comp)]
                     for k, comp in enumerate(out))


class AuxAnimation:
    """Texture animation (src/18140.c). Same header shape as the skeletal
    animations, but each channel is a per-frame stream of texture-table indices
    that func_800176DC substitutes into a material."""

    def __init__(self, frag, off):
        f = self.f = frag
        self.flags     = f.u8(off)
        self.startFrame= f.u16(off + 4)
        self.loopStart = f.u16(off + 6)
        self.nChannels = f.u16(off + 8)
        self.nFrames   = f.u16(off + 0xA)
        self.chanTable = f.ptr(off + 0xC)
        self.data      = f.ptr(off + 0x10)

    def sample(self, chan, frame):
        """func_80017540: index into the stream, clamped to the channel length."""
        if not (0 <= chan < self.nChannels) or self.chanTable is None:
            return None
        o = self.chanTable + chan * 4
        count, base = self.f.u16(o), self.f.u16(o + 2)
        if count == 0:
            return None
        i = base + (frame if frame < count else count - 1)
        return self.f.u8(self.data + i)

    def track(self, chan):
        n = max(1, self.nFrames)
        return [self.sample(chan, i) for i in range(n)]


# ------------------------------------------------------------------ textures

def rgba5551(p):
    return (((p >> 11) & 0x1F) * 255 // 31, ((p >> 6) & 0x1F) * 255 // 31,
            ((p >> 1) & 0x1F) * 255 // 31, 255 if (p & 1) else 0)


def decode_texture(f, tex, tlut=None, palette=0):
    """Returns (w, h, RGBA8 bytes) for the N64 texture formats these models use."""
    w, h, fmt, siz, addr = tex['w'], tex['h'], tex['fmt'], tex['siz'], tex['data']
    out = bytearray(w * h * 4)
    d = f.d
    n = w * h

    def nibble(i):
        return (d[addr + i // 2] >> (0 if i & 1 else 4)) & 0xF

    if fmt == 0 and siz == 2:                       # RGBA16 (5/5/5/1)
        for i in range(n):
            out[i*4:i*4+4] = bytes(rgba5551(struct.unpack_from('>H', d, addr + i * 2)[0]))
    elif fmt == 0 and siz == 3:                     # RGBA32
        out[:] = d[addr:addr + n * 4]
    elif fmt == 2:                                  # CI4 / CI8 -> RGBA16 palette
        pal = []
        if tlut is not None and tlut['data'] is not None:
            base = tlut['data'] + (palette * 16 * 2 if siz == 0 else 0)
            for i in range(16 if siz == 0 else 256):
                pal.append(bytes(rgba5551(struct.unpack_from('>H', d, base + i * 2)[0])))
        if not pal:
            pal = [b'\xff\x00\xff\xff'] * 256
        for i in range(n):
            idx = nibble(i) if siz == 0 else d[addr + i]
            out[i*4:i*4+4] = pal[idx % len(pal)]
    elif fmt == 3:                                  # IA16 / IA8 / IA4
        for i in range(n):
            if siz == 2:
                v = struct.unpack_from('>H', d, addr + i * 2)[0]
                l, a = v >> 8, v & 0xFF
            elif siz == 1:
                v = d[addr + i]
                l, a = (v >> 4) * 17, (v & 0xF) * 17
            else:
                v = nibble(i)
                l, a = ((v >> 1) * 255) // 7, 255 if (v & 1) else 0
            out[i*4:i*4+4] = bytes((l, l, l, a))
    elif fmt == 4:                                  # I8 / I4
        for i in range(n):
            l = d[addr + i] if siz == 1 else nibble(i) * 17
            out[i*4:i*4+4] = bytes((l, l, l, 255))
    else:
        for i in range(n):                          # unsupported: magenta
            out[i*4:i*4+4] = b'\xff\x00\xff\xff'
    return w, h, bytes(out)


def png(w, h, rgba):
    """Minimal PNG encoder (no PIL dependency)."""
    raw = b''.join(b'\x00' + rgba[y*w*4:(y+1)*w*4] for y in range(h))

    def chunk(tag, data):
        c = tag + data
        return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xFFFFFFFF)

    return (b'\x89PNG\r\n\x1a\n'
            + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0))
            + chunk(b'IDAT', zlib.compress(raw, 9))
            + chunk(b'IEND', b''))


# ---------------------------------------------------------------------- main

def unique(seq):
    """The distinct values of `seq`, in the order they first appear.

    Used where a set used to be. A set of small ints iterates in hash-slot
    order, which is stable across runs but is neither insertion nor sort order
    and is a CPython implementation detail -- and here it decided the order
    textures get REGISTERED in, and so their indices in the packed file. Order
    of appearance is a property of the data instead of the interpreter, which
    is what lets the Lua extractor produce the same file.
    """
    seen, out = set(), []
    for v in seq:
        if v in seen:
            continue
        seen.add(v)
        out.append(v)
    return out


def dedupe_fx(nodes):
    """The geo layout's effect callbacks, once each, IN THE ORDER THEY APPEAR.

    A geo layout can name the same callback on the same bone more than once
    (the walk visits a subtree twice), so these have to be deduplicated, and it
    used to be done by dropping them through a set. That was a real bug rather
    than a style point: the set held tuples containing strings, so its iteration
    order moved with PYTHONHASHSEED, and the generated flames of every species
    carrying more than one -- Ponyta, Rapidash and Moltres -- came out in a
    different order, with different seeds and therefore different pixels, on
    different runs of the same build.

    Order of appearance is the game's own order, it is stable, and it is what
    the Lua extractor can reproduce.
    """
    seen, out = set(), []
    for node in nodes:
        key = (node['bone'], node['callback'], node['arg'])
        if key in seen:
            continue
        seen.add(key)
        out.append(dict(node))
    return out


def extract(path, name=None, raw=False):
    """`raw=True` carries each texture's decoded RGBA8 bytes as `rgba` instead
    of encoding a PNG data URI into `png`.

    The viewer and the glTF export both want a PNG, so that stays the default.
    The mod's own packer wants the pixels: it stores them uncompressed, so that
    its Lua counterpart -- which has no zlib whose output is guaranteed to
    agree with this one's byte for byte -- can be checked against it exactly.
    """
    f = Fragment(path, name or str(path))
    m = Model(f)
    m.build()

    import base64
    auxAnims = [AuxAnimation(f, o) for o in m.auxAnims]

    texIndexMap, texOut = {}, []

    def register(texIdx, tlut, pal):
        key = (texIdx, tlut, pal)
        if key in texIndexMap:
            return texIndexMap[key]
        if not (0 <= texIdx < len(m.textures)):
            return -1
        texIndexMap[key] = len(texOut)
        tl = m.tluts[tlut] if 0 <= tlut < len(m.tluts) else None
        w, h, rgba = decode_texture(f, m.textures[texIdx], tl, pal)
        rec = dict(index=texIdx, w=w, h=h)
        if raw:
            rec['rgba'] = rgba
        else:
            rec['png'] = ('data:image/png;base64,'
                          + base64.b64encode(png(w, h, rgba)).decode())
        texOut.append(rec)
        return texIndexMap[key]

    for p in m.prims:
        if not p['tris']:
            continue
        pal = m.tile_palette(p['mat'])
        register(p['tex'], p['tlut'], pal)
        # An animated material can swap to any texture its channel names, so all
        # of them have to be decoded up front.
        if p['texAnim'] >= 0:
            for a in auxAnims:
                for t in unique(a.track(p['texAnim'])):
                    if t is not None:
                        register(t, p['tlut'], pal)

    prims = []
    for p in m.prims:
        if not p['tris']:
            continue
        pos, uv, nrm, skin = [], [], [], []
        pal = m.tile_palette(p['mat'])
        ti = texIndexMap.get((p['tex'], p['tlut'], pal), -1)
        # texture-table index -> slot in texOut, for the animated swap
        texMap = {}
        if p['texAnim'] >= 0:
            for a in auxAnims:
                for t in unique(a.track(p['texAnim'])):
                    if t is not None and (t, p['tlut'], pal) in texIndexMap:
                        texMap[t] = texIndexMap[(t, p['tlut'], pal)]
        tw, th = (m.textures[p['tex']]['w'], m.textures[p['tex']]['h']) if ti >= 0 else (32, 32)
        for v in p['verts']:
            pos += [v[0], v[1], v[2]]
            uv += [v[3] / tw, v[4] / th]
            nrm += [v[5] / 127.0, v[6] / 127.0, v[7] / 127.0]
            skin.append(v[9])
        prims.append(dict(tex=ti, cull=p['cull'], texAnim=p['texAnim'],
                          texMap={str(k): v for k, v in sorted(texMap.items())},
                          pos=pos, uv=uv, nrm=nrm, skin=skin,
                          idx=[i for t in p['tris'] for i in t]))

    def compress(values, nd):
        """Constant tracks collapse to a scalar; most channels never move."""
        r = [round(v, nd) for v in values]
        return r[0] if all(v == r[0] for v in r) else r

    anims = []
    for i, off in enumerate(m.anims):
        a = Animation(f, off)
        nf = max(1, a.nFrames)
        tracks = []
        for b in m.bones:
            ch = b['chan']
            bind = (b['t'], b['r'], b['s'])
            if ch < 0 or a.sample_trs(ch, 0, bind) is None:
                tracks.append(None)
                continue
            samples = [a.sample_trs(ch, fr, bind) for fr in range(nf)]
            tracks.append(dict(
                t=[compress([s[0][k] for s in samples], 3) for k in range(3)],
                r=[compress([s[1][k] for s in samples], 0) for k in range(3)],
                s=[compress([s[2][k] for s in samples], 5) for k in range(3)]))
        anims.append(dict(index=i, frames=nf, flags=a.flags,
                          channels=a.nChannels, loopStart=a.loopStart, tracks=tracks))

    auxOut = []
    for i, a in enumerate(auxAnims):
        auxOut.append(dict(index=i, frames=max(1, a.nFrames), flags=a.flags,
                           loopStart=a.loopStart,
                           channels=[a.track(c) for c in range(a.nChannels)]))

    return dict(
        species=m.species,
        name=SPECIES.get(m.species, f'#{m.species}'),
        file=os.path.basename(f.name),
        rootScale=m.rootScale,
        bones=[dict(parent=b['parent'], boneId=b['boneId'], chan=b['chan'],
                    flags=b['flags'], t=b['t'], r=b['r'], s=b['s']) for b in m.bones],
        textures=texOut,
        prims=prims,
        anims=anims,
        auxAnims=auxOut,
        fx=dedupe_fx(m.fx),
        warnings=m.warnings,
    )


SPECIES = {}
_NAMES = (
    "Bulbasaur Ivysaur Venusaur Charmander Charmeleon Charizard Squirtle Wartortle Blastoise "
    "Caterpie Metapod Butterfree Weedle Kakuna Beedrill Pidgey Pidgeotto Pidgeot Rattata Raticate "
    "Spearow Fearow Ekans Arbok Pikachu Raichu Sandshrew Sandslash NidoranF Nidorina Nidoqueen "
    "NidoranM Nidorino Nidoking Clefairy Clefable Vulpix Ninetales Jigglypuff Wigglytuff Zubat "
    "Golbat Oddish Gloom Vileplume Paras Parasect Venonat Venomoth Diglett Dugtrio Meowth Persian "
    "Psyduck Golduck Mankey Primeape Growlithe Arcanine Poliwag Poliwhirl Poliwrath Abra Kadabra "
    "Alakazam Machop Machoke Machamp Bellsprout Weepinbell Victreebel Tentacool Tentacruel Geodude "
    "Graveler Golem Ponyta Rapidash Slowpoke Slowbro Magnemite Magneton Farfetchd Doduo Dodrio "
    "Seel Dewgong Grimer Muk Shellder Cloyster Gastly Haunter Gengar Onix Drowzee Hypno Krabby "
    "Kingler Voltorb Electrode Exeggcute Exeggutor Cubone Marowak Hitmonlee Hitmonchan Lickitung "
    "Koffing Weezing Rhyhorn Rhydon Chansey Tangela Kangaskhan Horsea Seadra Goldeen Seaking "
    "Staryu Starmie MrMime Scyther Jynx Electabuzz Magmar Pinsir Tauros Magikarp Gyarados Lapras "
    "Ditto Eevee Vaporeon Jolteon Flareon Porygon Omanyte Omastar Kabuto Kabutops Aerodactyl "
    "Snorlax Articuno Zapdos Moltres Dratini Dragonair Dragonite Mewtwo Mew").split()
for _i, _n in enumerate(_NAMES):
    SPECIES[_i + 1] = _n


if __name__ == '__main__':
    here = os.path.dirname(os.path.abspath(__file__))
    src = sys.argv[1] if len(sys.argv) > 1 else 'assets/us/pokemon_models/24.bin'
    dst = sys.argv[2] if len(sys.argv) > 2 else os.path.join(here, 'model.js')
    data = extract(src)
    body = json.dumps(data, separators=(',', ':'))
    with open(dst, 'w') as fp:
        fp.write('window.PKMN_MODEL = ' + body + ';\n')
    tris = sum(len(p['idx']) // 3 for p in data['prims'])
    print(f"{data['name']} (#{data['species']})  bones={len(data['bones'])}  prims={len(data['prims'])}  "
          f"tris={tris}  textures={len(data['textures'])}  anims={len(data['anims'])}")
    print(f"frames per anim: {[a['frames'] for a in data['anims']]}")
    if data['warnings']:
        print('warnings:', data['warnings'][:5])
    print(f'wrote {dst} ({os.path.getsize(dst)/1024:.0f} KB)')
