modemwars/docs/overview.md
2026-08-23 02:09:40 -05:00

230 lines
16 KiB
Markdown

# Modem Wars (C64, Electronic Arts / Ozark Softscape 1988) - disk and memory overview
Source image: `Modem_Wars_1988_Electronic_Arts_b4.d64` (35 tracks, 683 blocks, disk name
`SPORT OF WAR`, id `OZ`, DOS `2A`). "Sport of War" was the game's working title; "OZ" = Ozark
Softscape (Dan Bunten).
Everything in this document was derived by: extracting the two directory files with `c1541`,
disassembling the boot loader and the 1541 drive code, decrypting every sector in Python with the
cipher recovered from the drive code, and confirming the resulting memory image against a VICE
(NTSC, true drive emulation) RAM snapshot taken at the game entry point `$0800`.
## 1. Disk layout
| Track(s) | Encrypted? | Contents |
|-----------------|-----------:|----------|
| 1 s0 | no | C128 auto-boot sector ("CBM" header, message "AN ELECTRONIC ARTS PRODUCTION"); copies a CBM80 pseudo-cartridge to `$8000` and calls GO64 |
| 1 s1-10 | no | Plain ASCII letter from Dan Bunten to would-be pirates (`docs/track1_DanBuntenLetter.txt`) |
| 1 s11-15 | - | random filler |
| 1 s16 | - | filler that happens to contain fragments of assembler source text from the mastering system |
| 1 s17 | no | 1541 bootstrap sector, executed in the drive with `B-E 2 0 1 17`; also holds a hidden 1988 EA message |
| 1 s18-20 | no | 1541 fast-loader code, runs at `$0300-$05FF` in the drive |
| 2-17 | - | every byte `$01` (unused filler; 12-17 are also marked free in the BAM) |
| 18 | no | directory track: s0 BAM, s1 directory, s7 first page of the modem driver, s8-14 unit start-position templates, s15-16 terrain tile rules + start-up code, s17-18 save slots |
| 19-21 | no | CBM file chains of the directory files `ea` (1 block) and `load` (45 blocks) |
| 22-27 s0-17 | yes | main program -> `$0800-$6EFF` (the last four sectors also supply `$0400-$07FF`) |
| 28 s0-12 | yes | data -> `$9300-$9FFF` |
| 28 s13-17 | - | random filler |
| 29 s0-3 | yes | solo trainer setup: picks the opening play -> `$EC00-$EFFF` |
| 29 s4-5 | yes | solo trainer drone-flight AI -> `$EE00-$EFFF` |
| 29 s6-8 | yes | solo trainer playbook (five canned opening plays) -> `$0200-$04FF` |
| 29 s9-10 | yes | modem status message strings -> `$8800-$89FF` (reused as AI work RAM by the trainer) |
| 29 s11-15 | yes | high-memory resident code -> loaded at `$FA00`, then moved to `$FBB8-$FFD1` |
| 30 s0-15 | yes | text/glyph engine, message system, unit pictures -> `$C000-$CFFF` (over the boot loader) |
| 31 s0-16 + 32 s0-7 | yes | random map generator and setup phase -> `$6F00-$87FF` |
| 33 s0-16 + 32 s8-15 | yes | comcen missile and drone screens -> `$6F00-$87FF` (what the boot loader installs) |
| 34 s1-15 (+18 s7) | yes | **modem driver** (software UART, Hayes control, packet protocol) -> `$E000-$EFFF` |
| 35 s0-15 | yes | **solo trainer** (the computer opponent) -> `$E000-$EFFF` |
| other last sectors (29 s16-17, 30 s16-17, 31 s17, 32 s16, 33 s17?, 34 s0/s16, 35 s16) | - | random filler |
## 2. Boot sequence
1. `LOAD"EA",8,1` (or the C128 path) loads the 102-byte file `ea` to `$02A8-$030B`. Its last bytes
overwrite the six BASIC vectors at `$0300-$030B` with `$02B8`, so BASIC's warm start runs the
loader instead of printing READY.
2. `$02B8`: KERNAL `LOAD` of file `load` (`$9800-$C3FF`), then `JSR $C000` -> `$C145`, then `JMP $0800`.
3. `$C145` (boot loader):
* shows the title picture (bitmap `$A000`, screen `$9C00->$8C00`, colours `$9800->$D800`, VIC bank 2,
`$D011=$3B $D016=$D8 $D018=$38`);
* opens RS-232 (device 2, 300 baud) and sends the Hayes string `ATE0S0=0` to a modem, if any;
* `I0` initialise, then `B-E 2 0 1 17` - the drive executes the bootstrap sector, which reads
track 1 sectors 18-20 to `$0300-$05FF` and jumps there;
* switches to `$01=$35` (I/O visible, no ROMs), points NMI/IRQ at an RTI (`$C229`);
* pulls tracks 22-27 (18 sectors each) to `$0800+`, copies `$6F00-$72FF` to `$0400-$07FF`,
then track 33 s0-16 -> `$6F00`, track 32 s8-15 -> `$8000`, track 28 s0-12 -> `$9300`,
track 29 s11-15 -> `$FA00` followed by a block move up by `$1B8` (so the data ends at `$FFD1`);
* stores a SID noise sample as random seed at `$09C3`. Its final RTS does not return to `$02B8`: the stack
was rebuilt during the load so that it lands at `$0461`, a memory-checksum routine in the `$0400` block
that ends with `JMP $0800`.
4. `$0800` -> `$0A6C` is the game. Early on it loads the runtime overlays (section 5).
## 3. Fast-loader protocol (C64 `$C004/$C034`, private game copy `$0804/$085C`)
* `setDest` (`$C034` / `$085C`): X = destination low byte, Y = high byte (stored in self-modifying code).
* `read` (`$C004` / `$0804`): A = number of sectors, Y = track, X = first sector. Each sector is
requested with a 3-byte command `$80, track, sector` (command byte, then parameters), transferred
256 bytes at a time (command bytes go out LSB first, one bit per handshake; the drive answers each with a status bit)
with a 2-bit handshake on CIA2 port A (`$DD00` bits 4/5 out, 6/7 in) timed
against `$D012` to avoid bad lines, and followed by a status byte (0 = ok). Errors turn the
border red and retry forever. The destination advances one page per sector.
* `write` (`$0843`, game only): same registers, command `$60`; the drive encrypts and writes.
* command `$C0` resets the drive.
## 4. Sector cipher (drive code `$0531`)
Every sector except those on track 18 is XORed with a rolling key:
```
a = ((track | $C0) << 1) ; carry = 1 because bit 7 is set
a = a + sector + carry ; carry = overflow of this add
key = ROL(a) ; rotates in the carry above; this is key[0]
for y = 1..255: key = ROL(y ^ key) ; carry chains from the previous ROL
data[y] ^= key
```
`extracted/decryptDisk.py` implements it; `extracted/decrypted/` holds the plaintext sectors.
## 5. Memory map at run time
| Range | Source | Notes |
|-------|--------|-------|
| `$0000-$00FF` | - | zero page (see `disassembly/zeropage.inc`) |
| `$0200-$03FF` | T18 s15-16 | terrain tile rule table (`$0200`, 256 entries) and start-up code (`$0300`); later reused as a sprite decompression buffer, and replaced by the trainer playbook in a solo game |
| `$0400-$07FF` | copy of T27 s13-16 | manual-check answer table (`$0401`), the checksum routine the loader returns into (`$0461`), the status message strings (`$0500`..), and later the unit scan lists and AI threat maps |
| `$0800-$6EFF` | T22-27 | main program; `$0804` loader, `$0843` writer, `$09A6` manual look-up protection, `$0B4F-$10EE` overlay loading |
| `$6F00-$87FF` | T31+T32a / T33+T32b | map generator + setup phase, or the comcen missile/drone screens; the flag at `$87FF` says which is resident |
| `$8800-$89FF` | T29 s9-10 | modem status strings; the solo trainer overwrites the whole block with its own per-unit tables |
| `$8A00-$8BFF` | - | work RAM (`$8A00`, `$8A40`, `$8A80`, `$8AC0`, `$8B00` referenced) |
| `$8C00-$8FFF` | LOAD `$9C00` | title screen RAM during boot; later reused by the game |
| `$9000-$92FF` | - | work RAM and variables (`$92A1`, `$92C7`, `$92F0`, ...) |
| `$9300-$9FFF` | T28 s0-12 | tables / graphics |
| `$A000-$BFFF` | LOAD | title bitmap during boot; free for the game afterwards |
| `$C000-$CFFF` | T30 | text/glyph engine (`$C133` print string, `$C01A` print char), message queue, sound, unit pictures (the boot loader lived here first) |
| `$D000-$DFFF` | - | I/O (`$01 = $35`) |
| `$E000-$EFFF` | T35 or T18s7+T34 | **opponent module**: either the solo trainer AI or the modem driver. Both present the same jump table (`$E000` per-frame poll, `$E003` open/close link, `$E006` get byte, `$E009` send byte, `$E00C` per-frame, `$E015` get key) so the game cannot tell them apart. `$EC00-$EFFF` may be swapped for the trainer setup (T29 s0-3) or the drone AI (T29 s4-5) |
| `$F000-$F6FF` | T18 s8-14 | at run time `$F000-$F63F` is the 40x40 map (one byte per cell) and the 100-entry unit record arrays start at `$F640`, `$64` apart. What is *stored* on the disk is different: four 400-byte unit start-position templates (see section 10) |
| `$F700-$FBB7` | - | work RAM / buffers |
| `$FBB8-$FFD1` | T29 s11-15 (moved) | resident high code incl. the IRQ handler region `$FD5D-$FD6F`; `$FFDE-$FFE7` receives 10 bytes from the BAM (offset `$AB`) = disk serial used by the protocol |
| `$FFFA-$FFFF` | - | NMI/RESET/IRQ vectors (RAM), set by the loader to `$C229` and later by the game |
Mode flags: `$0BA5` bit 7 chooses the opponent - set means the solo trainer (track 35), clear means a
modem game (track 34). `$87FF` says which `$6F00` overlay is resident, `$0B9B` bit 7 marks film playback
and `$0BA3` holds the game type.
### Runtime overlay loads (all through `$0804` read / `$085C` setDest)
| Call site | Destination | Track/sectors |
|-----------|-------------|---------------|
| `$0F07` | `$C000` | T30 s0-15 |
| `$1047` | `$6F00` | T31 s0-16 then T32 s0-7 (map generator) |
| `$1086` | `$6F00` | T33 s0-16 then T32 s8-15 (comcen screens) |
| `$1060` | `$8800` | T29 s9-10 |
| `$1074` | `$EC00` | T34 s12-15 (modem driver tail) |
| `$0B4F` | `$EC00` | T35 s12-15 (solo trainer tail) |
| `$0F1A/$0F38` | `$E000` | T18 s7 + T34 s1-11 (modem driver) |
| `$0F1A/$0F22` | `$E000` | T35 s0-11 (solo trainer) |
| `$10A4` | `$F000` | T18 s8-14 (unit start-position templates) |
| `$10BB` | `$0200` | T18 s15-16 (terrain tile rules) |
| `$0FB1` | `$0200` | T18 s0 (BAM) - disk id check against `OZ` / `EA` |
| `$7B24` (map generator) | `$EC00` | T29 s0-3 (trainer setup) |
| `$E0B4` (trainer) | `$EE00` | T35 s14-15 (restore its own tail) |
| `$E0D6` (trainer) | `$EE00` | T29 s4-5 (drone flight AI) |
| `$7E85`, `$8194` (map generator) | write | save game film / setup to track 18 |
| `$ECAA` (trainer setup) | `$0200` | T29 s6-8 (the trainer's playbook) |
## 6. Copy protection
`$09A6`: picks an entry of the 32 x 3-byte table at `$0401`, asks the player to "IDENTIFY THIS MAP
(SEE MANUAL)" (string at `$0A48`) and compares the three answer bytes at `$5A-$5C`. The answers are map
names, and a map name is simply the game's 24-bit map seed written in base 32 using the 32-character
alphabet at `$890C` - the same name the two players compare before a modem game to be sure they are
playing the same battlefield. So the manual's map list and the map generator are two views of one number. After three
failures it wipes all of memory (`$0A1E` loop) and hangs. The disk itself is not copy protected
(standard sectors); Dan Bunten's letter on track 1 explains why.
## 7. Files in this repository
* `extracted/files/` - the two directory files (`ea.prg`, `load.prg`)
* `extracted/sectors/` - every raw sector, `extracted/decrypted/` - every decrypted sector
* `extracted/bootImage.bin` - 64 KB memory image exactly as at the first execution of `$0800`
* `extracted/runtimeImage_A.bin`, `_B.bin` - with the runtime overlays applied (two variants)
* `extracted/vice/` - VICE automation used to validate the image (`ram_at_0800.bin` = real snapshot)
* `disassembly/` - the annotated, reassemblable ca65 sources (`verify.sh` rebuilds and compares)
* `tools/` - the Python disassembler / build pipeline
* `docs/` - this file, the title picture, the hidden texts
## 8. Game concepts recovered from the code and its text
* **Unit types** (`unitTypeTable` = `$F76C+unit`, names at `$C964`): 0 GRUNT, 1 RIDER, 2 BOOMER,
3 SPY, 4 COMCEN. Each side owns 50 units: indices 0-49 are player 0, 50-99 player 1, and the
last unit of each side (49, 99) is that side's COMCEN (command centre).
* **Unit records** are parallel 100-byte arrays that follow the map, spaced `$64` apart from
`$F640`: column, row, flags/facing, type, destination column/row, secondary target, cloak,
blitz, stun/dug-in, energy (0-50, shown as a percentage), move state, draw flags, the terrain
hidden under the unit, and the group id.
* **Groups** (names at `$C92C`, 7 x 8 bytes): ALPHA, BAKER, CHARLIE, DELTA, EASY, FOXTROT, GROVER.
* **Game types** (`$0BA3` bits 0-2, names at `$CCC8`): SCRIMAGE, QB SNEAK, THE BOMB, FACE-OFF,
SLUGGERS, FULL WAR, DEFENDER; `$CC11` selects STANDARD or CUSTOM rules. All seven names match the
manual, except that the shipped game spells the first one SCRIMAGE with a single M.
* **Options** offered on the first screen: compete with modem opponent, practice with solo trainer,
watch game film, save game film, load game film. Custom-game switches include HANDICAP,
TERRAIN PTS, DRONES and COMCEN SPEED (`$EF52`..).
* **Console panels**: STATS, REPAIR and MISC tabs, reached with the joystick and the fire button;
unit status words (READY, RELOAD, FIRED, REPAIR, MOVE, PINNED, DIGGIN) live at `$C98A`..
* **Screen**: multicolour bitmap in VIC bank 2 - bitmap `$A000`, screen RAM `$8C00`, colour RAM
`$D800`. The left 22x22 character frame shows the whole 40x40 battlefield at 4x4 pixels per
cell; the right frame shows a 7x5 cell tactical view at 16x16 pixels per cell, with the message
and status panel below it. "Text" is drawn into the bitmap with the game's own 8x8 font at
`$9426` (114 glyphs), not with the VIC's character mode.
* **Commands** to the engine and to the opponent are single bytes `$80`-`$A6` followed by 0-3
argument bytes, queued into a ring buffer and dispatched through `commandHandlerTable` at
`$4D2A`; the same stream is what gets recorded and replayed as a "game film".
## 9. The two opponent modules at `$E000`
Everything the game knows about "the other player" goes through one jump table at `$E000`, and exactly
one of two completely different modules is loaded behind it:
| | `game/modemDriverE000` (track 34) | `game/trainerAiE000` (track 35) |
|---|---|---|
| chosen when | "COMPETE WITH MODEM OPPONENT" (`$0BA5` bit 7 clear) | "PRACTICE WITH SOLO TRAINER" (`$0BA5` bit 7 set) |
| what it is | a complete software modem | the computer opponent |
| `$E006` get byte | pops the receive ring filled by the UART | always reports "nothing received" |
| `$E009` send byte | queues it for transmission | throws it away |
**The modem driver** is three layers. A bit-banged RS-232 UART on the user port, driven from the CIA2
NMI, with the CIA timers latched for exact 300 or 1200 baud; a Hayes AT layer that dials, answers and
watches the result codes (so it can notice a `CONNECT 1200` and switch speed), with Commodore-key hot
keys for answer/originate/baud/DTR; and a packet layer whose header byte carries the payload length,
with bit 7 distinguishing chat text from game commands.
**The solo trainer** presents the same interface but never touches a CIA. Once per lock-step exchange
it builds the packet the game is about to "receive", so its orders enter the engine through exactly the
same command interpreter a human opponent's would. It plays side 1 (units 50-99), keeps its per-unit
tables in the `$8800` block (the modem status strings, which are dead once a solo game starts), picks an
opening play from a canned playbook loaded to `$0200-$04FF`, and evaluates moves against an influence
map built from the enemy units' reach and weight. While its drone is airborne it swaps a drone-flight
AI into `$EE00-$EFFF` and swaps its own tail back afterwards.
That symmetry is the neat part of the design: the battle engine, the recorded "game film" and the
command interpreter are identical whether the opponent is a person on a phone line or the trainer.
## 10. Unit start-position templates (track 18 sectors 8-14)
The seven sectors the loader puts at `$F000-$F6FF` are not a battlefield. They hold four 400-byte
templates, at `$F000`, `$F190`, `$F320` and `$F4B0`. Each template is four parallel 100-entry arrays:
| offset | array |
|---|---|
| +$000 | starting column of each unit |
| +$064 | starting row |
| +$0C8 | flags - facing in bits 0-1, side in bit 5, "in a group" in bit 6, group id in bits 2-4 |
| +$12C | unit type (0 GRUNT, 1 RIDER, 2 BOOMER, 3 SPY, 4 COMCEN) |
One template is chosen according to the game type and copied into the live unit arrays, which occupy
the same addresses from `$F640` upwards once play begins. The standard roster each template describes
is 28 GRUNTs, 12 RIDERs, 6 BOOMERs, 3 SPYs and 1 COMCEN per side - 50 units each.
The last 192 bytes of the block, which land at `$F640-$F6FF`, are not used by the game at all: they are
a fragment of an older build of the game's own options-list printer that the development system happened
to leave in the sector. It is overwritten by the unit arrays as soon as a game starts.