Tachibana

Astéroric › Articles › Porting Asteroids to an Oric-1: what pushed back

Porting Asteroids to an Oric-1: what pushed back

Forty phases to fit a 1979 vector game into 20,000 cycles per frame. What gave way, what pushed back, and what the emulator was hiding from us.

By bmarty · Astéroric 1.0.0-beta · 14 September 2026

Asteroids runs on a vector-screen cabinet: a beam that traces segments, no pixels, no frame memory. The Oric-1 is the exact opposite — a 240 × 200 bitmap, a 1 MHz 6502, 48 KB partly taken by the screen, and a sound chip with three square-wave channels. The Astéroric project is a study clone: take the arcade logic as written in the rev 4 ROM (same processor, which helps) and make it fit on this machine. This article is about what pushed back. Phase numbers refer to the repository's CHANGELOG, which is authoritative.

1. The budget that did not hold

The initial framing did the arithmetic: at 1 MHz and 50 frames per second, you have 20,000 cycles per frame. A correct 6502 Bresenham routine without self-modifying code costs 15 to 22 cycles per pixel; with 30 segments of 20 pixels, drawn then erased, the budget is blown before anything is computed. The document listed three levers: accept 25 Hz, reduce shape complexity, or invest heavily in the line routine.

The phase 2 measurement settled it: ~97 cycles per pixel, five times the assumption. 25 Hz was adopted immediately — as the nominal target, not a downgrade, and it is the frame rate of many Oric games. The self-modifying code of phase 2b (dx/dy operands patched into immediates, Y-step direction patched on entry) brought only 4 cycles per pixel, far from the 50 aimed for. The real saving came later, and from elsewhere.

2. Drawing vectors on a bitmap

Everything is drawn in XOR: drawing a segment a second time erases it, with no background save. It is elegant, and it has an immediate flaw, found in phase 9g: a pixel shared by two segments — the ship's nose, every vertex of a closed polygon — is XORed twice, hence erased. All asteroid vertices were invisible. The first fix was an explicit replot of shared vertices (three XORs = pixel on). The right fix came in phase 24: a half-open Bresenham drawing ]P0, P1], every pixel except the start. On a polygon walked in a cycle, each vertex is then XORed exactly once. Net cost nil — one pixel fewer per segment pays for the entry test.

The decisive performance gain did not come from the line routine but from the idea of not calling it any more. Asteroids never rotate: their twelve silhouettes (four shapes × three sizes) are rasterised at build time by a Python script that reproduces the Bresenham of line.s, then emitted as HIRES bitmaps pre-shifted into their six intra-byte phases (3,402 bytes of data). Rendering becomes a byte-by-byte XOR blit, ~23 cycles per non-zero byte. A detail that matters: 240 being divisible by 6, the ghost instance of the wraparound (±240 px) keeps the same phase — a single set of bitmaps. The decimation of shapes down to 6-7 vertices, imposed in phase 18h to hold the frame rate, could be removed: the fine notches of the Atari shapes came back, since cost no longer depends on the vertex count.

A surprise in the profiler, along the way: sizeof(Asteroid) = 13. Not being a power of two, every asteroids[i].field access forced cc65 to call the software multiply mul8x167.1% of CPU time. Iterating by pointer made it vanish from the profile. Altogether, phases 24 to 26 raised idle CPU time from 17.9% to 39.8%.

3. Time: the VSync that does not exist

Phase 9 synchronised the loop on the VIA's CB1 pin, supposedly fed by the ULA's VSync signal. The game ran perfectly under Phosphoric. Then Phosphoric 1.16.11 aligned itself with the hardware, and the game started looping forever on the title screen: on a real Oric, CB1 is not wired to VSync. The emulator pulsed the pin for convenience and masked the mistake. The replacement is the VIA Timer 1 free-running at 20 ms — portable to real hardware, Phosphoric, Oricutron and MAME, at the price of a theoretical tearing never observed.

A subtler timing bug remained, present since phase 18 under the name “effective 17 Hz in busy scenes”. frame_wait() sampled the counter on entry, then waited two ticks: as long as the work fitted in the first tick, the timer grid gave 40 ms per frame; as soon as a frame exceeded 20 ms, it set off for two full ticks — 60 ms instead of 40. Phase 28 installed a fixed-step scheduler with a persistent target (frame_target += 2), catch-up over the following frames and resynchronisation after a long freeze. Adaptive 50 Hz was studied and rejected: all the physics is in pixels per frame, some fifteen families of timers count in frames, and the UFO moves ±1 pixel — switching to 50 Hz at times would double the game's speed unless everything were rescaled. A constant, reliable 25 Hz beats an intermittent, risky 50 Hz.

4. Flicker

At 25 Hz, an object erased at the start of a frame and redrawn at its end is absent from the screen ~80% of the time: the eye sees a present/absent cycle at the worst possible frequency. The fix was structural — tighten erase → tick → draw per entity and move everything that is not rendering (sound, score, text) out of that window. For asteroids, the window went from about 20 ms to 7 ms.

Torpedoes showed a nastier variant, revealed by a tester on real hardware (“fire beam hardly visible”). The old pipeline erased them at the start of the loop and redrew them at the end: absent from video memory during almost all of the computation. On real hardware, the phase between the CRT scan and Timer 1 drifts, so they appeared intermittently; under Phosphoric the phase is locked and we got 0 captures out of 20 with a visible torpedo — while RAM said it was active. The commit scheme (erase at the position of the last draw, redraw right after, screen state decoupled from logical state) gave 8 captures out of 8. Torpedoes also moved to 2 × 2 px.

5. The ROM we bypass

The keyboard is read without the ROM buffer, straight from the matrix — which assumes the matrix is understood. First version wrong: the code tested different columns while keeping a fixed R14 mask. On the hardware, SPACE and the four arrows all sit on column 4 and are told apart by their row, selected through PSG register 14. Only right SHIFT produced a false positive. Second trap, specific to the 6522: reading ORB returns what was written on bits configured as outputs, not the pin state. If the ROM left DDRB = $FF, PB3 reads back its own value and the scan becomes random — hence missed shots and inconsistent rotation. key_scan now forces PB3 to input for the duration of the scan.

Third trap, heard before it was understood: to drive the PSG's port A in matrix mode, key_scan wrote R7 = $7F — “mixer all muted” — every frame. The six tone and noise bits were cut once per frame and stayed cut until the next even sound tick: a 25 Hz chopping of every effect, most audible on long ones. That was the backlog's “ship explosion audio bug”. The fix rewrites the mixer's last real value, kept in a mixer_shadow.

The interrupt-driven sound player (phase 20) required knowing what the ROM does before jumping to the user vector: nothing. The 6502 pushes only PC and P; saving A, X and Y happens inside the ROM handler, which we short-circuit by patching the vector. Without our own PHA/TXA/TYA, the final triple PLA pops P and the return address, and the RTI goes anywhere. All other VIA IRQ sources must also be disabled before arming T1, otherwise a CA1/CB1/T2 leftover from the ROM fires a handler that never acknowledges it — infinite loop. Validated under Phosphoric with --trace-irq: 493 entries, 493 RTI, 19,930 to 19,970 cycles between two IRQs.

The handler is hooked to both vectors, $0228 (Oric-1) and $0244 (Atmos). That was not enough: with the Atmos ROM, the game does not work, under Phosphoric or Oricutron. Atmos compatibility remains a separate sprint, and the site says so.

6. The BSS, twice

C assumes a static variable is zero at start-up; the crt0 must guarantee it. Phase 9b: with a BSS larger than $83 bytes, the HIRES screen no longer switched on — the home-made zerobss differed subtly from cc65's, and the fix was to adopt the official routine through initlib. Phase 36, a month later: a ghost block appears at position (85, 85) on the first shot. 85 is $55, the pattern of uninitialised RAM. The comment “initlib calls zerobss” was false — cc65 calls them separately — and the BSS was in fact still not cleared. Two code reviews had not re-read a comment.

7. The sound of another machine

The 1979 cabinet makes its sounds with discrete analogue oscillators; there is nothing to port. The starting point was another 8-bit: Mine Storm, the Vectrex's Asteroids clone (1982). Different processor (6809), but the same AY-3-8912 chip and the same VIA 6522 — the code does not transpose, the register values do. The SS.THR, SS.BLT, SS.EXP tables served as a first approximation.

The rest was done by ear and by spectrum: FFT analysis of recordings of the cabinet. The three explosions (small, medium, large asteroid) have similar durations and differ only by noise frequency — ~167, 246 and 306 Hz, i.e. R6 = 12, 8 and 6. The shot went from a low noise to ~740 Hz. The architecture followed the cabinet: three independent circuits become three dedicated channels — A for effects, B for the thump-thump never interrupted by an explosion, C for the saucer — with a mixer recomputed from the state of all three. The title jingle (Grieg, public domain) was inaudible for three phases: the routine stored the note index in sound_tmp, then called psg_write… which uses sound_tmp as scratch. The note read back was the last value written to the PSG. A dedicated scratch was enough, placed in BSS because the zero page was full to the byte.

8. The emulator that lies, the hardware that answers

Astéroric was developed under Phosphoric, and the two projects corrected each other. The emulator masked the non-existent VSync (§3), then a wrong IJK model: the first joystick read went through PSG register 14, as Phosphoric simulated it, and did not work on a tester's physical interface. The real protocol goes through the printer port — VIA port A directly, PB4 strobe, presence bit 5 — and was validated against Oricutron; Phosphoric was fixed alongside, with a replay of the game's exact 6502 sequence in its tests.

The hardware also revealed what no emulator could show. The video attribute $1C written at initialisation selects HIRES at 60 Hz; bit 1 selects 50 Hz, and $1E is needed. Video memory content is identical in both cases, so the screenshot is identical — but the tester's RGB2HDMI would not lock onto the signal. No effect on game speed, which is paced by Timer 1 and not by the scan. In the other direction, the emulator supplied the instruments: headless captures compared bit for bit (make check), a 25-million-cycle profile, IRQ tracing, symbolised RAM dumps, scripted --type-keys scenarios. With a trap: a scripted key press lasts 40 ms, exactly one frame at 25 Hz, and can systematically fall between two keyboard scans in deterministic execution.

9. What remains

Sources & links

← Astéroric · Features →