Conversion report · 6502 / Maria

Contra on Maria

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.

Target Atari 7800, SN cart Source Contra (NES, US) disassembly Assembler MADS 2.1.7 Regions PAL + NTSC, one binary
Levels playable
8 of 8
plus level 4's boss room; every enemy each level places is ported
Test suites
11
all green; a change is not done until every one passes
EPROM
512 KB
128 banks of 4 KB; 115 declared, 77.7 % used
Fixed base free
47 B
in twelve pieces, the largest nine bytes
CPU left / frame
24,458
PAL cycles after Maria's DMA; NTSC 19,681
Per-line DMA
428
Maria cycles a scanline — the hard ceiling everything is budgeted against

Figures measured by tools/test_scaffold.py and tools/bench.py against a cycle-accurate 6502 / SN-cart / Maria model, not estimated.

The problem

Two machines that disagree about what a background is

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.

The two display systems, as the port has to deal with them
NES (PPU)Atari 7800 (Maria)
Background modelNametable of tile indices + attribute tableDisplay list of objects per zone; no tilemap
ScrollingTwo hardware registersNone — the CPU re-authors the lists
Colour4 background + 4 sprite palettes, 3 colours each, per 16×16 attribute cell8 palettes, 3 colours + a shared background colour, chosen per object
Pixel packingTwo bitplanes, 8×8 tilesRead mode per band: 160A is 4 px/byte at 2 bits; 320A is 8 px/byte at 1 bit
Sprites64 hardware sprites, 8 per scanlineNo sprites — a “sprite” is another object in the same list
Per-line limit8 sprites; the 9th vanishes428 DMA cycles a line, shared by background and sprites
CPU during displayRuns at full speed; PPU is independentHalted while Maria fetches — the picture and the game share one budget
Raster effectsSprite-0 hit, mid-frame register writesDisplay-list interrupts, one per zone entry at most
SoundAPU: 2 pulse, triangle, noise, DMCTIA + (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 engine

What had to be built that the NES gave away

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.

Structural limits the engine is built around
ConstraintValueWhy it exists
Playfield zones14 × 16 linesOne display list each, 96 bytes; up to 12 background + 10 sprite headers
Object width15 cells max16 cells leaves a width field of zero, and a zero second header byte ends the list
Background objects16 a zoneRuns, ring-wrap splits, palette overlays and the two border covers
Background shadows12 slotsColumns an enemy has drawn into, so the next decode does not undo them
Display-list list312 / 262 linesIt 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.

Findings

Things the machines actually did

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.

title screen · palette

The NES hides artwork inside an all-black palette

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.

6502 · flags

Bank 0 relies on flags the caller happens to leave

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.

RAM · aliasing

A variable the NES clears by aliasing is one the port never clears

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.

Maria · display list

A display list ends on the second byte of a header

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.

Maria · interrupt chain

A rotated interrupt chain is self-consistent, so it never recovers

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.

arithmetic · 8 bits

A signed byte can only see 127 columns

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.

tooling · Python

A method defined twice in one class replaces the first, silently

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.

source data

The disassembly contains two games

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.

enemy types

A shared routine must not name a renumbered type with a literal

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.

budgets

Six entries, fourteen walls

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.

Method

How any of this was findable

Every routine has a Python twin

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.

And its blind spot, which matters as much

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.

The eleven suites — a change is not done until all of them pass
SuiteWhat it proves
test_scaffoldBoot, RAM wipe, display-list geometry, read modes, DMA budget, list swap beating the beam — in both regions
test_levelRings, 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_playerThe ROM against the twin every tick over ~22 input scripts
test_flowTitle, level select, intro, pause, game over, restart, the level-1 ending, and the credits' cadence
test_soundThe whole 130-byte engine RAM block and the APU shadow, every frame
test_level2The base's records, rooms, zoom frames and boss room decoding into the right ring halves
test_level5The snow field's banks, super-tiles, spawn list and the 256-column wrap
test_vertThe vertical level: row buffers, its own display lists, 25,984 playfield pixels, and the scroll across fifteen row crossings
test_level6Tile animations, the boss's own sprite set, and its death writing a door into the background
test_level7Fifteen screens, the claws' cells, the shadow budget a screen demands, and a risen wall's picture read back from its own data
test_level8What the lair places, its rings and picture, and its eight palette rows

Tools

  • MADS 2.1.7 — the 6502 assembler. Case-insensitive labels and @+/@- anonymous labels resolving to the nearest @ have each caused real bugs here.
  • A cycle-accurate 6502 / SN-cart / Maria model (from the FloB 7800 project) — every measurement in this report comes from it. It does not halt the CPU for Maria DMA, so a pass only fits real hardware if its work stays under the CPU left figure, not the frame length.
  • Nine Python converters — graphics, palettes, sprites, levels, enemies, weapons, sound and credits, all reading the original NES ROM and the annotated disassembly. The suites compare the ROM against the converters, which is why a converter bug can hide from all eleven at once.
  • 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.
  • MAME with a real PAL BIOS and a signed cart — the cross-check that the emulator-independent model cannot give.
Compromises

What is deliberately not the NES

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.

Deliberate deviations
DeviationNESPortReason
Enemy bullets in flight16 slots6Each is a sprite, a collision test and a band's DMA
S-weapon bullets108Asked for against the slowdown
Soldier-generator slots75Same
Vertical level enemy slots166Its enemies take 240 ticks to cross; the cap took PAL from 6.8 % of frames over budget to 0.6 %
Exploding bridge4 clouds1Asked for while testing on the cart; the code says exactly what to restore
Player behind the backgroundSprite priority bitHidden insteadMaria draws a zone's objects in list order with sprites appended after the background — there is no priority to borrow
Claw retractionThree tile rowsCell blankedThe erase lives entirely in a third row that a 16-pixel cell has no room for
Players21Player 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.

Gains

Where the 7800 version is ahead

Text at the NES's real resolution

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.

Pitch that is exact rather than close

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.

One binary, two regions

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.

A level select, and a measured credit roll

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.

Assessment

How good is the conversion

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.

Content8 / 8 levels + bosses
Rendering fidelitypixel-compared per level
Game logictick-for-tick vs. twin
Soundno sweep, no DMC, no title tune
Frame ratesee the table below
Presentationwalk-in on level 1 only
CPU budget, measured per frame against the cycles that frame actually had
ScenarioPALNTSC
Level 1, walking17,924 of 22,3250.2 % of frames over
Level 5, walking and shooting18.4 K of 22.4 K0.6 % of frames over
Level 3, median frame14.9 K of 25.6 K7.8 % over (21 % shooting)
Base, worst pass9 % over (3 of 840 frames)41 % over (16 of 840)
L weapon0.7 % of frames over26 % of frames over
S weapon in a crowd29 % over with the tune playing67 % 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.

Reflection

What the 7800 asks that the NES does not

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.