#!/usr/bin/env python3
# extractFromDump.py -- convert a VICE C64 memory dump into JoeyLib
# Space Taxi assets.
#
# Reads:   /tmp/spacetaxi/mem0000-<labelN>.bin (VICE `save` output;
#          2-byte LE start-addr header + 64 KB RAM).
# Writes:  examples/spacetaxi/assets/tiles/tilebank<N>.png
#          examples/spacetaxi/assets/levels/level<NN>.txt
#          examples/spacetaxi/assets/sprites/sprites.png  (level1 only)
#          examples/spacetaxi/assets/font.png             (level1 only)
#
# Per-level:
#   - Custom charset at $2800-$2FFF (2 KB, 256 chars * 8 bytes mono)
#     becomes a tile bank PNG: 40 cols x 25 rows of 8x8 cells, with
#     tile index N at cell (N%40, N/40). The PNG uses the EGA-ish
#     16-color palette we already install on stage.
#   - Screen RAM at $0400-$07E7 (1000 bytes = 40x25 char codes) and
#     color RAM at $D800-$DBE7 (1000 bytes = 40x25 palette nybbles)
#     become the tilemap+colormap in the level .txt source.
#
# Sprite data at $3000-$37FF + sprite pointer table at $07F8-$07FF
# is decoded once (it's the same across levels for the active set):
#   - sprite 0 / 7: the taxi cab body & shadow
#   - sprite 1..6: passenger frames / animation cels
# 64 bytes per sprite, 24x21 1-bit, MSB-leftmost. Color comes from
# $D027-$D02E (one color per sprite).
#
# Usage:
#   python3 extractFromDump.py stuff/spacetaxi/mem0000-level1.bin level01 1
#   python3 extractFromDump.py stuff/spacetaxi/mem0000-level2.bin level02 2
# args: <dump-path> <level-name-without-extension> <tilebank-id>
#
# Dumps live in stuff/spacetaxi/ (committed-ish, repo-local) rather
# than /tmp so they survive reboots and stay with the project.

import os
import struct
import sys
from collections import Counter

from PIL import Image


# C64 standard color palette (EGA-ish 16-color JoeyLib mapping).
# Indices 0..15 match the C64 VIC-II color register order so a value
# read from $D8xx or $D02x maps directly to a JoeyLib palette slot.
# Colors below approximate Pepto's standard PAL palette in $RGB form.
C64_PALETTE_RGB = [
    (0x00, 0x00, 0x00),  # 0  black
    (0xFF, 0xFF, 0xFF),  # 1  white
    (0x88, 0x39, 0x32),  # 2  red
    (0x67, 0xB6, 0xBD),  # 3  cyan
    (0x8B, 0x3F, 0x96),  # 4  purple
    (0x55, 0xA0, 0x49),  # 5  green
    (0x40, 0x31, 0x8D),  # 6  blue
    (0xBF, 0xCE, 0x72),  # 7  yellow
    (0x8B, 0x54, 0x29),  # 8  orange
    (0x57, 0x42, 0x00),  # 9  brown
    (0xB8, 0x69, 0x62),  # 10 light red
    (0x50, 0x50, 0x50),  # 11 dark gray
    (0x78, 0x78, 0x78),  # 12 mid gray
    (0x94, 0xE0, 0x89),  # 13 light green
    (0x78, 0x69, 0xC4),  # 14 light blue
    (0x9F, 0x9F, 0x9F),  # 15 light gray
]


ROOT = os.path.dirname(os.path.abspath(__file__))


def load_dump(path):
    with open(path, "rb") as f:
        raw = f.read()
    start = raw[0] | (raw[1] << 8)
    if start != 0:
        raise SystemExit(f"{path}: unexpected start ${start:04X}, want $0000")
    if len(raw) - 2 != 0x10000:
        raise SystemExit(f"{path}: not a 64K dump (got {len(raw) - 2} bytes)")
    return raw[2:]


def new_canvas(w, h):
    img = Image.new("P", (w, h), 0)
    flat = bytearray()
    for r, g, b in C64_PALETTE_RGB:
        flat.extend((r, g, b))
    flat.extend(b"\x00" * (768 - len(flat)))
    img.putpalette(bytes(flat))
    return img


def paint_charset_glyph(px, mem, src_code, bx, by):
    """Plot the 8x8 glyph for C64 screen code `src_code` at pixel
    (bx, by) of the indexed-palette PIL image. Foreground = palette
    index 1, background untouched (already index 0 from new_canvas).
    """
    src = 0x2800 + src_code * 8
    for r in range(8):
        byte = mem[src + r]
        for c in range(8):
            if byte & (0x80 >> c):
                px[bx + c, by + r] = 1


def extract_tilebank(mem, out_path, code_to_slot=None):
    """Render the custom charset at $2800-$2FFF as a 320x200 PNG.
    When `code_to_slot` is None, lays out all 256 chars at their raw
    C64 positions (40 cols x 25 rows). When `code_to_slot` is given,
    writes each charset glyph at the slot the level loader expects
    -- i.e. the bank tile at position N is the glyph for screen-code
    `code_for_slot[N]`. That keeps level data and tilebank aligned
    after the level extractor's frequency-sorted code remapping.
    """
    img = new_canvas(320, 200)
    px = img.load()
    if code_to_slot is None:
        for ch in range(256):
            paint_charset_glyph(px, mem, ch, (ch % 40) * 8, (ch // 40) * 8)
    else:
        # Invert: slot -> code. Multiple codes can collide into slot 0
        # (background); only the first one we see "owns" that slot for
        # painting purposes -- the rest just fall through to the same
        # blank tile. Highest priority is the explicit space mapping.
        slot_to_code = {}
        for code, slot in code_to_slot.items():
            slot_to_code.setdefault(slot, code)
        for slot, code in slot_to_code.items():
            if slot >= 256:
                continue
            paint_charset_glyph(px, mem, code, (slot % 40) * 8, (slot // 40) * 8)
    img.save(out_path, "PNG", optimize=True)


def build_code_to_slot(mem):
    """Build the screen-code -> alphabet-slot map shared between the
    tilebank emission and the level emission. The slot index is what
    the runtime sees in level.tilemap[]; the tilebank must mirror this
    ordering so a level-data slot-N cell renders the charset glyph
    that was actually used in the original screen RAM at that code.
    """
    screen = mem[0x0400:0x0400 + 1000]
    codes = sorted(Counter(screen).items(), key=lambda kv: (-kv[1], kv[0]))
    code_to_slot = {}
    next_slot = 0
    for code, _count in codes:
        if code == 0x20:                  # space -> always index 0
            code_to_slot[code] = 0
            continue
        if code in code_to_slot:
            continue
        if next_slot >= len(ALPHABET):
            code_to_slot[code] = 0
            continue
        # Reserve slot 0 for background (space). The very first
        # non-space code claims slot 1, etc.
        if next_slot == 0:
            next_slot = 1
        code_to_slot[code] = next_slot
        next_slot += 1
    return code_to_slot


def screencode_to_char(code):
    """Convert a C64 screen code into a printable tilemap character
    using the encoding the .txt format already understands:
        0      -> '.' (treated as 0)
        1..9   -> '1'..'9'  (digits encode the lower 10 indices)
                  but we shift to use up most of the index space below.
    For arbitrary 0..255 we need a 256-symbol alphabet -- not feasible
    in a single character per cell. So we encode as TWO chars per cell
    using base-16 hex would still only cover 0..255. Decision: keep
    .txt cells one-char and map screen codes via a custom 64-char
    alphabet plus an @char directive table emitted into the file so
    the loader knows exactly which screen code each char represents.
    """
    # This function is unused as a one-char encoder; we encode via the
    # generic alphabet below instead.
    return code


# Generic 1-char alphabet (62 codes); the level loader's index map
# was: '.' or ' '=0, '0'..'9'=0..9, 'A'..'Z'=10..35, 'a'..'z'=36..61.
# That's only 62 codes, not 256. The original C64 levels use ~30
# distinct screen codes per level so most levels fit. We map the N
# most-frequent screen codes to those alphabet slots.
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"


def extract_level(mem, level_name, tilebank_id, out_path, code_to_alpha):
    """Render screen RAM + color RAM as an STL1-format .txt source
    using the prebuilt screen-code -> alphabet-slot mapping. Caller
    must pass the same mapping that was used to reorder the tilebank
    PNG; otherwise the level tilemap and tilebank diverge.
    """
    screen = mem[0x0400:0x0400 + 1000]   # 40x25
    color  = mem[0xD800:0xD800 + 1000]   # 40x25, low nybble = palette slot
    codes  = sorted(Counter(screen).items(), key=lambda kv: (-kv[1], kv[0]))

    # Full 25-row C64 screen. The title screen uses all 25 rows
    # (PUBLISHED ... SOFTWARE credit wraps across rows 21, 22, 24
    # with frame chars interleaved). Gameplay HUD draws over the
    # bottom 3 rows at runtime.
    playfield_rows = 25

    lines = []
    lines.append(f"# Extracted from VICE memory dump. Edit at your own risk;")
    lines.append(f"# regenerating will overwrite. Keep the @tile mappings in")
    lines.append(f"# sync with the tilebank PNG (extracted from $2800).")
    lines.append(f"")
    lines.append(f"@name {level_name.upper()}")
    lines.append(f"@tilebank {tilebank_id}")
    lines.append(f"@music 0")
    bg = mem[0xD021] & 0x0F
    border = mem[0xD020] & 0x0F
    lines.append(f"@bgColor {bg}")
    lines.append(f"@borderColor {border}")

    # Best-effort taxi spawn: middle-top of playfield. The original
    # script-driven spawn position would need to be lifted out of
    # game state at dump time; for now hand-edit if you want it
    # to match the cracker's preferred spawn.
    lines.append(f"@taxiSpawn 18 2")
    lines.append(f"")

    # Pads: scan the playfield for "landable surfaces" -- runs of
    # >= 3 identical non-background tile cells with two or more
    # empty rows directly above (so the taxi can approach from the
    # top). Per-row scanning runs top-to-bottom; for the same
    # surface tile across rows we only take the topmost contiguous
    # platform edge.
    bg_code = codes[0][0] if codes else 0x20
    def is_bg(code):
        return code == bg_code or code == 0x20

    pads = []
    seen_used = [False] * (playfield_rows * 40)
    for row in range(2, playfield_rows):
        line = screen[row * 40:(row + 1) * 40]
        prev = screen[(row - 1) * 40:row * 40]
        prev2 = screen[(row - 2) * 40:(row - 1) * 40]
        col = 0
        while col < 40 and len(pads) < 8:
            if is_bg(line[col]):
                col += 1
                continue
            j = col
            while j < 40 and line[j] == line[col] and not seen_used[row * 40 + j]:
                j += 1
            width = j - col
            # Approachable from above? At least 2/3 of cells in the
            # 2 rows above this stretch must be background.
            above_clear = 0
            for k in range(col, j):
                if is_bg(prev[k]):
                    above_clear += 1
                if is_bg(prev2[k]):
                    above_clear += 1
            if width >= 3 and above_clear >= width:
                pads.append((row, col, width))
                for k in range(col, j):
                    seen_used[row * 40 + k] = True
            col = j

    lines.append("# Pads auto-detected from landable surfaces (>= 3 wide,")
    lines.append("# approachable from above). Verify and adjust as needed.")
    pad_letter = ord("A")
    for (row, col, width) in pads:
        if pad_letter > ord("H"):
            break
        lines.append(f"@pad {chr(pad_letter)} {col} {row} {width}")
        pad_letter += 1
    if pad_letter == ord("A"):
        lines.append("# (No landable surfaces auto-detected; emitting placeholders.)")
        lines.append("@pad A 4 10 4")
        lines.append("@pad B 32 10 4")
        pad_letter = ord("C")

    lines.append("")
    # Fare list: simple round-robin between detected pads.
    pad_count = pad_letter - ord("A")
    if pad_count >= 2:
        for k in range(pad_count):
            src = chr(ord("A") + k)
            dst = chr(ord("A") + ((k + 1) % pad_count))
            lines.append(f"@fare {src} {dst} 45")
    else:
        lines.append("@fare A B 45")
    lines.append("")

    # Emit the @tile mapping for round-trip clarity (and so the
    # author can read a level .txt and tell which screen code maps
    # to which char). Format: @tile <char> <screencode> -- the
    # mkstlevel parser ignores these today but they're useful
    # documentation, and a future loader could honor them.
    lines.append("# Screen-code -> alphabet-char mapping (one per unique code).")
    inv = {v: k for k, v in code_to_alpha.items()}
    for idx in sorted(inv):
        ch = '.' if idx == 0 else ALPHABET[idx]
        code = inv[idx]
        lines.append(f"# @tile '{ch}' = $C64_screencode_{code:02X}")
    lines.append("")

    # Tilemap (22 rows of 40 chars each).
    lines.append("@tilemap")
    for r in range(playfield_rows):
        row = screen[r * 40:(r + 1) * 40]
        s = ""
        for code in row:
            idx = code_to_alpha.get(code, 0)
            s += '.' if idx == 0 else ALPHABET[idx]
        lines.append(s)

    lines.append("")
    lines.append("@colormap")
    for r in range(playfield_rows):
        row = color[r * 40:(r + 1) * 40]
        s = "".join(
            ('0' if (c & 0x0F) == 0
             else ALPHABET[(c & 0x0F)]
             if (c & 0x0F) < len(ALPHABET) else '0')
            for c in row
        )
        lines.append(s)

    with open(out_path, "w") as fp:
        fp.write("\n".join(lines) + "\n")


def extract_sprites(mem, out_path):
    """Render C64 hardware sprites into the JoeyLib-expected sprite
    sheet layout (320x200, 16-color indexed PNG):
        y=  0..23  : 1 taxi cel (24x24, x = 0)
                       sprite 0 ptr (in level1 dump: $DC -> $3700)
                     The adjacent slot $DD is the landing-gear-down
                     pose, not a thrust frame -- ignored for now.
        y= 24..47  : 4 passenger cels (16x16 each, x = 0, 16, 32, 48)
    Sprite 3 ptr ($07FB) is the passenger data; sprites 3-6 share it.
    """
    img = new_canvas(320, 200)
    px = img.load()

    # Shared multicolor sprite colors from VIC registers.
    mcm_color0 = mem[0xD025] & 0x0F     # 2-bit value 01
    mcm_color1 = mem[0xD026] & 0x0F     # 2-bit value 11
    mcm_mask   = mem[0xD01C]            # bit per sprite: 1 = multicolor

    def paint_sprite_at(data_addr, sprite_color, dst_x, dst_y, dst_w, dst_h, multicolor=False):
        # Mono C64 sprite is 24x21, 1 bit per pixel, 3 bytes per row.
        # Multicolor C64 sprite is 12 "double-wide pixels" x 21 rows,
        # 2 bits per pixel, still 3 bytes per row. Color mapping:
        #   00 -> transparent
        #   01 -> mcm_color0 ($D025)
        #   10 -> sprite_color (sprite's own $D027+sp)
        #   11 -> mcm_color1 ($D026)
        if sprite_color == 0:
            sprite_color = 1
        sx_max = min(24, dst_w)
        sy_max = min(21, dst_h)
        for r in range(sy_max):
            if multicolor:
                for byte_idx in range(3):
                    byte = mem[data_addr + r * 3 + byte_idx]
                    # 4 multicolor pixels per byte; each spans 2
                    # hardware pixels wide.
                    for pp in range(4):
                        bits = (byte >> ((3 - pp) * 2)) & 0x03
                        if bits == 0:
                            continue
                        if bits == 1:
                            col = mcm_color0
                        elif bits == 2:
                            col = sprite_color
                        else:
                            col = mcm_color1
                        sx0 = byte_idx * 8 + pp * 2
                        if sx0 + 1 >= sx_max:
                            break
                        px[dst_x + sx0,     dst_y + r] = col
                        px[dst_x + sx0 + 1, dst_y + r] = col
            else:
                for byte_idx in range(3):
                    byte = mem[data_addr + r * 3 + byte_idx]
                    for bit in range(8):
                        sx = byte_idx * 8 + bit
                        if sx >= sx_max:
                            break
                        if byte & (0x80 >> bit):
                            px[dst_x + sx, dst_y + r] = sprite_color

    def paint_c64_sprite(sp_index, dst_x, dst_y, dst_w, dst_h):
        ptr = mem[0x07F8 + sp_index]
        if ptr == 0:
            return False
        sprite_color = mem[0xD027 + sp_index] & 0x0F
        is_multicolor = bool(mcm_mask & (1 << sp_index))
        paint_sprite_at(ptr * 64, sprite_color, dst_x, dst_y, dst_w, dst_h,
                        multicolor=is_multicolor)
        return True

    # Single taxi cel from sprite 0's actual pointer.
    paint_c64_sprite(0, 0, 0, 24, 24)

    # 4 passenger cels = the shared C64 sprite 3 (sprites 3..6 all
    # point to the same data in the level-1 dump).
    for cel in range(4):
        paint_c64_sprite(3, cel * 16, 24, 16, 16)

    img.save(out_path, "PNG", optimize=True)


def extract_font(mem, out_path):
    """Render the custom charset (which IS the in-game font in C64
    text-mode games) as the 320x200 font.png. Same layout as
    tilebank, but here we want each glyph at cell (ascii%40,
    ascii/40) for jlDrawText's ASCII map.

    For Space Taxi specifically, the charset doubles as the level
    tile bank AND any in-screen text Sierra-style. We map the C64
    screen codes used for ASCII display (codes 1..26 = A..Z,
    32..63 = punctuation/digits) directly to their ASCII positions
    in the JoeyLib font sheet.
    """
    img = new_canvas(320, 200)
    px = img.load()

    def render_at(ascii_code, charset_idx):
        if ascii_code < 32 or ascii_code >= 128:
            return
        src = 0x2800 + (charset_idx & 0xFF) * 8
        col = ascii_code % 40
        row = ascii_code // 40
        bx = col * 8
        by = row * 8
        for r in range(8):
            byte = mem[src + r]
            for c in range(8):
                if byte & (0x80 >> c):
                    px[bx + c, by + r] = 1

    # C64 upper-only screen codes: 1..26 = A..Z, 27=[ 28=£ 29=] 30=↑ 31=←
    # 32 = space, 33..63 = !"# ... ?, etc. We map ASCII directly.
    for c in range(32, 64):
        render_at(c, c)                       # symbols + digits 1:1
    for c, code in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ"):
        render_at(ord(code), 1 + c)           # screen code 1..26 -> A..Z

    img.save(out_path, "PNG", optimize=True)


def main():
    if len(sys.argv) != 4:
        print("usage: extractFromDump.py <dump-path> <level-name> <tilebank-id>", file=sys.stderr)
        sys.exit(2)
    dump_path     = sys.argv[1]
    level_name    = sys.argv[2]
    tilebank_id   = int(sys.argv[3])

    mem = load_dump(dump_path)
    print(f"loaded {dump_path}: 64 KB OK")

    tiles_dir   = os.path.join(ROOT, "assets", "tiles")
    sprites_dir = os.path.join(ROOT, "assets", "sprites")
    levels_dir  = os.path.join(ROOT, "assets", "levels")
    os.makedirs(tiles_dir,   exist_ok=True)
    os.makedirs(sprites_dir, exist_ok=True)
    os.makedirs(levels_dir,  exist_ok=True)

    # Build the screen-code -> bank-slot mapping once and feed it to
    # both extractors so the tilemap and tilebank stay aligned.
    code_to_slot = build_code_to_slot(mem)

    # 8.3 DOS naming -- "tilebank0" is 9 chars and fopen fails under
    # DOSBox strict 8.3 mode. Keep the short form across the pipeline.
    tilebank_path = os.path.join(tiles_dir, f"tbank{tilebank_id}.png")
    extract_tilebank(mem, tilebank_path, code_to_slot)
    print(f"  wrote {tilebank_path}")

    level_path = os.path.join(levels_dir, f"{level_name}.txt")
    extract_level(mem, level_name, tilebank_id, level_path, code_to_slot)
    print(f"  wrote {level_path}")

    # Sprites + font come from level 1 only (sprite pointer table is
    # script-driven so it differs by scene; we use the first dump's
    # set as the canonical sheet, plus its charset as the font).
    if tilebank_id == 1:
        sprite_path = os.path.join(sprites_dir, "sprites.png")
        extract_sprites(mem, sprite_path)
        print(f"  wrote {sprite_path}")

        font_path = os.path.join(ROOT, "assets", "font.png")
        extract_font(mem, font_path)
        print(f"  wrote {font_path}")


if __name__ == "__main__":
    main()
