feat(badapple): UDP network loader and sender

This commit is contained in:
arti committed 2026-11-01 10:19:36 +02:00
1 parent 116356a975
commit 8a1cd1bfb1
8 files changed
+407 -18

No files matched your search

+12 -3
View File
@@ -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"
+62 -1
View File
@@ -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
+109 -14
View File
@@ -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;
+93
View File
@@ -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 */
+52
View File
@@ -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."
+1
View File
@@ -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)
+65
View File
@@ -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("<IHHH", offset, len(body), seq, crc16(body)) + body
def parse_ack(data: bytes) -> int:
if len(data) < 8 or data[:4] != b"BAK1":
raise ValueError("not a BAK1 ack")
return struct.unpack_from("<I", data, 4)[0]
def send_movie(host: str, port: int, path: str, chunk: int = 1450,
delay: float = 0.0005) -> 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())
+13
View File
@@ -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