Skip to content

jonx/SubterraCS

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

143 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Subterranean Stryker — Reverse Engineering & C# Port

License: MIT .NET 10 Avalonia 12

A clean-room-ish reverse engineering project that takes the 1985 ZX Spectrum game Subterranean Stryker (Insight Software — code by Mark Wilson & Peter Gough, music by Tim & Mike Follin) and rebuilds it as a portable, cross-platform C# program with a hand-written Z80 emulator, a complete reverse-engineering toolkit, and an asset/sprite viewer.

Every line of code in this repository is hand-written — no third-party Spectrum emulator, no third-party Z80 disassembler, no third-party imaging library. The point isn't just to reach a working port; it's to make it possible for a single reader to follow every line of code from "load a snapshot" to "render the game".

Loading screen — submarine, explosion, INSIGHT logo, SUBTERRANEAN STRYKER title


What works today

A live, playable game

A hand-written Z80 emulator, plus a 48 K Spectrum host (RAM + ROM + ULA), inside an Avalonia 12 window. The original 1985 binary boots, runs, and accepts input on macOS, Linux, and Windows.

Game's own title screen, BY MIKE FOLLIN, control select menu Gameplay — surface scene with tree, terrain, HUD reading DEPTH 1 SCORE 000000 SHIELD FUEL RESCUED 00 Gameplay — enemies appearing in the sky over the same scene

dotnet run --project src/Subterra.Game
  • Press 2 on the title screen, then play with arrow keys (move) and Space (fire) — layout-independent, works on AZERTY
  • Option 1 instead gives the classic keyboard scheme: Q/A rows thrust up/down, L moves horizontally, Enter fires
  • 1–5 on the title screen pick a control method (KEYBOARD / key row 6-0 / Kempston / Interface 2 / Sinclair — see docs/disasm/input.md)
  • Esc quits

A complete reverse-engineering toolkit

A subterra CLI with 19 sub-commands covering snapshot decoding, Z80 disassembly, memory inspection, opcode-pattern search, live emulator runs with scripted key input, RAM dumps, sprite extraction, and instruction-level execution tracing. The full reference lives in docs/TOOLS.md. Highlights:

  • render-snapshot and render-scr decode Spectrum screens to PNG.
  • disasm reads any region of a snapshot as Z80 mnemonics.
  • find-bytes greps for an opcode pattern with wildcards.
  • run-emu boots the game in the emulator, scripts key presses by frame number, and saves the final screen and RAM dump.
  • scrwrite-trace logs every screen-memory write during a single gameplay frame, with PC, address, byte value, and (x, y).
  • sprite-scan interprets a memory region as a grid of 8 × 8 (or any size) cells and writes a contact-sheet PNG for each window.

Three asset banks already extracted

Player Stryker, right-facing direction frame, pink sprite    Player Stryker, left-facing direction frame, mirror image
The player Stryker — two 16-byte directional frames (right and left), 16 × 8 pixels each, sourced from $E63B / $E64B and drawn by the dedicated XOR routine at $DCF5. The XOR drawing is exactly why the ship flickers in-game — same call erases and redraws, with a gap in between. See RE-LOG §17.

16 animation frames of pickaxe / shovel heads being swung mid-air with dirt particles flying around, bright magenta
Entity type 0 at $B8F4 — turns out this is the workers' digging-tool animation (pickaxe / shovel heads swung in 16 frames, with dirt particles), not the player. The whole entity-type table at $F5A0 is full of monsters and decor (lava, stalactites, falling rocks, spiders, bubbles, mine carts, …); the Stryker has its own dedicated draw path. We mis-identified type 0 as the player at first; the user corrected us — see RE-LOG §17.

Two more asset banks already extracted

21 UDG cave-terrain tiles at address E62B
The 21 cave-terrain UDGs at $E62B (8 × 8 each).

Master sprite tile bank at B0F4 — ~390 tiles, including cave walls, trees, buildings, humanoid figures (RESCUED people), projectiles, and HUD letters F U E L plus digits
The master 8 × 8 sprite tile bank at $B0F4 (≈ 390 tiles, decoded with subterra sprite-scan). Cave walls, trees, buildings, the "RESCUED" people figures, projectiles, and the HUD font (you can spot F-U-E-L in there).

An interactive asset viewer

dotnet run --project src/Subterra.Editor

Avalonia GUI that auto-loads either the bundled snapshot or a post-game RAM dump and lets you scroll through memory at any cell size. Preset buttons jump to the tile bank, UDGs, music data, etc. Hovering a cell shows its address and raw bytes; "Save PNG" writes a clean contact sheet into renders/ (gitignored scratch).


How it was built

The method — boot the original binary in an emulator we control, drive it with scripted input, and read the game's own sprites, tiles, and variables straight out of its RAM, disassembling only the handful of routines that decide its feel — has its own guide: docs/PLAYBOOK.md. It also explains why we built our own emulator and tools instead of using third-party ones, and is written so you could follow it to port a different Spectrum game.

The story of the reverse engineering — every dead end included — is in docs/RE-LOG.md. The lookup table of every named address we've identified is in docs/MEMORY-MAP.md. The two are kept in lockstep; every "found new routine" commit touches both. And the best discoveries — the Star Wars hall of fame, the developers' hidden signatures, the eight sounds the game never plays (press N in the native port to hear their reconstructions), the level that corrupts memory — are collected in docs/CURIOSITIES.md.

A few highlights from the journey:

  • The master tile bank at $B0F4 was found by tracing back from LD DE,$4000 (the routine writing the screen address) to the inner draw helper at $DAF2, where the indirection index → $B0F4 + index*8 jumped out. See RE-LOG §14.
  • Flicker and colour clash are by design. Subterranean Stryker draws its moving sprites with an XOR routine at $E1DE — same call erases and draws, with a gap in between. And colour is stored in 32×24 attribute cells, so a sprite passing near an enemy briefly shares its 8×8 colour. Both are intrinsic Spectrum-hardware behaviour, faithfully reproduced. See RE-LOG §12.
  • A forty-year-old Star Wars easter egg. The idle title screen draws a HALL OF FAME ($FCDB) whose default high-score names at $FE0F are Red Squadron pilots: "somebody", Wedge, Biggs, "John D.", Luke, Porkins — plus "Timothy" and "Gof", almost certainly Tim Follin and Peter Gough signing their work. As far as we can tell, nobody had spotted it in the data before. See title-menu.md and RE-LOG §59.
  • The bitmap IS the collision system. Nothing in this game ever compares coordinates for damage: the player takes hits when his XOR-draw lands on non-zero pixels ($DCF5), and enemy ships/the boss DIE when their own draw finds the laser beam pattern $EF under them ($E9F0 — including a kill jingle and an 8-particle explosion). We got this wrong once ("the laser hits nothing") before finding the check hiding in the targets' blitter — the correction story is in RE-LOG §62 and laser.md.
  • Level 0 is a bug. The level-0 record pointer sits 3 bytes out of alignment — the page after level 5 would draw garbage and corrupt RAM (entities.md). The port wraps 5 → 1 instead.

Repository layout

original/        original 1985 game files + 48 K Spectrum ROM
  tape/          the .tzx tape image
  dumps/         the .z80 snapshot + SCR loading screen
  rom/           48k.rom (with provenance note)
src/             emulator-based solution (.NET 10)
  Subterra.Spectrum/   snapshot loader, Z80 CPU, ULA, screen, PNG
  Subterra.Assets/     SpriteSheet decoder, RenderedImage
  Subterra.Tools/      the `subterra` CLI — 19 sub-commands
  Subterra.Game/       Avalonia window — playable emulator
  Subterra.Editor/     Avalonia asset viewer + Map tab editor
native/          standalone emulator-free C# port (SDL2, no NuGet)
  SubterraCS.slnx         three-project .NET 10 solution
  SubterraCS.Core/        game logic — zero dependencies
  SubterraCS.Platform/    SDL2 P/Invokes + key-binding layer
  SubterraCS.Game/        executable
docs/
  PLAYBOOK.md      the method + a port-your-own-game field guide (start here)
  RE-LOG.md        the running notebook (read top-to-bottom)
  MEMORY-MAP.md    every named address, organised by RAM region
  TOOLS.md         every tool with what / why / how-to
  CURIOSITIES.md   hidden gems: Star Wars HOF, lost sounds, level-0 bug, …
  FEASIBILITY.md   pre-port assessment (now historical)
  disasm/          per-subsystem annotated Z80 listings (21 files)
  images/          20 curated renders from the RE process
assets/extracted/
  tiles-b0f4.bin first standalone asset file (3 KB tile bank)
renders/         timestamped render output — gitignored (regenerable
                 scratch; curated examples live in docs/images/)

Quick-start

Requires .NET 10 SDK. No other tooling required.

# 1. Build the whole solution
dotnet build SubterraneanStryker.slnx

# 2. Play the original game in our emulator
dotnet run --project src/Subterra.Game

# 3. Browse memory / assets with the GUI viewer
dotnet run --project src/Subterra.Editor

# 4. Use the CLI — print all available commands
dotnet run --project src/Subterra.Tools -- --help

# Examples of CLI use ------------------------------------------------

# Render the original loading screen
dotnet run --project src/Subterra.Tools -- \
    render-scr original/dumps/SCRSHOT/SUBSTRYK.SCR

# Render the snapshot's screen memory (= title screen)
dotnet run --project src/Subterra.Tools -- \
    render-snapshot original/dumps/SUBSTRYK.Z80

# Disassemble the main game entry point
dotnet run --project src/Subterra.Tools -- \
    disasm original/dumps/SUBSTRYK.Z80 F5FD 30

# Boot the game, drive it via scripted keys, dump RAM for the Editor
dotnet run --project src/Subterra.Tools -- \
    run-emu original/rom/48k.rom original/dumps/SUBSTRYK.Z80 600 \
    -keys=5-10:SPACE,40-50:1,200-500:A \
    -ram=build/post-game.bin

# Extract the in-game UDG cave tiles from the RAM dump
dotnet run --project src/Subterra.Tools -- \
    sprite-scan build/post-game.bin E62B E700 8x8 \
    -cols=8 -count=21 -scale=6

See docs/TOOLS.md for the full reference of every CLI command, every public class in the runtime library, and every GUI feature.


Generated files (what's in the repo vs what you build locally)

A few intermediate files appear in commands and in the Editor's auto-load path but are not committed — they're cheap to regenerate, and pinning them in git would just create churn. Here's exactly where each one comes from.

build/post-game.bin — 48 K RAM dump captured mid-gameplay

The boot-time snapshot (original/dumps/SUBSTRYK.Z80) captures the game waiting on PAUSE 0, before its own initialisation has run. That means the master tile bank at $B0F4 and the in-game UDGs at $E62B are not yet populated. To see those banks we have to run the game past its init code and dump RAM at that point.

mkdir -p build
dotnet run --project src/Subterra.Tools -- \
    run-emu original/rom/48k.rom original/dumps/SUBSTRYK.Z80 600 \
    -keys=5-10:SPACE,40-50:1,200-500:A \
    -ram=build/post-game.bin

Reading the -keys= line: press SPACE during frames 5..10 (to break out of PAUSE 0 on the boot screen), press 1 during frames 40..50 (to pick the KEYBOARD control option), then press A for frames 200..500 (to fly the ship through the level). After 600 frames we save the full 48 K of Spectrum RAM as a flat binary, build/post-game.bin.

The Editor (dotnet run --project src/Subterra.Editor) checks for this file at start-up and uses it if present, so the Tile bank ($B0F4) and Cave UDGs ($E62B) preset buttons render real content. If the file is missing, the Editor falls back to the boot snapshot — those banks just look like zeros.

assets/extracted/tiles-b0f4.bin — 3 KB master tile bank

A 3 072-byte slice of the post-game RAM dump containing the master 8 × 8 tile sheet at $B0F4..$BCF3. Committed (small, useful), but you can regenerate it from the RAM dump above with:

dd if=build/post-game.bin of=assets/extracted/tiles-b0f4.bin \
   bs=1 skip=$((0xB0F4 - 0x4000)) count=3072

renders/*.png — render output (gitignored)

Every PNG rendered by any tool in the project goes here, with a timestamp suffix. Examples:

  • subterra render-scr file.scrrenders/scr-<name>_<ts>.png
  • subterra render-snapshot file.z80renders/snapshot-<name>_<ts>.png
  • subterra run-emu ...renders/emu-<name>-f<NNNNN>_<ts>.png (and one per -stride frame if you ask for a sequence)
  • subterra sprite-scan ...renders/scan-$<addr>-<WxH>_<ts>.png
  • The Editor's Save PNG button → renders/sprites-$<addr>-...

The whole renders/ directory is gitignored — it's regenerable scratch. Twenty curated renders that document the RE process (sprite scans, emu vs native comparison, diff-vs-emu progression, entity type sheet) live in docs/images/ instead.

bin/ and obj/ — .NET build output

Gitignored, fully regenerable via dotnet build. You should never need to look in here.


The native port

A second, emulator-free C# port lives alongside the Avalonia-wrapping Subterra.Game. It's in native/ — a standalone three-project solution (SubterraCS.slnx) with a hand-rolled SDL2 wrapper (~250 LoC of P/Invokes, no NuGet packages), the four sprite-blitters ported as C# methods, the entity / spawn / level systems re-implemented natively, and a procedural level generator that takes over once the original's six pages have been exhausted. As of RE-LOG §55, every gameplay subsystem from level-load through death has been disasm'd, documented in docs/disasm/, and ported faithfully — verified by a 0% diff-vs-emu at f100 and f300 (title + early frames).

Side-by-side comparison: cassette emulator (left) and native C# port (right), both showing the same cave silhouette with tree, hill profile, HUD bars, and mini-map
EMU (left) vs native C# port (right) at the same level-1 gameplay state. Same cave silhouette, tree, hill profile, and HUD bars — produced by completely independent code paths: a Z80 running the 1985 binary on the left, ~1.5k lines of game C# on the right.

Recent native port milestones (full narrative in docs/RE-LOG.md, per-subsystem ASM traces in docs/disasm/):

  • Damage (damages.md) — $DCF5 XOR-overlap shadow-carry flag is the cassette's PRIMARY damage trigger; $DD4D coord walker is INSTANT-DEATH; $DDC4 has no per-hit invincibility. Port has all three ($DCF5 + $EB7A/$EDC0 address-match + $DD4D coord walker), with the artifact SetInvincible(20) cooldown removed.
  • Spawn-in + slide-in (spawn-in.md) — $DB1A 16-row scroll-and-paint slide-in, $E135 dots-converge (8 particles, 40 frames), correct level-start sequencing per $F6F2..$F6CB and respawn loop at $F6C7..$F6EF. Particles drawn as 2×2 pixel XOR blocks (port of $E1C0's 4-corner $E1DE calls), not the full 8×8 attribute cell my first pass used.
  • Assets (assets.md) — full inventory of every byte in assets/extracted/: cassette address, byte layout, consumer, port loader. Per-level cave colour wired through level-speed-e57c.bin (each level now has its own attribute byte — white, green, magenta, yellow, red, blue).
  • Shift precision modifier (port-only) (input.md §"Port-only addition") — hold Shift while pressing Q/A/L for edge-triggered single-step movement. Vertical = 1 px altitude per edge. Horizontal = 1 px scroll per edge via a post-shift over the whole playfield bitmap so workers + ships + bullets all stay anchored to the cave during sub-pixel scrolling. The cassette has no equivalent ($DA23/$DA62 are byte-aligned at 8 px/frame).
cd native
dotnet run --project SubterraCS.Game           # interactive SDL2 mode
dotnet run --project SubterraCS.Game -- --headless --frames=600   # headless test

Controls (native port):

  • Q / A — thrust up / down
  • L — scroll horizontally in current facing
  • Left / Right — face left / right + scroll
  • Enter / Space — fire
  • 1–5 on the title screen — pick a control option and start (like the original menu)
  • Shift (port-only) — precision modifier: each direction key fires ONE pixel per press-edge instead of accelerating
  • N — cycle SFX mode for the events the cassette left silent (CURIOSITIES.md §2): OFFDESIGNED (purpose-built sfx-*.wav) → HISTORICAL (1985 lost-*.wav reconstructions)
  • K — key bindings screen: remap any game action in-game (arrows select, Enter rebinds, Esc/K saves and exits)
  • F11 — toggle fullscreen, P — pause, R — reset, Esc — quit

All movement/fire keys are remappable — easiest via the in-game K screen, which saves to keymap.cfg at the repo root. The file is also hand-editable (one action per line, fire = enter, space; multiple keys per action; actions left out keep their defaults); system keys (Esc/P/R/F11/N/K/digits) stay fixed so a broken config can't lock you out.

Is a full C# port realistic?

Yes — and it's been done. See the native port above and native/README.md for the full picture. docs/FEASIBILITY.md preserves the pre-port assessment that predicted two to three focused weeks; the estimate held up well, plus the time the reverse-engineering took to map the remaining subsystems.

Roadmap

Known open follow-ups, parked rather than blocking. (Earlier roadmap items — beeper audio, native game logic, sprite/level format decoding — are done; see docs/disasm/sound.md, the native port section, and docs/disasm/assets.md.)

  • Gamepad support in the native port (the SDL2 wrapper is keyboard-only today).
  • Release packagingdotnet publish profiles so people can play without the .NET SDK.

(Closed since this list was last trimmed: level-0 decoded — it's a data bug in the original, port wraps 5 → 1; the laser-kill mechanism found in $E9F0; entities proven stationary via the full $F1EF trace; $FA32 fully decoded — the game has exactly ONE piece of music and the message-SFX system is vestigial; the eight never-played $F8xx sounds reconstructed and unlockable via N in the native port (CURIOSITIES.md §2); in-game key-remap screen (K, saves keymap.cfg); native title menu honours keys 1–5; Hall of Fame with on-screen name entry + persistence; the boss's procedural state-byte sprite; Map tab editor; authentic cassette SFX WAVs.)


Acknowledgements

This project sits squarely on top of forty years of work by other people. Every choice we got to make — what to extract, where to look, which opcode is which, what a flag bit means — was already documented somewhere, by someone who didn't have to. None of this would have been possible without them, and it feels important to name a few groups explicitly:

The original team. Mark Wilson and Peter Gough wrote Subterranean Stryker; Tim and Mike Follin wrote its music. Tearing apart somebody's 1985 Z80 code 40 years later only makes sense if you keep in mind that real people designed it, with real constraints, and real cleverness. Some of the tricks we re-discovered (the three parallel draw paths, the chunky 2×2 XOR sprites, the ($5C36)-points-to-ROM-font HUD font, the level-page scroll gate at $E584) are small jewels of mid-80s programmer thinking.

Sinclair Research, Amstrad, Sky-In-One Ltd. The ZX Spectrum is forty years old and still legible because Amstrad granted permission in 1999 for the 48 K / 128 K ROMs to be freely redistributed for non-commercial use. Our emulator boots from that ROM, unmodified.

Zilog, for publishing the Z80 CPU User Manual. Every flag edge case in our Z80Cpu traces back to a paragraph in that book — flag-correct ALU, the family of CB / ED / DD / FD prefixes, the auxiliary registers, the IM 0 / 1 / 2 modes.

The Spectrum preservation community, in particular World of Spectrum (worldofspectrum.org and worldofspectrum.net), Spectrum Computing (spectrumcomputing.co.uk), Everygamegoing, the Sinclair Wiki (sinclair.wiki.zxnet.co.uk), Philip Kendall, and the MDFS ROM-images mirror. They are the reason a 1985 cassette and its loading screen are still available in 2026 in pristine, documented form. The original/ directory of this repository is borrowed from their work; we will take any of it down if asked.

The .z80 snapshot format, designed by Gerton Lunter for the original Z80 emulator and adopted as a de-facto preservation standard. Our Z80SnapshotReader follows the spec he documented (via the community-mirrored z80.txt) — v1, v2 and v3 forms.

Decades of Spectrum hardware lore. The interleaved bitmap address layout, the 32 × 24 attribute grid + colour-clash, the ULA's port $FE keyboard half-row encoding, the FRAMES counter at $5C78, the UDG pointer at $5C7B, the 50 Hz interrupt timing, the printer-buffer / system-variables / channel-area layout — all of these are knowledge that exists because someone, somewhere, once wrote it down and kept it findable. Sites like Sinclair Wiki and the various Spectrum FAQs collected on Usenet and re-mirrored ever since are the substrate this project floats on.

Every author of every previous Z80 emulator and disassembler. We deliberately didn't read other Spectrum-emulator source code while writing ours — not because they're bad, but because the project's premise is "do it ourselves so we understand it". But their existence, and the years of bug reports and test cases they generated, set the bar for what "correct" means and gave us the oracle we needed when our emulator misbehaved.

If any of the above are reading this and feel under-credited or mis-attributed, please open an issue — getting the acknowledgements right matters.

Legal

The original 1985 game is © Insight Software. The tape image, the snapshot, and the loading screen under original/ are widely mirrored across preservation archives (World of Spectrum, Spectrum Computing). Nothing here is sold, and the binaries are kept only for reverse engineering and historical preservation. Insight Software (or any current rights holder) can ask for the binaries to be removed at any time.

The 48 K Spectrum ROM under original/rom/ is © Amstrad plc, redistributed under Amstrad's 1999 permission grant for non-commercial use.

All hand-written code (tools, runtime library, GUI apps, documentation) is licensed under the MIT License — see LICENSE.

— John Knipper, <code@jkn.me>

About

A clean-room-ish reverse engineering project that takes the 1985 ZX Spectrum game Subterranean Stryker (Insight Software: code by Mark Wilson & Peter Gough, music by Tim & Mike Follin).

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages