diff --git a/bad-apple/.gitignore b/bad-apple/.gitignore index 1100c44..2fce34c 100644 --- a/bad-apple/.gitignore +++ b/bad-apple/.gitignore @@ -2,5 +2,6 @@ data/* !data/manifest.json preview/ player/build/ +player/work/ __pycache__/ .pytest_cache/ diff --git a/bad-apple/player/build.sh b/bad-apple/player/build.sh new file mode 100755 index 0000000..ad8c485 --- /dev/null +++ b/bad-apple/player/build.sh @@ -0,0 +1,120 @@ +#!/bin/sh +# Build the Bad Apple payload with the 1987 TI TMS34010 SDK under DOSBox. +# +# .text at bit 0x018C0000, .bss at bit 0x01C00000 (the free block the rdebug +# loader already uses). Outputs: build/player.coff, player.bin, player.env. +set -eu + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +DIR=$(cd "$(dirname "$0")" && pwd) +WORK="$DIR/work" +OUT="$DIR/build" +IMG="$ROOT/tools/tms34010-sdk/1987-05 TMS34010 SDK/1987-11-06 TMS34010 Simulator-C Tools-ASM Tools.img" +DOSBOX_CONF="$ROOT/harness/sdk-scratch/dosbox-bench.conf" + +TEXT=018C0000 +BSS=01C00000 + +mkdir -p "$WORK" "$OUT" + +if [ ! -x "$WORK/GSPCG.EXE" ]; then + mcopy -s -n -i "$IMG" "::/A2/*" "$WORK/" + mcopy -s -n -i "$IMG" "::/C2/*" "$WORK/" +fi + +cp "$DIR/main.c" "$WORK/MAIN.C" +cp "$DIR/movie.c" "$WORK/MOVIE.C" +cp "$DIR/movie.h" "$WORK/MOVIE.H" +cp "$DIR/fill.asm" "$WORK/FILL.ASM" +cp "$DIR/entry.asm" "$WORK/ENTRY.ASM" + +{ + printf '%s\n' '-a' + printf '%s\n' '-o PLAYER.out' + printf '%s\n' '-m PLAYER.map' + printf '%s\n' '-e _start' + printf '%s\n' 'MAIN.obj' + printf '%s\n' 'MOVIE.obj' + printf '%s\n' 'FILL.obj' + printf '%s\n' 'ENTRY.obj' + printf '%s\n' '-l RTS.LIB' + printf '%s\n' 'MEMORY' + printf '%s\n' '{' + printf '%s\n' " prog: origin = ${TEXT}h, length = 20000h" + printf '%s\n' " ram: origin = ${BSS}h, length = 200000h" + printf '%s\n' '}' + printf '%s\n' 'SECTIONS' + printf '%s\n' '{' + printf '%s\n' ' .text: {} > prog' + printf '%s\n' ' .data: {} > prog' + printf '%s\n' ' .cinit: {} > prog' + printf '%s\n' ' .bss: {} > ram' + printf '%s\n' '}' +} > "$WORK/PLAYER.CMD" +sed -i 's/$/\r/' "$WORK/PLAYER.CMD" + +{ + printf 'gspcpp MAIN\r\n' + printf 'gspcc MAIN\r\n' + printf 'gspcg MAIN\r\n' + printf 'gspa MAIN MAIN.obj MAIN.lst -l\r\n' + printf 'gspcpp MOVIE\r\n' + printf 'gspcc MOVIE\r\n' + printf 'gspcg MOVIE\r\n' + printf 'gspa MOVIE MOVIE.obj MOVIE.lst -l\r\n' + printf 'gspa FILL FILL.obj FILL.lst -l\r\n' + printf 'gspa ENTRY ENTRY.obj ENTRY.lst -l\r\n' + printf 'gsplnk PLAYER.cmd\r\n' + printf 'exit\r\n' +} > "$WORK/BUILD.BAT" + +rm -f "$WORK"/MAIN.OBJ "$WORK"/MOVIE.OBJ "$WORK"/FILL.OBJ \ + "$WORK"/ENTRY.OBJ "$WORK"/PLAYER.OUT "$WORK"/PLAYER.MAP \ + "$WORK"/MAIN.LST "$WORK"/MOVIE.LST "$WORK"/FILL.LST \ + "$WORK"/ENTRY.LST + +SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ +timeout 240 dosbox -conf "$DOSBOX_CONF" -noprimaryconf -nolocalconf \ + -c "mount c $WORK" -c "c:" \ + -c "BUILD.BAT" \ + -c "exit" -exit > "$WORK/dosbox.log" 2>&1 + +if [ ! -f "$WORK/PLAYER.OUT" ]; then + echo "build.sh: GSPLNK produced no PLAYER.OUT" >&2 + sed 's/\r$//' "$WORK/PLAYER.MAP" 2>/dev/null | tail -20 >&2 || true + for f in MAIN.LST MOVIE.LST FILL.LST ENTRY.LST; do + sed 's/\r$//' "$WORK/$f" 2>/dev/null | grep -i 'error' \ + | grep -vi 'no errors' >&2 || true + done + exit 1 +fi +for f in MAIN.LST MOVIE.LST FILL.LST ENTRY.LST; do + if sed 's/\r$//' "$WORK/$f" 2>/dev/null | grep -i 'error' \ + | grep -qvi 'no errors'; then + echo "build.sh: $f diagnostics:" >&2 + sed 's/\r$//' "$WORK/$f" | grep -in 'error' \ + | grep -vi 'no errors' >&2 + exit 1 + fi +done + +cp "$WORK/PLAYER.OUT" "$OUT/player.coff" +sed 's/\r$//' "$WORK/PLAYER.MAP" > "$OUT/player.map" +sed 's/\r$//' "$WORK/MAIN.LST" > "$OUT/main.lst" 2>/dev/null || true +sed 's/\r$//' "$WORK/MOVIE.LST" > "$OUT/movie.lst" 2>/dev/null || true +sed 's/\r$//' "$WORK/FILL.LST" > "$OUT/fill.lst" 2>/dev/null || true + +python3 "$ROOT/harness/hello-world/cofextract.py" \ + "$WORK/PLAYER.OUT" "$OUT/player.bin" > "$OUT/cofextract.txt" + +ENTRY_PTR=$(sed 's/\r$//' "$WORK/PLAYER.MAP" \ + | awk '/ENTRY POINT SYMBOL/ { print $NF; exit }') +if [ -z "$ENTRY_PTR" ]; then + echo "build.sh: no entry point in PLAYER.map" >&2 + exit 1 +fi +printf 'ENTRY_PTR=0x%s\n' "$ENTRY_PTR" > "$OUT/player.env" + +echo "build.sh: $OUT/player.bin ($(wc -c < "$OUT/player.bin") bytes)" +grep -E "ENTRY POINT|^\.(text|cinit|bss)" "$OUT/player.map" | head -8 +echo "build.sh: entry bit 0x$ENTRY_PTR" diff --git a/bad-apple/player/entry.asm b/bad-apple/player/entry.asm new file mode 100644 index 0000000..9a1bf7f --- /dev/null +++ b/bad-apple/player/entry.asm @@ -0,0 +1,18 @@ +; entry.asm -- payload entry for the Bad Apple player. +; +; The payload is entered by a debugger/rdebug jump and never returns. Set the +; C stack, call main, and spin if main ever returns. + + .text + .globl _start + .globl _main +_start: + setf 32, 0, 1 + movi _cstack, a14 + move a14, a13 + calla _main +spin: + jruc spin + + .bss _cstack, 32768, 16 ; 32768 bits = 4096 bytes + .end diff --git a/bad-apple/player/fill.asm b/bad-apple/player/fill.asm new file mode 100644 index 0000000..c9fc5c7 --- /dev/null +++ b/bad-apple/player/fill.asm @@ -0,0 +1,56 @@ +; fill.asm -- one FILL L rectangle into the 1280x1024, 256-byte-stride VRAM. +; +; The C wrapper publishes: +; fill_daddr linear bit address of the top-left pixel +; fill_dydx (rows << 16) | columns, in screen pixels +; fill_color 0x0000 = black, 0xFFFF = white +; +; FILL L destroys DADDR and DYDX, so the wrapper reloads them every call. +; PSIZE, PMASK and CONTROL are programmed once by _fill_init_. + + .ref _fill_daddr + .ref _fill_dydx + .ref _fill_color + .text + + .globl _fill_run_ +_fill_run_: + setf 32, 0, 1 + move b2, -*sp, 1 + move b3, -*sp, 1 + move b7, -*sp, 1 + move b9, -*sp, 1 + + move @_fill_daddr, b2, 1 + movi 800h, b3 ; 2048 bits = 256-byte row pitch + move @_fill_dydx, b7, 1 + move @_fill_color, b9, 1 + + ; PSIZE is cheap to re-assert; if the resident OS left it at 2 bpp, + ; FILL would write twice as many pixels as asked for. + movi 1, a1 + move a1, @0C0000150h, 0 + + fill l + + move *sp+, b9, 1 + move *sp+, b7, 1 + move *sp+, b3, 1 + move *sp+, b2, 1 + rets + + .globl _fill_init_ +_fill_init_: + setf 32, 0, 1 + movi 1, a1 + move a1, @0C0000150h, 0 ; PSIZE = 1 + clr a1 + move a1, @0C0000160h, 0 ; PMASK = 0 + move a1, @0C0000170h, 0 ; upper half, forward compatibility + move @0C00000B0h, a1, 0 ; CONTROL + andi 0801Fh, a1 ; keep CD and RM/RR, raster op 0 + move a1, @0C00000B0h, 0 + move @0C00000B0h, a1, 0 ; read back: I/O writes are pipelined + rets + + .end diff --git a/bad-apple/player/main.c b/bad-apple/player/main.c new file mode 100644 index 0000000..ddcadb4 --- /dev/null +++ b/bad-apple/player/main.c @@ -0,0 +1,74 @@ +/* main.c -- Bad Apple payload, one-frame mode and (later) playback. */ +#include "movie.h" + +#define MOVIE_BASE 0x00080000 +#define MOVIE_MAX 0x00308000 +#define VRAM_BASE 0x04000000 +#define ROW_BITS 2048 +#define MARK 0x01810000 + +extern void fill_run_(); +extern void fill_init_(); + +unsigned int fill_daddr; +unsigned int fill_dydx; +unsigned int fill_color; + +void fill_rect(x, y, w, h, color) +int x; +int y; +int w; +int h; +int color; +{ + if (w <= 0 || h <= 0) { + return; + } + fill_daddr = VRAM_BASE + (unsigned int)y * ROW_BITS + (unsigned int)x; + fill_dydx = ((unsigned int)h << 16) | (unsigned int)w; + /* The decoder's colour is the canvas value: 1 = black, 0 = white. */ + fill_color = color ? 0x0000 : 0xFFFF; + fill_run_(); +} + +static void paint(x, y, w, h, color, ctx) +int x; +int y; +int w; +int h; +int color; +void *ctx; +{ + fill_rect(x, y, w, h, color); +} + +int main() +{ + struct movie_header header; + int rc; + + fill_init_(); + + /* Start from white; the OS left its own screen behind. */ + fill_rect(0, 0, 1280, 1024, 0); + + rc = movie_open((unsigned char *)MOVIE_BASE, MOVIE_MAX, &header); + if (rc != 0) { + *(unsigned int *)MARK = 0xBAD00001; + for (;;) { + } + } + + rc = movie_decode_frame((unsigned char *)MOVIE_BASE, MOVIE_MAX, 0, + &header, paint, 0); + if (rc != 0) { + *(unsigned int *)MARK = 0xBAD00002; + for (;;) { + } + } + + *(unsigned int *)MARK = 0; /* FRAME */ + *(unsigned int *)(MARK + 0x10 * 8) = 1; /* STATUS: painted */ + for (;;) { + } +} diff --git a/bad-apple/tools/check_vram.py b/bad-apple/tools/check_vram.py new file mode 100644 index 0000000..4b8c777 --- /dev/null +++ b/bad-apple/tools/check_vram.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Compare a MAME VRAM dump with the logical frame the movie should show.""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from pipeline.movie import read_movie +from pipeline.tilepack import apply_record + +VRAM_STRIDE = 256 + + +def vram_to_logical(data: bytes, width: int = 640, height: int = 512, + scale: int = 2) -> np.ndarray: + """Unpack a 1-bpp VRAM dump and majority-downsample scaleƗscale blocks.""" + screen_w = width * scale + screen_h = height * scale + need = VRAM_STRIDE * screen_h + if len(data) < need: + raise ValueError("VRAM dump is %d bytes, need at least %d" + % (len(data), need)) + rows = np.frombuffer(data[:need], dtype=np.uint8).reshape(screen_h, + VRAM_STRIDE) + bits = np.unpackbits(rows, axis=1, bitorder="little")[:, :screen_w] + blocks = bits.astype(np.uint16).reshape(height, scale, width, scale) + # The driver's palette: bit 1 = white, bit 0 = black. + return blocks.mean(axis=(1, 3)) < 0.5 + + +def expected_frame(movie_path: str, frame: int) -> np.ndarray: + header, records = read_movie(movie_path) + if frame < 0 or frame >= len(records): + raise ValueError("frame %d outside 0..%d" % (frame, len(records) - 1)) + canvas = np.zeros((header.height, header.width), dtype=bool) + for record in records[:frame + 1]: + apply_record(canvas, record, tile=header.tile) + return canvas + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--vram", required=True) + parser.add_argument("--movie", required=True) + parser.add_argument("--frame", type=int, default=0) + parser.add_argument("--scale", type=int, default=2) + args = parser.parse_args(argv) + + data = Path(args.vram).read_bytes() + header, _ = read_movie(args.movie) + actual = vram_to_logical(data, header.width, header.height, args.scale) + expected = expected_frame(args.movie, args.frame) + if np.array_equal(actual, expected): + print("EXACT frame %d" % args.frame) + return 0 + bad = int(np.count_nonzero(actual != expected)) + ys, xs = np.where(actual != expected) + print("MISMATCH frame %d: %d logical pixels differ" % (args.frame, bad)) + for y, x in list(zip(ys, xs))[:16]: + print(" (%d,%d) expected=%d actual=%d" + % (x, y, expected[y, x], actual[y, x])) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bad-apple/tools/load_mame.lua b/bad-apple/tools/load_mame.lua new file mode 100644 index 0000000..b66ab3e --- /dev/null +++ b/bad-apple/tools/load_mame.lua @@ -0,0 +1,151 @@ +-- load_mame.lua -- debugger entry for the Bad Apple payload. +-- +-- Loads the payload at bit 0x018C0000 and the movie at bit 0x00080000, then +-- enters the payload at the OS idle loop. This is the development path; the +-- real delivery path is tools/load.sh (rdebug + LANCE). +-- +-- Env: +-- BA_MODE=one|play (one is the only mode implemented here) +-- BA_MOVIE movie file (default bad-apple/data/test_movie.bin) +-- BA_PAYLOAD raw payload (default bad-apple/player/build/player.bin) +-- BA_ENV player.env with ENTRY_PTR (default .../player.env) +-- BA_AT load frame (default 2500) +-- BA_END inspection/exit frame (default 2600) + +local function hex(v, w) + return string.format("%0" .. (w or 8) .. "X", v) +end + +local machine = manager.machine +local cpu = machine.devices[':maincpu'] +local dbg = machine.debugger +local prog = cpu.spaces['program'] + +if not dbg then + print("BA ERROR: debugger not enabled; pass -debug") + machine:exit() + return +end + +local ORIGIN = 0x018C0000 +local MOVIE = 0x00080000 +local MARK = 0x01810000 +local ENTRY_PC = 0x3F579970 +local AT = tonumber(os.getenv("BA_AT") or "2500") +local STOP_AT = tonumber(os.getenv("BA_END") or "2600") +local MOVIE_PATH = os.getenv("BA_MOVIE") or "bad-apple/data/test_movie.bin" +local PAYLOAD_PATH = os.getenv("BA_PAYLOAD") or "bad-apple/player/build/player.bin" +local ENV_PATH = os.getenv("BA_ENV") or "bad-apple/player/build/player.env" + +local function read_all(path) + local f, err = io.open(path, "rb") + if not f then + print("BA ERROR: cannot open " .. path .. ": " .. tostring(err)) + machine:exit() + return nil + end + local data = f:read("*a") + f:close() + return data +end + +local function entry_from_env() + local explicit = os.getenv("BA_ENTRY") + if explicit then + return tonumber(explicit) + end + local f = io.open(ENV_PATH, "r") + if not f then + return ORIGIN + end + for line in f:lines() do + local value = line:match("ENTRY_PTR=0x(%x+)") + if value then + f:close() + return tonumber(value, 16) + end + end + f:close() + return ORIGIN +end + +local payload = read_all(PAYLOAD_PATH) +local movie = read_all(MOVIE_PATH) +if not payload or not movie then + return +end +local entry = entry_from_env() +print(string.format("BA: payload %d bytes, movie %d bytes, entry %s", + #payload, #movie, hex(entry))) + +local function write_bytes(data, base) + local i + for i = 0, #data - 1 do + prog:write_u8(base + i * 8, data:byte(i + 1)) + end +end + +local function verify(data, base) + local i + for i = 0, #data - 1 do + if prog:read_u8(base + i * 8) ~= data:byte(i + 1) then + return false, i + end + end + return true, 0 +end + +local loaded = false +local entered = false +local done = false +local frames = 0 + +local function load_and_enter() + write_bytes(payload, ORIGIN) + write_bytes(movie, MOVIE) + local ok, bad = verify(payload, ORIGIN) + if not ok then + print("BA ERROR: payload readback mismatch at byte " .. bad) + machine:exit() + return + end + ok, bad = verify(movie, MOVIE) + if not ok then + print("BA ERROR: movie readback mismatch at byte " .. bad) + machine:exit() + return + end + print("BA: loaded; breakpoint at " .. hex(ENTRY_PC)) + dbg:command("bpset " .. hex(ENTRY_PC) .. ",,{ pc = " .. hex(entry) .. " ; g }") +end + +emu.register_frame_done(function() + frames = frames + 1 + if frames == AT and not loaded then + loaded = true + load_and_enter() + end + if loaded and not entered and prog:read_u16(MARK) == 0x0000 + and prog:read_u16(MARK + 0x10 * 8) == 0x0001 then + entered = true + done = true + print(string.format("BA: STATUS=painted at frame %d; FRAME=%d", + frames, prog:read_u16(MARK))) + end + if loaded and not done and frames > AT + 30 then + local err = prog:read_u32(MARK) + if err ~= 0 then + print(string.format("BA ERROR: marker %s at frame %d", + hex(err), frames)) + machine:exit() + end + end + if frames >= STOP_AT then + print(string.format("BA: end at frame %d (loaded=%s done=%s)", + frames, tostring(loaded), tostring(done))) + machine:exit() + end +end, "vt1200-badapple-loader") + +dbg.visible_cpu = cpu +dbg.execution_state = 'run' diff --git a/bad-apple/tools/make_test_movie.py b/bad-apple/tools/make_test_movie.py new file mode 100755 index 0000000..62969a4 --- /dev/null +++ b/bad-apple/tools/make_test_movie.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Make a small known movie for the one-frame MAME FILL check.""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from pipeline.movie import FrameRecord, MovieHeader, write_movie +from pipeline.tilepack import diff_tiles + + +def main() -> int: + width, height = 640, 512 + canvas = np.zeros((height, width), dtype=bool) + canvas[20:70, 10:110] = True # a rectangle crossing tiles + canvas[96:128, 160:192] = True # a solid logical tile at (5,3) + records = diff_tiles(np.zeros_like(canvas), canvas, tile=32) + frames = [FrameRecord(1, 0, records)] + header = MovieHeader(width, height, 2, 32, width // 32, height // 32, + len(frames), 60, 72) + out = ROOT / "data" / "test_movie.bin" + out.parent.mkdir(parents=True, exist_ok=True) + write_movie(str(out), frames, header) + print("%s: %d bytes, %d dirty tiles" + % (out, out.stat().st_size, len(records))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bad-apple/tools/tests/test_check_vram.py b/bad-apple/tools/tests/test_check_vram.py new file mode 100644 index 0000000..aff8f2d --- /dev/null +++ b/bad-apple/tools/tests/test_check_vram.py @@ -0,0 +1,11 @@ +from tools.check_vram import vram_to_logical + + +def test_vram_downsamples_2x2_lsb_first(): + # VRAM bit 1 is white, bit 0 is black (the driver's palette). + vram = bytearray(256 * 1024) + vram[0] = 0x0C # bits 2 and 3 white in row 0 + vram[256] = 0x0C # bits 2 and 3 white in row 1 + logical = vram_to_logical(bytes(vram), width=640, height=512, scale=2) + assert logical[0, 0] # bits 0..1 are black + assert not logical[0, 1] # bits 2..3 are white