DOS re-creation of Batty (Elite Software / Hit-Pak, 1987, ZX Spectrum 48K). VGA mode 13h (320x200x256), 1:1 pixel-faithful inside the 256x192 playfield, border around it. Built with Open Watcom v2, boots in QEMU.
Targets a 386 in 32-bit protected mode: batty.exe is an LE image loaded
by the DOS32A extender, which ships alongside it on the floppy. The flat
memory model means no segmentation and no 64 KB limits — see
notes/toolchain.md.
Playable end-to-end in all three modes — 1 Player, 2 Players and Double Play: title → menu → hi-score teaser → all 15 levels → game-over → 3-letter initials entry → back to title.
Everything the game does is implemented: bricks (1-hit, multi-hit and
undestructible), the 50 Hz game loop, bat resize and side fillers, multi-ball,
all 10 bonus types, the laser, the rocket clear, UFO and bird enemies with
bombs, the L4 spark enemy, magnets, the HUD, pause, high-score persistence
(HISCORE.DAT), and the Kinnock easter egg. Every loaded asset is generated
from the tape at build time (test-asset-provenance).
Static parity. make test headlessly diffs five checkpoints against ZX
ground-truth snapshots, all pixel-identical: title, menu, hi-score, level
entry, and the bat band as its own ROI. BATTY_LEVEL=N make test re-runs the
level checkpoint for N = 1..15, and all 15 levels are pixel-perfect —
FAIL-gated for every level by make test-levels-sweep.
Gameplay frame parity. Ball motion and brick collision are byte-exact
with the Spectrum: the ported handling_ball (exact 64-direction q8.8
motion) plus the LAFFC brick collision match the original's ball object —
x, fraction, y, direction, hit cell, bounce axis — frame for frame over level
3's first 150 frames, verified against ZEsarUX through the frame-step harness.
BATTY_LEGACY_COLLISION=1 reverts to the older approximate collision. Bat
deflection, the RNG walk, enemy motion and animation, the bonus economy,
scoring and every per-frame animation are byte-exact and gate-locked too.
110 gates hold all of it. Full detail:
notes/parity-status.md, open gaps in
notes/parity-gaps.md, the roadmap in
PLAN.md.
brew install mtools qemu
make floppy # builds build/batty.exe + assets, packs build/batty.img
make run # boots in QEMU
make run-dosbox # boots in DOSBox-X (brew install dosbox-x)
make run-86box # boots in 86Box (set BOX86_MACHINE to a 386-class machine)
make test # headless visual regression
make test-fast # host suites + source gates, no emulator, seconds
make parity-check # test + the byte-exact collision gatemake run-dosbox BOOTS the image rather than mounting it and running
BATTY.EXE. That matters: the build-time BATTY_* switches live in the
floppy's AUTOEXEC.BAT, and DOSBox-X's own shell never runs a mounted image's
AUTOEXEC — so the shorter invocation silently drops every switch you set. It
ignores your personal dosbox-x.conf by default (DOSBOX_CONF overrides), so
a conf pinning a 286 or CGA cannot fail in a way that looks like the port's
fault. Cycles default to max; the game paces itself off its own 50 Hz PIT
programming, so cycles buy headroom inside a frame rather than speed.
DOSBOX_CYCLES=12000 is roughly a 386DX-40.
make run-86box writes its VM config under build/86box/ and mounts
build/batty.img as drive A:. BOX86_MACHINE defaults to ibmxt, which
cannot run this build — pass a 386-class machine id from your own 86Box
(86Box --help lists them). The default is left visibly wrong rather than
replaced with an id nobody here can verify. BOX86_BIN, BOX86_ROMPATH,
BOX86_MACHINE, BOX86_GFXCARD and BOX86_FDD_TYPE all override.
The remaining targets (make regions, make candidates,
make run-original, make snapshot) drive RE tooling against the original
tape and additionally need z80dasm + srecord
(brew install z80dasm srecord). They are only needed when rediscovering
something not already captured in original/disasm/ or notes/ — see
Approach.
src/ The DOS implementation — main.cpp plus 14 separately
compiled modules (Open Watcom v2, 386 protected mode)
tests/ Host-native test suites (compile the real sources)
notes/ Project knowledge — read these first
original/
batty.tap Original tape image
disasm/ Authoritative RE reference: the full annotated
disassembly (submodule; consult before touching
anything semantically tricky)
blocks/ TAP-decoded per-block dumps
assets/ Binary assets extracted from the tape and consumed at
build time (font, sprites, level data, ...)
replays/ Replay / seeded-scenario specs for the parity harness
scripts/ Python tooling — asset extraction, RE, test harness
build/ Build outputs (gitignored)
vendor/ Vendored toolchain (Open Watcom v2, MS-DOS floppy)
tools/ Build-from-source dependencies (ZEsarUX submodule)
Everything the build needs is in the repo. Host tools: mtools and
qemu-system-i386 (brew install mtools qemu). z80dasm and srecord are
only needed for the RE-only targets.
Vendored, ready to use. vendor/openwatcom-v2/current-build-<date>/ is a
trimmed Open Watcom v2 daily snapshot (wcc386, wpp386, wlink, wdis,
h/, the 32-bit DOS libraries and the DOS32A extender), with per-host
binaries for macOS arm64 / x64 and Linux x64; refresh with
scripts/vendor_openwatcom.sh. vendor/msdos/floppy-minimal.img is a 1.44 MB
MS-DOS 4.0 boot floppy; refresh with scripts/vendor_msdos.sh. Both need
gh.
Submodule, build once. tools/zesarux/ is only needed for the RE targets
(make run-original, make snapshot, and the
scripts/capture_levels*.py / scripts/trace_blitter.py /
scripts/run_original_cheat.py tools):
git submodule update --init tools/zesarux
cd tools/zesarux/src && ./configure --enable-sdl2 && makeThe Makefile defaults to tools/zesarux/src/zesarux; override with
ZESARUX=/path/to/zesarux. make run-original defaults to SDL video and
audio at double the normal window scale, equivalent to
ZESARUX_VO=sdl ZESARUX_AO=sdl ZESARUX_RUN_OPTS="--zoom 4". Check what your
build actually has before choosing other overrides:
tools/zesarux/src/zesarux --help | sed -n '/--vo driver/,/--version/p'A minimal Linux build may list only fbdev stdout simpletext null video and
dsp onebitspeaker null audio. onebitspeaker needs PC-speaker I/O
permissions, so use null for smoke tests and RE automation
(ZESARUX_VO= ZESARUX_AO=null). If sdl or xwindows is not listed, that
binary cannot open a window with it — install the matching development
headers and rebuild.
The hard RE is done. original/disasm/batty.asm (from
CityAceE/Batty, vendored as a
submodule) is a complete, named, annotated Z80 disassembly — every routine
that matters already has a label and a comment. It is also build-verified: an
assembled copy differs from the reference binary in exactly the bytes we patch
(notes/per-level-profile.md). Treat it as the source of truth.
Workflow for a new port:
- Find the routine in
original/disasm/batty.asmby its label (handling_ball,print_briks,enemy_prepare, ...).scripts/disasm.py <label>prints it. - Port it into the right
src/module, citing the routine name in a code comment so the lineage is searchable. - Add a one-paragraph entry in
notes/<topic>.mdonly if the routine exposes a non-obvious invariant (encoding, RAM layout, timing quirk). Otherwise the disassembly plus the code comment is enough.
Read past the label. A routine boundary is a label, not an end: several
wrong conclusions here came from listings that stopped one instruction short.
scripts/disasm.py prints a FALLS THROUGH warning for it, and
notes/lessons.md collects the rest of the traps.
You should not need to disassemble anything from original/batty.tap
yourself. The RE-only tooling earns its keep only when chasing dynamic or
SMC behaviour the static disassembly cannot resolve (computed jumps,
self-modifying loops — use ZRCP single-stepping against ZEsarUX), or when
rediscovering a routine not yet named in original/disasm/, which is rare
enough to be worth flagging upstream.
original/batty.tap, original/BATTY.TZX, original/Batty.scr and
original/Batty.txt are © Elite Systems / Hit-Pak, 1987. Vendored for
personal reverse-engineering reference only. See PLAN.md WS9 before
distributing anything built from them.
The MS-DOS recreation (everything under src/, tests/, scripts/,
notes/, the Makefile) is MIT.