diff --git a/bad-apple/player/build.sh b/bad-apple/player/build.sh index ad8c485..c26195b 100755 --- a/bad-apple/player/build.sh +++ b/bad-apple/player/build.sh @@ -14,6 +14,12 @@ DOSBOX_CONF="$ROOT/harness/sdk-scratch/dosbox-bench.conf" TEXT=018C0000 BSS=01C00000 +NET=${NET:-0} +if [ "$NET" = 1 ]; then + NAME=player-net +else + NAME=player +fi mkdir -p "$WORK" "$OUT" @@ -25,6 +31,8 @@ fi cp "$DIR/main.c" "$WORK/MAIN.C" cp "$DIR/movie.c" "$WORK/MOVIE.C" cp "$DIR/movie.h" "$WORK/MOVIE.H" +cp "$DIR/romapi.h" "$WORK/ROMAPI.H" +printf '#define NET_DEFAULT %s\n' "$NET" > "$WORK/MODECFG.H" cp "$DIR/fill.asm" "$WORK/FILL.ASM" cp "$DIR/entry.asm" "$WORK/ENTRY.ASM" @@ -105,7 +113,8 @@ 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" + "$WORK/PLAYER.OUT" "$OUT/$NAME.bin" > "$OUT/cofextract.txt" +cp "$WORK/PLAYER.OUT" "$OUT/$NAME.coff" ENTRY_PTR=$(sed 's/\r$//' "$WORK/PLAYER.MAP" \ | awk '/ENTRY POINT SYMBOL/ { print $NF; exit }') @@ -113,8 +122,8 @@ 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" +printf 'ENTRY_PTR=0x%s\n' "$ENTRY_PTR" > "$OUT/$NAME.env" -echo "build.sh: $OUT/player.bin ($(wc -c < "$OUT/player.bin") bytes)" +echo "build.sh: $OUT/$NAME.bin ($(wc -c < "$OUT/$NAME.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 index 9a1bf7f..f198ab1 100644 --- a/bad-apple/player/entry.asm +++ b/bad-apple/player/entry.asm @@ -1,7 +1,12 @@ -; entry.asm -- payload entry for the Bad Apple player. +; entry.asm -- payload entry and ROM socket/allocator thunks. ; ; The payload is entered by a debugger/rdebug jump and never returns. Set the ; C stack, call main, and spin if main ever returns. +; +; The firmware's wrappers end in RETS 2 (they pop the return address and two +; argument words, and restore A14 from a caller-pushed slot). A plain calla +; would leave SP off by one word; these thunks save/restore SP and A14 exactly +; as harness/udpstream/entry.asm does. .text .globl _start @@ -14,5 +19,61 @@ _start: spin: jruc spin + .globl _socket_ +_socket_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F5C9A00h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + + .globl _bind_ +_bind_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F5CAA80h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + + .globl _recvfrom_ +_recvfrom_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F5D7D50h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + + .globl _close_ +_close_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F5E8930h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + + .globl _malloc_ +_malloc_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F701230h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + + .globl _delay_ +_delay_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F833A20h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + .bss _cstack, 32768, 16 ; 32768 bits = 4096 bytes + .bss _sys_sp, 32, 16 + .bss _sys_a14, 32, 16 .end diff --git a/bad-apple/player/main.c b/bad-apple/player/main.c index 52983d3..1b7b920 100644 --- a/bad-apple/player/main.c +++ b/bad-apple/player/main.c @@ -1,5 +1,15 @@ -/* main.c -- Bad Apple payload, one-frame mode and (later) playback. */ +/* main.c -- Bad Apple payload: network receive, then tile playback. */ #include "movie.h" +#include "romapi.h" +#include "modecfg.h" + +#ifndef NET_DEFAULT +#define NET_DEFAULT 0 +#endif + +/* The 30 s stream's exact size, from data/manifest.json. The real loader + * allocates this before the first datagram; a different clip needs a rebuild. */ +#define NET_LEN 2401942 #define MOVIE_BASE 0x00080000 #define MOVIE_MAX 0x00308000 @@ -13,6 +23,9 @@ #define CTRL_MOVIELEN (MARK + 0x30 * 8) #define CTRL_WAIT (MARK + 0x34 * 8) #define CTRL_READY (MARK + 0x38 * 8) +#define CTRL_NET (MARK + 0x3C * 8) + +#define NET_PORT 7001 extern void fill_run_(); extern void fill_init_(); @@ -50,6 +63,69 @@ void *ctx; fill_rect(x, y, w, h, color); } +static unsigned char rxbuf[1600]; +static unsigned char sa[16]; +static unsigned char from[16]; +static int fromlen; + +/* Receive movie.bin over the OS's UDP stack into a malloc'd bit buffer. + * Packet: u32 offset, u16 length, u16 seq, then bytes; length 0 is EOF. */ +static unsigned int receive_movie(expected) +unsigned int expected; +{ + unsigned int ptr; + unsigned int offset; + unsigned int length; + unsigned int total; + unsigned int i; + unsigned char *dst; + int fd; + int n; + + ptr = malloc_(expected); + if (ptr == 0) { + return 0; + } + fd = socket_(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (fd < 0) { + return 0; + } + sa_any(sa, NET_PORT); + if (bind_(fd, sa, SOCKADDR_LEN) < 0) { + close_(fd); + return 0; + } + total = 0; + dst = (unsigned char *)ptr; + for (;;) { + fromlen = SOCKADDR_LEN; + n = recvfrom_(fd, rxbuf, (int)sizeof(rxbuf), 0, from, &fromlen); + if (n < 8) { + continue; + } + offset = (unsigned int)rxbuf[0] + | ((unsigned int)rxbuf[1] << 8) + | ((unsigned int)rxbuf[2] << 16) + | ((unsigned int)rxbuf[3] << 24); + length = (unsigned int)rxbuf[4] | ((unsigned int)rxbuf[5] << 8); + if (length == 0) { + break; + } + if (offset + length > expected) { + continue; + } + for (i = 0; i < length; i++) { + dst[offset + i] = rxbuf[8 + i]; + } + total += length; + if (total >= expected) { + break; + } + } + close_(fd); + return ptr; +} + /* Poll the display interrupt pending bit. Interrupts are disabled; this is * a status bit, not an ISR. A missing DPYINT only costs a bounded spin. */ static void wait_vsync() @@ -78,18 +154,9 @@ int main() unsigned int pace; unsigned int movie_base; unsigned int movie_len; + unsigned int net; int rc; - vid_dint_(); - fill_init_(); - - /* Display interrupt at line 1: our only timebase. Interrupts stay off; - * wait_vsync() polls the pending bit. */ - *(unsigned short *)0xC00000A0 = 1; - - /* Start from white; the OS left its own screen behind. */ - fill_rect(0, 0, 1280, 1024, 0); - movie_base = *(unsigned int *)CTRL_MOVIE; movie_len = *(unsigned int *)CTRL_MOVIELEN; if (movie_base == 0) { @@ -99,12 +166,40 @@ int main() movie_len = MOVIE_MAX; } - if (*(unsigned int *)CTRL_WAIT != 0) { - *(unsigned int *)(MARK + 0x10 * 8) = 0x10; /* takeover, waiting for data */ - while (*(unsigned int *)CTRL_READY == 0) { + net = *(unsigned int *)CTRL_NET; + if (net == 0 && NET_DEFAULT) { + net = 1; + } + if (net != 0) { + /* Real delivery: the OS stack is still alive for the receive; the + * payload owns the machine only after the last byte arrives. */ + movie_len = NET_LEN; + *(unsigned int *)(MARK + 0x10 * 8) = 0x20; + movie_base = receive_movie(movie_len); + if (movie_base == 0) { + *(unsigned int *)MARK = 0xBAD00003; + for (;;) { + } + } + vid_dint_(); + } else { + vid_dint_(); + if (*(unsigned int *)CTRL_WAIT != 0) { + *(unsigned int *)(MARK + 0x10 * 8) = 0x10; + while (*(unsigned int *)CTRL_READY == 0) { + } } } + fill_init_(); + + /* Display interrupt at line 1: our only timebase. Interrupts stay off; + * wait_vsync() polls the pending bit. */ + *(unsigned short *)0xC00000A0 = 1; + + /* 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_len, &header); if (rc != 0) { *(unsigned int *)MARK = 0xBAD00001; diff --git a/bad-apple/player/romapi.h b/bad-apple/player/romapi.h new file mode 100644 index 0000000..49f2d37 --- /dev/null +++ b/bad-apple/player/romapi.h @@ -0,0 +1,93 @@ +/* + * romapi.h -- the VT1200 firmware entry points a C payload may call. + * + * Every address here is a TMS34010 BIT address (= Ghidra byte address x 8). + * The evidence for each is dwt/notes/TCPIP.md (sockets) and dwt/notes/HEAP.md + * (allocator); the ones marked "verified live" have been called from a payload + * on real hardware, not just read out of the ROM. + * + * K&R C only. This compiler rejects ANSI prototypes (`int f(int a)` is a + * syntax error), so the declarations below are old-style: the parameter lists + * live in the comments, and the compiler checks nothing but the name. That is + * the price of using the 1987 toolchain; keep the comments honest. + * + * Calling convention: the C compiler and the firmware agree -- arguments are + * widened to 32 bits and pushed upward on the program stack (A14), first + * argument last, and the result comes back in A8. The thunks in entry.asm + * forward with `calla`, so the caller's already-pushed arguments pass through + * untouched and only A8 has to survive. + */ +#ifndef ROMAPI_H +#define ROMAPI_H + +/* ---- ROM entry points -------------------------------------------------- */ +#define ROM_SOCKET 0x3F5C9A00 /* Ghidra 07EB9340 verified live */ +#define ROM_BIND 0x3F5CAA80 /* Ghidra 07EB9550 BOOTP caller */ +#define ROM_LISTEN 0x3F5CBCD0 /* Ghidra 07EB979A inetd caller */ +#define ROM_ACCEPT 0x3F5CCB40 /* Ghidra 07EB9968 inetd caller */ +#define ROM_SENDTO 0x3F5D3D70 /* Ghidra 07EBA7AE verified live */ +#define ROM_RECVFROM 0x3F5D7D50 /* Ghidra 07EBAFAA verified live */ +#define ROM_READ 0x3F5D8E70 /* Ghidra 07EBB1CE rdebug / Xlib */ +#define ROM_CLOSE 0x3F5E8930 /* Ghidra 07EBD126 verified live */ +#define ROM_SETSOCKOPT 0x3F5DD260 /* Ghidra 07EBBA4C BOOTP / Xlib */ +#define ROM_DELAY 0x3F833A20 /* Ghidra 07F66744 sleep(ms), telnet caller */ +#define ROM_SELECT 0x3F871CE0 /* Ghidra 07F0E39C Xlib caller, candidate */ +#define ROM_MALLOC 0x3F701230 /* Ghidra 07EE0246 verified live */ +#define ROM_FREE 0x3F702440 /* Ghidra 07EE0488 allocator */ +#define ROM_CREATE_PROC 0x3F706A40 /* Ghidra 07EE0D48 GUI work */ + +/* ---- thunks implemented in entry.asm ----------------------------------- */ +/* + * int socket_(domain, type, protocol) -> fd, or -1 + * int bind_(fd, sockaddr *, len) -> 0, or -1 + * int recvfrom_(fd, buf, len, flags, from *, fromlen *) + * -> bytes, or -1 + * int close_(fd) -> 0, or -1 + * unsigned int malloc_(bytes) -> BIT pointer, or 0 + * int free_(bit pointer) -> 0 + * + * `from` is a 16-byte sockaddr the stack fills in; `fromlen` points to an + * int that must be 16 on entry (the call writes back the real length). + */ +int socket_(); +int bind_(); +int recvfrom_(); +int close_(); +int listen_(); +int accept_(); +int read_(); +int select_(); +int delay_(); +unsigned int malloc_(); +int free_(); + +/* ---- socket constants -------------------------------------------------- */ +#define AF_INET 2 +#define SOCK_DGRAM 2 +#define SOCK_STREAM 1 +#define IPPROTO_UDP 0 + +#define SOCKADDR_LEN 16 +/* + * struct sockaddr, 16 bytes, NETWORK byte order: + * +0 u16 family (AF_INET = 2 -> bytes 00 02) + * +2 u16 port (high byte first, e.g. 7000 = 0x1B58 -> 1B 58) + * +4 u32 addr (0 = INADDR_ANY) + * +8 u8 zero[8] + */ + +/* Fill sa (an unsigned char[16]) with AF_INET, `port`, and INADDR_ANY. + * `port` is in host order so callers can write 7000 rather than 0x1B58. */ +static void sa_any(sa, port) +unsigned char *sa; +unsigned int port; +{ + sa[0] = 0; sa[1] = AF_INET; + sa[2] = (port >> 8) & 0xFF; + sa[3] = port & 0xFF; + sa[4] = 0; sa[5] = 0; sa[6] = 0; sa[7] = 0; + sa[8] = 0; sa[9] = 0; sa[10] = 0; sa[11] = 0; + sa[12] = 0; sa[13] = 0; sa[14] = 0; sa[15] = 0; +} + +#endif /* ROMAPI_H */ diff --git a/bad-apple/tools/load.sh b/bad-apple/tools/load.sh new file mode 100755 index 0000000..97a7e75 --- /dev/null +++ b/bad-apple/tools/load.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# Deliver the network-build payload over rdebug, then send movie.bin over UDP. +# +# tools/load.sh --host 192.168.99.2 --movie data/movie.bin +# +# The terminal must be booted and answering ping. The payload calls the OS +# socket API, receives into a malloc'd buffer, then disables interrupts and +# plays. Recovery after that point is a power cycle. +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "$HERE/../.." && pwd) +HOST=192.168.99.2 +PORT=7001 +MOVIE=$ROOT/bad-apple/data/movie.bin +WAIT=180 +SETTLE=3 + +while [ $# -gt 0 ]; do + case "$1" in + --host) HOST=$2; shift 2 ;; + --port) PORT=$2; shift 2 ;; + --movie) MOVIE=$2; shift 2 ;; + --wait) WAIT=$2; shift 2 ;; + --settle) SETTLE=$2; shift 2 ;; + -h|--help) sed -n '2,12p' "$0"; exit 0 ;; + *) echo "load.sh: unknown option $1" >&2; exit 2 ;; + esac +done + +if [ ! -f "$MOVIE" ]; then + echo "load.sh: no movie at $MOVIE" >&2 + exit 2 +fi + +start=$(date +%s) +while ! ping -c1 -W1 "$HOST" >/dev/null 2>&1; do + if [ $(( $(date +%s) - start )) -ge "$WAIT" ]; then + echo "load.sh: $HOST did not answer within ${WAIT}s" >&2 + exit 1 + fi +done +echo "load.sh: $HOST answers" + +python3 "$ROOT/harness/badapple/rdebug_load.py" \ + --host "$HOST" \ + --image "$ROOT/bad-apple/player/build/player-net.bin" \ + --env "$ROOT/bad-apple/player/build/player-net.env" + +sleep "$SETTLE" +python3 "$HERE/send_movie.py" --host "$HOST" --port "$PORT" --movie "$MOVIE" +echo "load.sh: sent; the screen is the evidence. Power-cycle to recover." diff --git a/bad-apple/tools/load_mame.lua b/bad-apple/tools/load_mame.lua index ab60e73..693cbbc 100644 --- a/bad-apple/tools/load_mame.lua +++ b/bad-apple/tools/load_mame.lua @@ -132,6 +132,7 @@ local function load_and_enter() prog:write_u32(MARK + 0x30 * 8, #movie) prog:write_u32(MARK + 0x34 * 8, WAIT) prog:write_u32(MARK + 0x38 * 8, 0) + prog:write_u32(MARK + 0x3C * 8, 0) -- debugger path, never network dbg:command("bpset " .. hex(ENTRY_PC) .. ",,{ pc = " .. hex(entry) .. " ; g }") if WAIT == 0 then write_bytes(movie, MOVIE) diff --git a/bad-apple/tools/send_movie.py b/bad-apple/tools/send_movie.py new file mode 100644 index 0000000..ae5f423 --- /dev/null +++ b/bad-apple/tools/send_movie.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Send movie.bin to the payload's UDP receiver (port 7001). + +Packet: u32 offset, u16 length, u16 seq, u16 crc16, then length bytes. +A zero-length packet ends the load. This uses the VT1200 OS stack while it +is still alive; the payload disables interrupts only after the last byte. +""" +from __future__ import annotations + +import argparse +import socket +import struct +import sys +import time +from pathlib import Path + + +def crc16(data: bytes) -> int: + return sum(data) & 0xFFFF + + +def pack_packet(offset: int, seq: int, body: bytes) -> bytes: + return struct.pack(" int: + if len(data) < 8 or data[:4] != b"BAK1": + raise ValueError("not a BAK1 ack") + return struct.unpack_from(" int: + data = Path(path).read_bytes() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + offset = 0 + seq = 0 + while offset < len(data): + part = data[offset:offset + chunk] + sock.sendto(pack_packet(offset, seq, part), (host, port)) + offset += len(part) + seq += 1 + if delay: + time.sleep(delay) + sock.sendto(pack_packet(len(data), seq, b""), (host, port)) + finally: + sock.close() + print("sent %d bytes in %d packets" % (len(data), seq + 1)) + return 0 + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="192.168.99.2") + parser.add_argument("--port", type=int, default=7001) + parser.add_argument("--movie", required=True) + parser.add_argument("--chunk", type=int, default=1450) + parser.add_argument("--delay", type=float, default=0.0005) + args = parser.parse_args(argv) + return send_movie(args.host, args.port, args.movie, args.chunk, args.delay) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bad-apple/tools/tests/test_send_movie.py b/bad-apple/tools/tests/test_send_movie.py new file mode 100644 index 0000000..1ae0dde --- /dev/null +++ b/bad-apple/tools/tests/test_send_movie.py @@ -0,0 +1,13 @@ +from tools.send_movie import pack_packet, parse_ack + + +def test_pack_packet_header(): + pkt = pack_packet(0x1234, 1, b"abc") + assert pkt[0:4] == (0x1234).to_bytes(4, "little") + assert pkt[4:6] == (3).to_bytes(2, "little") + assert pkt[6:8] == (1).to_bytes(2, "little") + assert pkt[10:] == b"abc" + + +def test_parse_ack(): + assert parse_ack(b"BAK1" + (0x1000).to_bytes(4, "little")) == 0x1000