← Experimental Atari Multiplayer Arcade
How Joust 4-Player Was Made
A record of an experiment in using AI coding agents to modify a vintage game · 5 and 6 October 2026
- What this is
- The goal and the starting point
- Finding which bytes are code: a coverage emulator
- Source code for a cartridge that had none
- Checking that the source can move
- Two copy-protection traps
- How the original works
- A 32K program, three formats, every build kept
- Step 1: twelve riders
- Step 2: four players and two modes
- The scoreboard
- Speed
- Automated testing
- Bugs, including the agent's own
- Version 1.1: drop-in rejoin
- Timeline and numbers
- Known limits and open questions
- Lessons
- Credits
1. What this is
Joust 4-Player is an experimental modification of Atari's 1983 Joust cartridge for the Atari 400/800 (the game itself is © 1982 Williams). Up to four people can play at the same time, each on their own joystick port, in two modes: free for all and teams. The modification was made by AI coding agents (Claude Code) working from the original program, the machine code in the cartridge, with no source code available. All testing was done automatically in an emulator.
- What the agents did. One agent session built version 1.0 on the evening of 5 October 2026. It measured which bytes of the cartridge are code, generated a source file that reassembles to the identical cartridge, found two copy-protection traps, and then widened the game from two players to four, with the two modes and a new scoreboard. A second session added version 1.1 a few hours later: players who are out can rejoin ("drop-in"), a change requested for the online arcade.
- How it was checked. A byte-identical rebuild of the original; a relocation test that ran the moved source cycle for cycle against the original for 12,000 frames; and a suite of headless emulator tests: boot tests for each format, scripted jousts, a sprite multiplexer test, complete games played by an autopilot in every mode, wave tests and drop-in tests. Coverage counters showed which of the new routines the tests had executed.
- What is imperfect or uncertain. The cartridge version has a known boot bug that the disk and program-file versions do not have. Busy late waves run below full speed, as they do in the original, and more than four riders on the same lines flicker, also as in the original. The tests were automated, an autopilot does not play like a person, and bugs the tests did not provoke are likely to remain. Details are in section 17.
This page is adapted from the working report written by the agent that built version 1.0. The section on version 1.1 is drawn from the project's development log and notes. "The agent" means the session doing the work being described.
2. The goal and the starting point
The request was a four-player simultaneous version of Joust with two multiplayer modes: free for all, and teams ("1 vs 2" or "2 vs 2"). Four-player versions of Wizard of Wor and Pac-Man for the Atari 800 had been made earlier.
What there was to work with:
- The original cartridge image: 16 KB at
$8000-$BFFF, "COPYRIGHT ATARI 1983 / COPYRIGHT WILLIAMS 1982". Its trailer gives a start address of$8000and an init address of$BFF9(an RTS). It runs on an Atari 800 with OS-B. - No source code of any kind, and no disassembly to build on. (For Pac-Man, Roklan's source of the disk version had supplied names; here every name had to be found by reading the code.)
- The original is already a 2-player game: player 1 rides a yellow ostrich, player 2 a green stork, both on screen together with up to eight buzzard riders, and the two knights can unhorse each other. That made Joust an easier starting point than Pac-Man, where only one player is on screen at a time: most of the rules for several players already existed.
- The project's toolkit: the cc65 assembler and linker, the atari800 emulator with a socket interface for automated control, a headless test harness, and the methods from the Wizard of Wor and Pac-Man conversions done earlier the same day.
A first look settled one point: the screen shows more riders than the Atari has hardware sprites (four players plus four missiles), so the original already multiplexes its sprites. The open question was how far that mechanism could be stretched.
3. Finding which bytes are code: a coverage emulator
A tracing disassembler follows jumps and branches from the entry points, but it cannot see through jump tables, interrupt vectors that are set up at run time, or code that is reached only through pointers. Instead of guessing, the agent measured. It built a private copy of the project's atari800 emulator (the shared emulator stayed untouched) and added a counter for every executed instruction address, with a socket command that dumps all 64K counters.
A coverage script ran the original under it: the attract mode, the option keys, and five random 1- and 2-player games. Coverage settled at 4,735 executed opcode addresses. Each of them is certainly code, and the tracer starts from all of them. The parts that never ran (later waves, the pterodactyl, wave bonuses) were found by the tracer from there, and the listing marks them as never run.
The private emulator later received more features, each one added when a problem needed it:
- A fixed random seed. atari800 seeds POKEY's random generator from the wall clock, so two runs never matched.
- Waiting for the test to connect before running any frame. Without it the emulator ran freely for a varying number of frames before the test took control.
- A read watch: a log of every data read in an address range, with the address of the reading instruction.
- An instruction trace: program counter, registers, scanline and cycle of every instruction, for comparing two runs step by step.
4. Source code for a cartridge that had none
A source generator turns the cartridge into a ca65 assembler source, and a separate symbol script holds everything a program cannot work out on its own: names, data formats, pointers. Every instruction line keeps its original address and bytes in a comment:
JOUST:
lda OBJ_DEAD,x ; 8C4A BD 8C 10
bne L8C70 ; 8C4D D0 21
...
jsr BOUNCE ; 8C5E 20 3C 8D
For a source that can be changed, every ROM address must be a symbol, not only the
obvious ones in JSR and LDA operands. The places they were hidden
in Joust:
- 21 pairs of immediate operands that are the two halves of a pointer: the deferred VBI vector, eight display-list-interrupt vectors, the title picture, the platform graphics, the message and digit fonts. A scanning mode of the generator lists candidates: an immediate load stored to address and another one stored to address+1 close by. The agent checked each by reading the code; two were false hits (a 16-bit speed and a collision result).
- Two jump tables of code addresses (the egg state machine and the platform landing cases), each with a dead entry that points into junk.
- A table of ten records whose first two bytes are a pointer to a platform's graphics.
- A pointer inside a "poke list" that the game copies to RAM and executes as data (more about that list in section 6).
The generator supports these as data formats (words, records, byte expressions). By the end the symbol script named 111 routines and tables and 73 RAM variables and arrays. The first complete source reassembled byte-identical to the cartridge, and a byte-identical rebuild check confirms that every time it is run.
5. Checking that the source can move
A byte-identical result only proves the addresses came out the same. A missed pointer
still assembles to the right byte and breaks only when the code moves. So a relocation test
assembles the source twice, at $8000 and at $4000, fills the unused
16K of each with $02 (an opcode that halts the 6502, so any stale jump or read
shows), boots both as program files and compares their RAM every 500 frames under identical
scripted joystick input.
Getting two emulator runs to agree to the cycle took several rounds:
- The random seed and the free-running frames before the test connected (the emulator changes in section 3).
- The two program files finished loading at slightly different points in the frame. The
fix: the start stub waits for frame 255 and scanline
$40, then doesSTA WSYNC, so both start the game on exactly the same cycle. - The input had to be a pure function of the frame number, so that a bisecting search could rewind with saved states, replay exactly the same input and find the first frame where the two runs differ.
- A real quirk of the original: the title setup copies 512 bytes from ROM to RAM, and the last 19 are code bytes. The title's colour cycling then branches on bit 7 of those bytes, so its timing depends on where the code sits. The test zeroes those 19 bytes in both builds.
- The two copy-protection traps (next section).
The end result: the source assembled at $4000 ran cycle for cycle identical to the original for 12,000 frames (demo, title, a full 1-player game to game over and the demo again), and the read watch showed no data read of the old address range.
6. Two copy-protection traps
Both traps act only when the program runs from RAM. The 4-player version has to run from RAM (as a disk or a program file), so both had to be found.
Trap 1: the poke list
At power-on the game copies 16 bytes from ROM to $04F0: a short list of five
addresses and five values. Every time a score or a life is drawn, a routine "pokes" the five
values into the five addresses. When a game starts, the high byte of the third address is
changed to $A7, and that entry then writes $FF to
$A773: the operand of an LDX #$04 in the poke routine itself. In
ROM nothing happens. In a RAM copy the instruction becomes LDX #$FF, the loop
writes over memory and the game crashes. As a second line of defence, the joystick routine
reads the sticks only while that high byte is still $A7.
Trap 2: an RTS over the physics
L92F3: lda #$60 ; RTS
sta TASK_PHYS ; $8946: the object update task
When a knight finishes materialising at a spawn pad, the game writes an RTS over the start of its object update. In ROM nothing happens. In RAM every rider on the screen freezes, and the player cannot move.
Both targets are now symbols (TRAP_HI, TRAP2_ADDR). The 4-player
version sends the two writes to $C773 and $C946: unmapped memory on
the 800, OS ROM on an XL/XE.
7. How the original works
The original is a 16K cartridge for a 16K machine and uses every byte of RAM
$0000-$3FFF:
| RAM | Use |
|---|---|
| $0500-$065F | multiplexer: 12 sprite slots, 4 channel lists, DLI event lists |
| $0A00-$0BFF | title text (copied from ROM) |
| $0F00-$0FFF | sprite shapes (12 bytes each) |
| $1000-$12FF | object tables: the unused low part of single-line sprite memory |
| $1300-$17FF | missiles and players 0-3 |
| $1800 | the game's display list; $1900/$1A00 screen line address tables |
| $1BE0-$1FCF | 21 lines of rock and lava under the playfield |
| $2010-$3FEF | the playfield: ANTIC mode E, wide (48 bytes per line), 170 lines |
- Objects. Ten riders, 0-1 the players and 2-9 the enemies, each described by 31
arrays of 10 bytes (position, speed, facing, flapping, state, timers, AI target and so on).
Code throughout tells players from enemies with
TXA / AND #$FE / BNE. - The main loop is a scheduler: each task (physics, joysticks, enemy AI, states, spawning, messages, eggs, pterodactyl) runs when the frame counter reaches its time.
- The sprite multiplexer runs once per frame inside a display-list interrupt near the bottom of the screen. It sorts the 12 sprite slots (10 riders, the pterodactyl, the lava troll's hand) into the four hardware players, keeping at least 20 scan lines between two riders on the same player. It sets DLI bits in the display list where a player has to be moved and recoloured, and copies each rider's 12-byte shape into sprite memory. The lance is a missile, one scan line high and quad width, coloured by the "fifth player" register. Riders that don't fit (more than four in one band) are skipped for a frame and get priority in the next: that is the original's flicker.
- Collisions are tested in software: the higher lance wins; equal heights bounce. Player against player is allowed, which is why the original 2-player game is already "free for all".
The agent wrote what it learned into a notes document as it read, so the knowledge would survive if its working context was compacted.
8. A 32K program, three formats, every build kept
The ROM was full (it ends in 93 bytes of leftover junk from the original developer's assembler), and the RAM was full. The 4-player version therefore became a 32K program for a 48K machine:
$8000-$BFFF(HI): the original, patched and reassembled;$4000-$7FFF(LO): the new code, the new tables and the 4-player RAM, which a 16K Atari never had.
A build script produces, from one link:
- a program file (XEX) and a boot disk image (ATR) with its own loader, both of which load the two halves into RAM;
- a 32K XEGS bank-switched cartridge image, with an emulator header and raw: bank 3
is fixed at
$A000, bank 0 holds$8000-$9FFF, and banks 1-2 hold LO, which a small boot routine copies to RAM at power-on. It needs 32K RAM and shows a red screen with less.
As in the other projects, every build was kept together with the source that produced it,
files were archived before every edit, and the development log is append-only. The
generated source was forked into the 4-player program, with every edit marked
; 4P. The larger rounds of edits were applied by four small recorded patch
scripts that check every line they change, so each step can be reproduced and reviewed.
9. Step 1: twelve riders
Four players and eight enemies means 12 objects instead of 10. Rather than squeeze them
into the old space, the agent moved the object tables out of the sprite memory gap into LO,
with 16 bytes per array. Because the generated source names every array
(OBJ_X, OBJ_STATE, ...), moving them was a change of 31 equates.
Then:
- the 19 player tests
AND #$FEbecameAND #$FC(objects 0-3 are players, 4-11 enemies), and 9 object loops count to 11; - every per-player array was widened to four: scores, bonus counters, men, joystick direction and button, flap sounds, eggs caught in a row;
- the multiplexer got 14 sprite slots: the pterodactyl moved from slot 10 to 12, the troll's hand from 11 to 13, and the "skipped riders get priority" rotation was adapted;
- one table (skid time) turned out to be indexed by object number rather than enemy type, and was extended to 12 entries;
- a new joystick routine reads all four ports. The old one used
STY zp,X, which has no absolute form once the arrays had left zero page.
After this step, 1- and 2-player games played as before, which was the check that the widening had not broken anything.
10. Step 2: four players and two modes
Title screen
The console has only three keys. SELECT keeps the difficulty; OPTION now cycles
1, 2, 3, 4 players (free for all), then 3 players teams and 4 players teams. The title got a
new display list with two more text lines: 4-PLAYER V1.0 B0017 under the logo,
and a MODE: line. On an XL/XE, which has only two joystick ports (detected by its
OS reset vector), OPTION offers 1 and 2 players only.
The teams
The agent read "(1 vs 2) or (2 vs 2)" as the two ways teams can be formed with three or
four players. Players 1 and 2 are always team 1; player 3 (or players 3 and 4) is team 2. The
title spells it out: TEAMS 1+2 VS 3, TEAMS 1+2 VS 3+4. On the
scoreboard the teams are simply its two rows.
The rule change itself is small. Where two riders meet, the original's test "are both
enemies? then just bounce" became a call to JOUSTOK4, which also returns "just
bounce" for two players of the same team. In free for all every player has their own team
number, so anyone can unhorse anyone, as in the original 2-player game.
The rest of the game, for four
- Players 3 and 4: light blue and pink (two colour-table entries the game was not using for riders), start positions for 1-4 players on the bottom ledge, and the physics "type" of players 1 and 2 (the original gave each object its own index as a type, which would have made player 3 fly like a bounder).
- Men, scores, bonus men: new routines for four players. The game ends when all four are out.
- Messages: 32 instead of 16 (
PLAYER 3,PLAYER 4,PLAYER n WINS,TEAM n WINS,TIE GAME). At game over with 2-4 players the winner is announced; for teams, the sum of each team's scores decides. - Enemy targets: the original made each enemy go for the player above it, or spread
enemies over both players by object number.
AITARGET4does the same over four players. - The pterodactyl: it appears if any player is on the board, picks a random player, and enters on the lane away from the highest and lowest player (the original looked at players 1 and 2 only).
- Team, gladiator and survival waves work for 1-4 players: a team wave pays 3000 to everyone still in the game if no knight unhorsed another; the first knight to unhorse another in a gladiator wave gets 3000.
11. The scoreboard
Four scores had to go somewhere, and each player should be able to find theirs by colour. The playfield is ANTIC mode E: three colours plus background per line, and changing colours in the middle of the playfield would need display-list interrupts that collide with the multiplexer's. So the scoreboard went under the playfield, where only the game's own two status interrupts run:
- the 21 rock lines became 2 rock lines, two ANTIC mode 6 text lines (8 scan lines each) and 3 rock lines;
- a mode 6 character takes its colour from bits 6-7 of its code, one of four registers. The
status interrupt sets those four registers to the four players' colours right after
WSYNC, and one more interrupt restores the rock colours afterwards; - a small font (digits copied from the OS ROM, plus a knight drawn from 8 of the 12 rows of the rider sprite) shows score, knight, men in reserve for each player.
The first version used all 24 columns of the wide line, and the outer columns turned out to be outside the visible picture, so it was laid out again in the middle 20. The two boxes inside the bottom ledge, where the original showed players 1 and 2, would have been left empty; they now show the session high score and the wave, or in teams the two team totals, drawn in the original's grey 5x5 digit style.
One more safety fix in this area: the original's screen line table points lines below the
playfield to $3FF0 and up, which on a 16K machine is nothing, but on a 48K machine
is now the new code. Those entries point to a dummy line.
12. Speed
The original already slows down when the screen is full: its tasks simply run late. The agent measured this with the coverage counters: in wave 12 with two players and about 6.7 enemies, the physics task ran on 761 of 1,000 frames. Twelve riders and fourteen sprite slots cost more per frame than ten and twelve. A profile over 500 busy frames (cycles per routine, from the executed-instruction counts) showed where the time goes:
| Part | Share of the CPU (4 players, wave 12) |
|---|---|
| sprite multiplexer, all parts | about 45%, of which copying the shapes 15% |
| platform collision tests | 15% |
| physics and rider-against-rider collisions | about 13% |
Two changes, both with exactly the same results as before:
- the four 12-byte shape copy loops (21 cycles per byte) became unrolled copies, about 137 cycles less per sprite per frame (shape drawing fell from about 2,500 to 900 cycles per frame);
- the platform tests walked through all ten platforms from the bottom one up. A 256-entry table built at power-on, indexed by the rider's height, lets them start at the first platform the rider can actually touch. The platforms are sorted by height, so the skipped ones are exactly those the original would have rejected.
Result in the same kind of heavy scene with four players: physics on 831 instead of 675 of 1,000 frames (two different runs, with 4.6 and 5.2 enemies on average, so the figures are only an indication), and the main loop now idles about 16% of the time. A full late wave still runs below full speed, as in the original.
13. Automated testing
Everything was tested headless on an emulated Atari 800 with OS-B (only the 800 has ports 3 and 4), with no window, on private sockets, and mostly with the fixed random seed so that every failure could be replayed.
| Test | What it checks |
|---|---|
| Smoke test | the cartridge, the program file and the boot disk boot, start a game and play |
| Event tests | PvP in free for all; team mates only bounce; opponents joust; game over announces PLAYER 2 WINS and TEAM 1 WINS; the team-wave bonus (6 tests) |
| Multiplexer test | 12 riders frozen in three layouts: every rider gets a sprite at least every other frame |
| Soak tests | an autopilot plays a whole game in any mode to game over and back to the demo; fails on a stall or crash |
| Wave test | jumps to a given wave and logs the special events: team, gladiator, survival, egg and pterry waves, bonuses, the pterodactyl, the troll |
| Load and profile tests | CPU load and profile, original against 4-player |
| Drop-in tests (v1.1) | see section 15: 5 scenarios, 57 checks |
The autopilot reads the object tables, picks the nearest enemy, flaps until it is above it and glides onto it. It is reckless, but it scores about 30,000 points and reaches wave 4. To test later waves, a skip-to-wave helper sets the wave number and lets the next enemy that disappears end the wave.
Two more checks: an XL (only 1-2 players offered, the game plays), and coverage of the 4-player build over the whole test set. Every new routine ran except the title's OPTION handler (the tests set the options directly; it was tried by hand). The bonus man, egg catching and hatching, pterodactyl hits, the troll's grabs and the wave bonuses all ran with four players.
14. Bugs, including the agent's own
$12E2-$12E9 and
animates only players 1 and 2. Players 3 and 4 drew their sparkles (with XOR), never erased
them, and wrote into player 1's bytes. Every other 2-player array had been found by searching
the code; this one was hidden behind plain addresses. Fixed with 4-entry arrays.CMP #$A7 as $A6CA; it is at $A6CC. With the trap moved,
the comparison failed and the joysticks were never read, in the early builds and in the first
relocation runs, which still "passed" because both builds ignored the joystick in the same
way. Since then the tests also check that a player actually moves.$FF. A test pushed one to $FE, and the
game would never have ended. It cannot happen in a real game, but the test now checks the sign
bit.- a start-position routine written wrongly on the first try (fixed before it was ever built);
- a
BIT DEMOthat also ANDs the accumulator (the player count), so the demo check failed with one player (caught when re-reading the code); - branches pushed out of range by the larger code;
- the zsh shell does not word-split
$CF, so a list of compiler flags arrived as one argument when the private emulator was rebuilt; - a patch script that stopped at its first check while the build after it still ran: build 3 is labelled "step 1" in the build index but is really build 2 plus the joystick fix (the development log records this).
Two "bugs" that were not: player 2 "won" a joust that a test meant player 1 to win (gravity had moved them; the game was right and the test's geometry was wrong), and a scoreboard digit that looked like an inverted block in a scaled screenshot was a normal slashed zero in the raw screen buffer.
15. Version 1.1: drop-in rejoin
In the online Atari Multiplayer Arcade, where four people play together over the network, a player who had lost all their men had to watch until everyone else was out as well. The request for a change came through the agent working on the arcade, and the design chosen was: a player who is out can rejoin, their score restarts at 0, and a title-screen option, ON by default, controls it. The arcade drives the title screen with OPTION and SELECT and cannot send keyboard keys, so in the arcade drop-in is always on. A second agent session did this work shortly after midnight on 6 October.
Constraints
- The RAM addresses the arcade reads (each player's score and men in reserve, and a few game-state bytes) stayed where they were. Assertions in the new code now stop the build if any of them moves.
- The new code went into a new segment after the 4-player RAM (
$535C-$55C6, 619 bytes) and the new variables at the end of that RAM, so nothing from version 1.0 moved. The original's half (HI) kept its size, with 301 bytes free; every change there is the operand of aJSRorJMP. - Before any edit the files were archived, and the full test suite was run on version 1.0 as a baseline (all passed, in 3 minutes 19 seconds).
How it works
- When a player is out. A player of the current game counts as out when their men counter has bit 7 set and their death is finished: the riderless mount has left the screen and the game has removed the object (a death in the lava removes it at once).
- The rejoin. The input task, which runs every second frame, now calls
DROPCHK5in place of the original joystick read. It reads the four joysticks and looks for a fresh press of each player's button: released at the previous check and pressed now, so a button held down to flap does nothing. If that player may rejoin (drop-in on, not the demo, the game still running, a player of this game, out),REJOIN5puts the player into exactly the state the game itself leaves a knight in after a death with men left: score 0, the bonus counter reset so the extra man comes at 20,000 points of the new run, 4 men, the object waiting to spawn. The game's own respawn does the rest: about a second of waiting, a free spawn pad, materialising with the usual protection. The player's team does not change. - Timing. A rejoin becomes possible 0.1 to 3.2 seconds after the last man is lost (8 to 194 frames in autopilot games; the mount has to leave the screen first), and the knight then takes about a second to materialise. A player can rejoin as often as they like.
- The end of the game is unchanged: when everybody is out at the same time, the game-over sequence starts and nobody can rejoin.
- Best run. Each player's best finished run is kept. The team boxes add, for each team player, the larger of their best run and their current run; at game over the winner is decided from those values, and the scoreboard shows them during the game-over sequence and in the demo afterwards. The score in RAM is never rewritten: it is always the run being played. The arcade counts increases of that score as points and a drop as a new run.
- Scoreboard. An out player who may rejoin gets
PRESS FIREin their block, in their colour, alternating with their score every 32 frames. For this the scoreboard font now also holds the letters A-Z, copied from the OS ROM at power-on (this works with both OS-B and AltirraOS). - Title. A new line,
DROP-IN (KEY D): ONorOFF, under the mode line (the title display list became 8 scan lines longer). The D key toggles it once per press; the OS key repeat is ignored until the keyboard reports that no key is down. Drop-in is switched on at power-on and kept in RAM only; START, SELECT, OPTION and the joysticks do not change it. With drop-in off the game behaves as version 1.0.
Builds and tests
- Build 18, the first, had a bug:
DROPCHK5tested the processor flags of the old button state, so nobody could rejoin. The new drop-in test found it; build 19 fixed it. - Builds 18 to 20 rewrote each player's score to their best run at game over. The arcade would have counted that jump as points, so build 21, the release, leaves the score alone and keeps the final results in a separate table that decides the winner and feeds the scoreboard. Build 20, the release candidate, had passed every test except the new check that the score stays untouched at game over.
- A new drop-in test with five scenarios (rejoin, teams, game over, players not in
the game, drop-in off) and 57 checks, on a headless Atari 800 with OS-B and fixed random
seeds. It covers a rejoin on a fresh press with 4 men and a score of 0 through the game's own
respawn, a held button that does not rejoin, the
PRESS FIREprompt, the extra man of the new run, teams, best runs, game over when everybody is out, players who are not in the game, the D key, and drop-in off behaving as version 1.0. The soak test now runs with drop-in off by default and can switch it on for a given number of frames; a rejoin by a player who is not in the game fails it. - Results on build 21: the full suite passed in 5 minutes 37 seconds. Soak games with drop-in off reached game over and the demo (4 players: 16,428 frames; 4 players in teams: 10,544; 1 player: 16,128) with no rejoins. A 4-player soak with drop-in on for 20,000 frames counted 14 rejoins, then, with drop-in off, reached game over at frame 25,460 and the demo. The byte-identical rebuild check and the relocation test still passed.
- Checked separately with one-off scripts: the boot disk on the AltirraOS Atari 800
setup that the arcade uses (the title line, the
PRESS FIREletters, a rejoin), and the cartridge (drop-in works; the cartridge's older boot bug is still present, see section 17).
16. Timeline and numbers
| Time | Step |
|---|---|
| 5 Oct, 20:35 | the original cartridge image arrives; project set-up, test harness, first look |
| 20:40 | coverage emulator, coverage runs (4,735 code addresses) |
| 20:45-21:10 | source generator, byte-identical; relocation test, trap 1, reproducible emulator |
| 21:10-21:20 | trap 2, names for everything, the fork, the 32K build (builds 1-3) |
| 21:20-21:45 | step 1 (twelve riders) and step 2 (four players, modes, scoreboard) (builds 4-7) |
| 21:50-22:17 | the sparkle bug, speed, the ledge boxes, tests, user manual; v1.0 = build 17 |
| 6 Oct, 00:05 | version 1.1 begins: files archived, plan, baseline test run on v1.0 |
| 00:05-00:40 | drop-in code, new tests, documentation (builds 18-21) |
| 00:40 | v1.1 = build 21 |
| Measure | Value |
|---|---|
| Generated source of the original | about 7,200 lines, byte-identical |
| Edits to the original (v1.0) | 222 lines marked ; 4P |
| New code (v1.0) | 1,326 lines (about 660 instructions plus macros) |
| Memory used (v1.0) | HI 15,955 of 16,256 bytes; LO about 4.9 KB of 16 KB |
| Drop-in code (v1.1) | 619 bytes in a new segment of LO; HI unchanged in size |
| Builds | 17 to v1.0, 21 to v1.1, all kept |
17. Known limits and open questions
- The cartridge version has a boot bug. At power-on its boot routine copies the new
code area from the cartridge into RAM, but it uses a leftover register value (the RAMTOP
setting) as the low bytes of its pointers, so the first 128 bytes (
$4000-$407F) are not copied. That area holds a short signature and the first message texts, so on the cartridge, and only there, the game's messages show as rows of "0". The bug has been present since version 1.0; it was noted during the version 1.1 checks and has not been fixed. The disk and program-file versions are not affected, and the online arcade boots the disk version. - Speed. A busy late wave runs below full speed, as in the original. The speed figures in section 12 come from two runs with different numbers of enemies and are an indication, not a precise comparison.
- Flicker. More than four riders on the same lines flicker, as they did in the original; the multiplexer now has more riders to share the four hardware sprites.
- Hardware. Three or four players need an Atari 400 or 800, the only models with joystick ports 3 and 4. On an XL/XE the game runs with 1-2 players. The cartridge needs 32K RAM, the disk and the program file 48K.
- Test coverage. All testing was automated or scripted. The autopilot reaches about wave 4; later waves were tested by jumping to them, not by playing through. The title's OPTION handler was not run by the automated tests and was tried by hand. The tests check the outcomes they were written for, so bugs in situations they did not provoke are likely to remain.
- Drop-in in the arcade. The arcade cannot press the D key, so there drop-in is always on.
18. Lessons
- Measure, don't guess, which bytes are code. A coverage counter in the emulator made the disassembly trustworthy from the start and later made the profile almost free.
- Get the emulator reproducible before comparing runs. The random seed, the frames before the test connects, the exact start cycle: each one alone spoiled the comparison.
- A comparison of two builds can pass while both are broken. Always also check an absolute outcome: the player moves, the game ends, the score goes up.
- Cartridges from 1983 may defend themselves against RAM copies. Search for every write into the ROM range, direct and through pointers, before trusting a RAM build.
- Compare against the original under the same autopilot. It found the one 2-player array that all the code searches missed.
- Recorded patch scripts work better than hand edits for wide, repetitive changes: each one checks that every line it changes exists exactly once.
- Memory that another program reads is an interface. The online arcade reads scores and men from fixed addresses; version 1.1 added build-time assertions that stop the build if those addresses move, and one candidate build was replaced because a score rewritten at game over would have been counted by the arcade as points.
Credits
- Williams created Joust (arcade, 1982); Atari published the Atari 400/800 cartridge (1983). The 4-player edition still runs the original's code for much of the game.
- No outside disassembly or source code was used: the agents disassembled the cartridge themselves (sections 3-5).
- Tools used throughout: the cc65 assembler and linker, the atari800 emulator (the online arcade runs it compiled to WebAssembly), and AltirraOS by Avery Lee, the free replacement operating system the online arcade boots.