joost

personal web page

Elite on the MSX1: the four exploits, in detail

← back to Elite on the MSX1

Four ways to break the game, traced through the disassembly, and one section on everything that turned out to be watertight. None of the four cracks is in combat; they all live in navigation, the economy, and the save file.

Addresses are absolute Z80 addresses; CODE.COM loads at 0x0100 (addr = file_offset + 0x100). Disassembly via tools/zdis.py.

1. Cross the galaxy for the price of a short hop

You can reach any system in the current galaxy in a single jump, regardless of distance, and be charged only the fuel cost of a legal short-range target.

The recipe:

  1. On the chart, select a legal, in-range neighbour as your hyperspace target.
  2. Press H to start the countdown (10, 9, 8…).
  3. While it counts down, switch to the galactic chart and press F (Find), then type the name of any system in the galaxy, including one far out of fuel range.
  4. When the countdown expires you jump there, even though the game would never have let you start a countdown to it, and your fuel only drops by the cost of the original short hop.

The range and fuel test runs exactly once, at the instant you press H, and never again. H (handler 0x88ac, dispatch 48 ac 88 at 0x41e0):

88AC  CALL 0x6dcf
88AF  LD A,(0xe91b) : OR A : JP nz,0x16fe   ; countdown already running? ignore key
...
88E5  CALL 0x5314            ; cursor -> target system 0xe72b
88E8  CALL 0x53fd            ; target 0xe72b -> destination coords / cursor
88FA  CALL 0x6141            ; DISTANCE(origin 0xeb67 -> target 0xe72b) -> DE
88FE  LD HL,(0xf775)         ; HL = current fuel (max 0x46 = 70 = 7.0 LY)
8901  XOR A : LD H,A
8903  SBC HL,DE              ; fuel - distance
8905  JR nc,0x8910           ; fuel >= distance -> OK
8907  CALL 0x16fe
890A  LD HL,0x8c4a           ; "Beyond Range"
890D  JP 0x1683              ; ...and abort, no countdown
8910  LD A,E
8911  LD (0xe87f),A          ; CACHE this distance as the fuel to burn on arrival
8916  LD (0xe91c),A = 0x32   ; sub-tick reload (50)
891B  LD (0xe91b),A = 10     ; start the 10-unit countdown
8925  RET

The only consequences of the SBC HL,DE at 0x8903 are the Beyond Range abort (message 0x8c4a, referenced nowhere else) and caching the distance to 0xe87f. Find is a completely separate way to set the target (handler 0x55a3, dispatch 46 a3 55 = 'F' at 0x41d4) that never looks at the countdown:

55A3  LD A,(0xe902) : CP 4 : RET nz   ; galactic chart only (no countdown check)
...    <scan every system in the galaxy for a name match>
55F9  LD (0xe72b),A          ; *** selected target := the found system ***
55FC  CALL 0x53fd            ; target -> destination coords / cursor
5604  JP 0x52ce              ; redraw chart

Find rewrites 0xe72b and re-runs 0x53fd, but it never touches the cached distance 0xe87f, the countdown 0xe91b, or the fire flag 0xe920. The countdown keeps ticking in the VDP interrupt at 0x7202, which runs every frame regardless of screen:

721F  LD A,(0xe91b) : OR A : JR z,...       ; countdown active?
7225  DEC (0xe91c) : JR nz,...              ; per-second sub-tick (reload 0x32)
7232  DEC (0xe91b) : JR nz,...              ; tick the seconds
7238  LD A,0xff : LD (0xe920),A             ; hit 0 -> raise the "JUMP NOW" flag

The fire executor 0x894d, called unconditionally from the main loop at 0x1520, does no range test at all, and arrival copies the target and debits the cached distance:

894D  LD A,(0xe920) : OR A : RET z          ; jump flag raised?
8952  CALL 0x8ac8 / 0x1a7d / 0x64a1 / 0x8b7b   ; do the jump
...
8A6A  LD A,(0xe72b)          ; the (Find-tampered) target
8A6D  LD (0xeb67),A          ; *** current system := target, unconditionally ***
8A7F  LD A,(0xf775)          ; fuel
8A82  LD HL,0xe87f
8A85  SUB (HL)               ; fuel -= CACHED distance (the original short target!)
8A86  LD (0xf775),A

Two consequences, precisely: unlimited range (any of the galaxy’s 256 systems in one jump, 0xeb67 copied from 0xe72b with no comparison), and near-free travel (fuel burned is the decoy’s cost cached at 0x8911; pick a 0.1 LY decoy and a 40 LY real destination and you cross the galaxy for almost nothing). Because the original passed SBC HL,DE, the SUB (HL) can never underflow.

The root cause: the design assumes target selection and range validation are the same event, true when the only way to change the target is the cursor plus H. Find is a second way to set 0xe72b while a countdown is live. The check guards the keypress, not the jump.

addrrole
0xe72bselected hyperspace target (written by both H and Find)
0xeb67current system, set from 0xe72b on arrival (0x8a6d)
0xe87fcached jump distance / fuel to burn, latched at countdown start (0x8911)
0xe91b / 0xe91ccountdown seconds / sub-tick
0xe920“jump now” flag, raised by the ISR
0xf775fuel (tenths of a LY, max 0x46)
0xe902screen mode (4 = galactic chart, gates Find)

2. Launder your record by jumping

Every hyperspace arrival halves your bounty. A handful of jumps through empty systems washes a wanted commander back to Clean, no fine, no cost.

Legal status is not a stored label; it is classified live at 0x62fc from the single bounty byte 0xeb65 (0 = Clean, 1 to 49 = Offender, >= 50 = Fugitive). The arrival sequence unconditionally halves it:

8A7A  LD HL,0xeb65
8A7D  SRL (HL)          ; bounty >>= 1, every jump

From the maximum 255 it takes at most eight jumps to reach 0 (255, 127, 63, 31, 15, 7, 3, 1, 0), and a bare Fugitive bounty of 50 clears in six. Nothing in the jump path can raise it. This is not cosmetic: police Vipers do spawn, and the arrival ambush size is derived from the top bits of the same byte:

8A32  LD A,(0xeb65)
8A35  RLCA : RLCA : AND 3 : INC A   ; ambush party size = 1..4, scaled by bounty

The periodic hostile launcher at 0x3176 spawns the Viper (type 0x0c). So halving the bounty both calls off pursuit (status -> Clean) and shrinks the ambushes.

3. The edited-save trainer

The commander loader copies on-disk bytes straight into live variables with no checksum and no bounds check.

The load path 0xa377 checks only for a BDOS disk error:

A386  CALL 0xa4c6        ; random-read the file into buffer 0xd606
A389  JP c,0xa55c        ; the ONLY guard: disk I/O error. No content validation.
A397  LDIR ...           ; copy 128-byte payload to 0xf6c0
A3A2  CALL 0xa49d        ; apply payload to live variables

The apply routine 0xa49d walks a field-descriptor table at 0xa599 ([dest_lo, dest_hi, len], hi == 0 terminates) and LDIRs each field straight into the live variable:

A4A3  LD C,(HL):INC HL   ; dest lo
A4A4  LD A,(HL):INC HL   ; dest hi
A4A7  OR A : JR z,end    ; hi==0 -> end of table
A4AB  LD A,(HL):INC HL   ; length
A4AE  EX DE,HL : LD D,B:LD E,C : LD C,A:LD B,0
A4B4  LDIR               ; file buffer -> live var, no validation

The save side 0xa483 is the mirror image and appends no checksum. Security-relevant fields reachable this way:

varlengrants
0xeb163cash, 24-bit (up to ~16.7M cr)
0xf77520fuel, missiles, every equipment flag (large bay, ECM, escape capsule, docking computer, galactic hyperdrive, energy unit…)
0xeb121combat rank 0–8 (8 = E-L-I-T-E)
0xeb133kill tally
0xeb651bounty / legal status (0 = Clean)

Set the cash bytes and rank byte, flip the equipment flags, zero the bounty, and the game applies all of it on load.

4. The escape-capsule wipe

Buying a cheap escape capsule and ejecting resets your bounty to zero, an instant one-shot record wipe. The eject handler 0x19fc (gated on the capsule flag 0xf77c) on a successful ejection:

1A5A  XOR A
1A5B  LD (0xf77c),A      ; consume the capsule (must re-buy), correct
1A5E  LD (0xf814),A
1A61  LD (0xeb65),A      ; *** bounty / legal status := 0 = Clean ***

Where the per-jump launder in section 2 needs several hops, the capsule zeroes 0xeb65 outright. It does consume the capsule, so it is a paid one-shot, but a capsule costs far less than the fines it erases.

What turned out to be watertight

The whole combat loop was audited for free kills, double-scoring, god-mode and resource cheats, and it is the most tightly guarded subsystem in the game. Every path re-validates on the operation that matters:

Trading was checked the same way: you sell your entire holding at once (count read from the hold, so it cannot go negative), buys are 24-bit credit-checked before the deduction (0x5c25), cost cannot 16-bit-overflow (stock masked to 63 by AND 0x3f at 0x4c08, and 255 x 4 x 63 = 64,260 < 65,536), and prices are deterministic with a fixed 95% sell spread.

Two theoretical soft spots remain, neither practically exploitable. The energy-bomb ship loop 0x32d5 omits the exploding-bit check the ECM loop has, so it could in principle re-score a ship on the exact detonation frame, but the bomb is one-shot and visits each slot once. And prolonged sun-skimming hull damage was never located (cabin temp 0xf811 only lights a warning at 0x16d4), so whether extended skimming is truly risk-free is unconfirmed either way.