Porting Konami's Contra (NES, US) to the Atari 7800 SN cartridge — what the two machines actually demand of a programmer, what had to be rebuilt from nothing, and what the numbers say at the end.
Figures measured by tools/test_scaffold.py and tools/bench.py against a cycle-accurate 6502 / SN-cart / Maria model, not estimated.
The NES draws a background you describe: a nametable of 8×8 tile indices, an attribute table of palettes, a pattern table of pixels, and two scroll registers. Scrolling is free. A game changes the picture by writing tile numbers.
Maria — the 7800's graphics chip — has none of that. There is no tilemap, no scroll register, no attribute table. The screen is a display-list list: for each horizontal band, a pointer to a list of objects, and each object is an address, a palette, a width and an X position. Maria walks those lists with DMA and steals the cycles from the 6502 while it does. Nothing is free. Every pixel on screen exists because a list entry put it there, and the list is rebuilt by the CPU.
So the port is not a translation of Contra's rendering. Contra's logic is transliterated instruction for instruction; the rendering underneath it is a new engine whose job is to make Maria produce, every frame, what the NES's PPU would have produced for free.
| NES (PPU) | Atari 7800 (Maria) | |
|---|---|---|
| Background model | Nametable of tile indices + attribute table | Display list of objects per zone; no tilemap |
| Scrolling | Two hardware registers | None — the CPU re-authors the lists |
| Colour | 4 background + 4 sprite palettes, 3 colours each, per 16×16 attribute cell | 8 palettes, 3 colours + a shared background colour, chosen per object |
| Pixel packing | Two bitplanes, 8×8 tiles | Read mode per band: 160A is 4 px/byte at 2 bits; 320A is 8 px/byte at 1 bit |
| Sprites | 64 hardware sprites, 8 per scanline | No sprites — a “sprite” is another object in the same list |
| Per-line limit | 8 sprites; the 9th vanishes | 428 DMA cycles a line, shared by background and sprites |
| CPU during display | Runs at full speed; PPU is independent | Halted while Maria fetches — the picture and the game share one budget |
| Raster effects | Sprite-0 hit, mid-frame register writes | Display-list interrupts, one per zone entry at most |
| Sound | APU: 2 pulse, triangle, noise, DMC | TIA + (on this cart) POKEY at $0450 and SN76489 at $043F |
The consequence that shapes everything else: on the NES the picture costs the game nothing. On the 7800 the picture is charged to the same 6502 that runs the game, twice over — once in the cycles Maria steals for DMA, and once in the cycles the CPU spends building the lists Maria will read. Every design decision below is downstream of that.
The background engine is the largest single piece of original work in the port. A level's map is converted to unique 16-column pictures; virtual entries map the NES's screen sequence onto them. Columns of pixels live in scroll rings in cart RAM — one 64-byte-wide, 16-page strip per zone — and the display lists are built from run templates: neighbouring columns that share a palette become one object, because an object is what costs DMA.
Fourteen zones of sixteen lines cover the playfield, each with its own list, double-buffered so Maria never reads a list being written. A zone whose window has not moved is not rebuilt at all; a zone that moved by exactly one cell has its templates shifted in place rather than re-emitted. Those two cases are the common ones while walking, and they are the reason the engine fits.
| Constraint | Value | Why it exists |
|---|---|---|
| Playfield zones | 14 × 16 lines | One display list each, 96 bytes; up to 12 background + 10 sprite headers |
| Object width | 15 cells max | 16 cells leaves a width field of zero, and a zero second header byte ends the list |
| Background objects | 16 a zone | Runs, ring-wrap splits, palette overlays and the two border covers |
| Background shadows | 12 slots | Columns an enemy has drawn into, so the next decode does not undo them |
| Display-list list | 312 / 262 lines | It must cover the whole frame, not the visible part — Maria reads straight on past a list that stops short |
Sprites are the same objects in the same lists, so they compete with the background for the 428 cycles a line. The port runs a per-band DMA manager: each band gets a budget derived from what its background already costs, the injector spends it, and DrawEnemies rotates its starting slot each frame so a band that ran out drops a different enemy next time. The result is flicker under load rather than an enemy that is permanently invisible — which is, as it happens, exactly what the NES's 8-sprites-per-line limit produces.
A selection of the more instructive failures. Each was diagnosed by measurement — a differential run, a rendered frame compared against converted data, or a cycle count — rather than by reading the code harder.
An alien figure stands to the right of PLAY SELECT on the title screen, drawn in palette 3 — whose three entries are all $0F. It is invisible by construction. The port's palette animation steps palette 3 through a record's four cycle rows every eight frames for every record, so eight frames after the title appeared, the alien lit up in the jungle's colours.
No ring or run check could see it: the ring held the map exactly and every list was a background run in the right place. What found it was the rendered frame compared against a reference built from the NES's own nametable.
A first enemy routine is entered on the NES with the carry set — it survives from the cmp #$10 that chose the level dispatch several routines earlier — and the lair's alien mouth computes its hit points with adc #$03 on that basis. The port's dispatcher left it clear, so every mouth had one hit point too few.
Separately: AND does not touch the carry, and the spider's respawn delay leans on a carry surviving across one. The transliterated assembly gets that for free; the Python twin, which computed it arithmetically, was one short and drifted a whole spawn cycle over a few hundred ticks.
The base's S weapon has three per-bullet variables that nothing writes by name. On the NES they are the same bytes as three others, and clear_bullet_values zeroing those is the whole of their initialisation. The port gave them RAM of their own, so nothing reset them: a reused bullet slot inherited the last shot's accumulated drift and the indoor spread grew wider with every shot — 21 NES pixels for the first, 61 for the fourth at the same bullet age, with no bound.
The transliteration was perfect; the RAM map was not. The twin had the identical hole, so three differential scripts passed.
Which makes the byte after a list load-bearing. The HUD lists are eleven bytes and the twelfth is their real terminator, so two console-RAM addresses are not allocatable. One of them held an enemy variable: the moment a base room placed its enemies, the bottom HUD zone ran on into whatever followed it in RAM — 743 DMA cycles on a line that carries 428.
Read mode is set by a display-list interrupt and holds for a band, so the chain is a step counter: 160A for the playfield, 320A for the HUD, then the frame sync. A level load is one main-loop pass many video frames long, and Maria spends all of them reading lists being rewritten underneath it. One frame carrying a different number of interrupts rotates the counter by one — and the rotated state is stable: the sync starts firing at scanline 32, the main loop's own reset becomes a no-op, and the playfield is drawn in the HUD's read mode for the rest of the level.
Which region lost was a timing lottery. Turning an unrelated RAM wipe off fixed PAL and broke NTSC instead. The fix is not to find the frame but to pin the chain: wait for the start of a vertical blank, where every interrupt of the frame has already fired, and zero the counter there.
The run-list search decides whether an entry is before or after the visible window from a signed byte difference — correct only while the cursor is within 127 columns of the window. Loading a room rewound the cursor to column 0 on every step, forward ones included. From the ninth entry on, the window is at column 144 and a run at column 15 reads as +127: ahead of the window. The search backed up at once and the zone emitted no runs at all.
A black room the player could still walk through, in every indoor record with more than nine entries. The rings decoded perfectly throughout, which is why the suite's ring comparison passed while the screen was empty.
The lair's alien mouth routines were named mouth_00/01/02; the waterfall's dragon already had those names 1,200 lines further down the same module. Every lair mouth ran the dragon's routines — 1,700 of 1,700 ticks differing, with no clue but an enemy whose hit points never changed. The harness now checks every pair of twin modules for accidental overlap.
src/ carries Contra and Probotector behind .ifdef. The converter's byte-collector took every .byte line, so where the disassembly has a conditional it spliced both variants into one array. Level 8's super-tile data has two of them, which made its base set 76 super-tiles instead of 73 — and every screen code above the first conditional read the wrong tile. Screen 0's left columns came out as rows of alien mouths where the level has a purple wall.
The NES's turret man and his bullet ($0e/$0f) are renumbered per level, and not onto the same pair: $18/$19 on levels 5 and 6, $19/$1A in the hangar — whose own enemy generator has taken $18. The shared firing routine wrote the literal $19 as “his bullet”. In the hangar, $19 is the turret man himself: every shot generated another turret man sixteen pixels left and four up, each of which fired and made another, until all sixteen enemy slots held one.
A spiked wall that is already up is part of the level's picture, so the collision map has its column solid, and the NES undoes that at run time as it draws the rubble. A ROM bank cannot patch itself, so the column goes into a six-entry list the player's wall check reads — six, because six walls are already up. But both wall types recorded an entry, and the level places eight rising walls as well. The list filled before the player reached the later ones, the overflow was dropped silently, and the wall blew up leaving an invisible barrier with nothing left to shoot.
The ported player, the indoor player, the sound engine and level 4's boss room are each transcribed a second time in Python from the same NES listings. The suites run the ROM and the model from identical input and compare every tick: player state, bullets, enemies, weapons, score, lives, RNG, and the sprite variant actually drawn.
This is what catches the subtle class of bug — a clobbered Y register, cmp flags leaking into a caller's branch, a missing indexed load.
A differential test proves two implementations agree. It cannot see a fault they share. Three of this port's worst bugs were invisible for exactly that reason: the aliased S-weapon variables, the turret man's literal enemy type, and the six-entry wall list — the twin had all three.
The answer is checks that assert an invariant instead of a comparison: two shots of the same age are the same width; what he fires is a bullet and he does not multiply; every already-up wall fits in the list.
| Suite | What it proves |
|---|---|
| test_scaffold | Boot, RAM wipe, display-list geometry, read modes, DMA budget, list swap beating the beam — in both regions |
| test_level | Rings, run headers, the rendered picture against an image built from the cell data, a full-level sweep, the stale-zone guarantee, the CPU budget |
| test_player | The ROM against the twin every tick over ~22 input scripts |
| test_flow | Title, level select, intro, pause, game over, restart, the level-1 ending, and the credits' cadence |
| test_sound | The whole 130-byte engine RAM block and the APU shadow, every frame |
| test_level2 | The base's records, rooms, zoom frames and boss room decoding into the right ring halves |
| test_level5 | The snow field's banks, super-tiles, spawn list and the 256-column wrap |
| test_vert | The vertical level: row buffers, its own display lists, 25,984 playfield pixels, and the scroll across fifteen row crossings |
| test_level6 | Tile animations, the boss's own sprite set, and its death writing a door into the background |
| test_level7 | Fifteen screens, the claws' cells, the shadow budget a screen demands, and a risen wall's picture read back from its own data |
| test_level8 | What the lair places, its rings and picture, and its eight palette rows |
@+/@- anonymous labels resolving to the nearest @ have each caused real bugs here.bench.py and profiler.py — the CPU yardstick and per-routine attribution. The budget is not a constant: it moves with what Maria has to fetch, so it is measured per frame.Each of these is a decision with a reason, not an omission. They are recorded in the port's own documentation so they can be revisited.
| Deviation | NES | Port | Reason |
|---|---|---|---|
| Enemy bullets in flight | 16 slots | 6 | Each is a sprite, a collision test and a band's DMA |
| S-weapon bullets | 10 | 8 | Asked for against the slowdown |
| Soldier-generator slots | 7 | 5 | Same |
| Vertical level enemy slots | 16 | 6 | Its enemies take 240 ticks to cross; the cap took PAL from 6.8 % of frames over budget to 0.6 % |
| Exploding bridge | 4 clouds | 1 | Asked for while testing on the cart; the code says exactly what to restore |
| Player behind the background | Sprite priority bit | Hidden instead | Maria draws a zone's objects in list order with sprites appended after the background — there is no priority to borrow |
| Claw retraction | Three tile rows | Cell blanked | The erase lives entirely in a third row that a 16-pixel cell has no room for |
| Players | 2 | 1 | Player 2 is marked in game over, as the NES marks a missing second player |
Still switched off for cartridge testing rather than by design: sound effects and music, three lives replaced by forty, and the bridge cloud above. Not emulated at all: the APU's sweep unit (two effects play flat), the DMC drum samples, and the title tune. Not yet ported: the weapon-item half of solid-background collision, the destroyed-island tile patch, and the NES's end-of-level walk-in for levels other than the first.
The playfield is 160A — four pixels to a byte — so a character would be four pixels wide and unreadable. 320A packs eight pixels to a byte at one bit each, and a cell is the same two bytes at the same position. The title screen's text block and both HUD lines are drawn at 320 columns, which is the NES's own 256 across the same width, at identical DMA cost.
POKEY runs on the same 1.789773 MHz clock as the NES APU, so a pulse period maps to a 16-bit divider of 8×(P+1)−1 — three shifts, and 0.00 cents of error. On the SN76489 the triangle channel maps exactly (n = P+1) and gets a channel of its own, which the NES's mixer never gave it; the pulses land within 1.01 cents at worst.
PAL and NTSC differ in scanline count, display-list geometry, interrupt count and tick rate. The port detects the region at boot and runs both from the same image, with PAL on a one-tick-per-frame table — 17 % slower than the NES, as the original PAL carts were.
The NES has no way to reach the base or the waterfall without playing through what precedes them; this build has a select screen. And the ending credits roll at one pixel every four frames evenly — the failure mode there is a cadence, not a frame, so it is checked as a sequence of scroll positions against a constant gap.
Content is complete: all eight levels and every boss, including level 4's boss room, with every enemy each level places ported and running against a differential model. The two areas that are not complete are performance under specific loads and a set of switches still set for testing.
| Scenario | PAL | NTSC |
|---|---|---|
| Level 1, walking | 17,924 of 22,325 | 0.2 % of frames over |
| Level 5, walking and shooting | 18.4 K of 22.4 K | 0.6 % of frames over |
| Level 3, median frame | 14.9 K of 25.6 K | 7.8 % over (21 % shooting) |
| Base, worst pass | 9 % over (3 of 840 frames) | 41 % over (16 of 840) |
| L weapon | 0.7 % of frames over | 26 % of frames over |
| S weapon in a crowd | 29 % over with the tune playing | 67 % over |
An overrun costs a frame, not the picture: the main loop waits for the next sync, so the display stays coherent and the game briefly runs slower. Ordinary play fits both regions. The S weapon in a crowd does not, and NTSC — with 19,681 cycles left to PAL's 24,458 — is where every scenario is tighter.
The honest summary. This is a faithful conversion of the game's logic on top of an entirely new rendering engine, verified by comparison against the original's own data rather than by eye. It is not finished: sound is incomplete, several scenarios exceed the frame budget, four testing switches are still set, and the NES's end-of-level walk-in exists for one level of eight. Nothing in the remaining list is unknown — each item is written down with the measurement that identified it.
The NES rewards a programmer who thinks in tiles and tables. Its hardware answers the question “what should be on screen?” and the CPU is left alone to run the game.
Maria rewards a programmer who thinks in cycles and lists. It answers a narrower question — “what should this band fetch?” — and hands back the rest of the problem, along with a bill for the fetching. The 7800 has the faster CPU of the two and, in 320A, the finer horizontal resolution; what it does not have is anything that draws a scrolling world for you.
Three-quarters of the difficulty in this port was not Contra. It was that on the NES a background is a description, and on the 7800 it is a program — one that has to be re-run, correctly and inside a fixed cycle count, sixty times a second, while the game it exists to serve runs in whatever is left.