AAE (Another Arcade Emulator) is a C emulator for vector arcade machines, derived from MAME's drivers. A port doesn't run AAE as a program: it links AAE's driver for one game into the cartridge image as a C library and points its draw output at the Vectrex beam.
game/tacscan/ in the starter kit is a complete worked example: Sega G80 hardware, a Z80 and Sega's own vector generator. Read it alongside this page; everything here has a concrete counterpart there.
First, decide whether it can work
Three questions, in this order. Getting them wrong costs days.
Is it a vector game?
The Vectrex has no framebuffer. A vector arcade game emits line segments, the same shape as what this hardware draws. A raster game doesn't, and turning one into vectors is a research project. See Porting from MAME.
Does the CPU fit?
The RP2350 runs at 150 MHz and has to emulate the original machine, build the command list, and keep the bus busy. Measure before committing:
cd game/<yours> && make host && ./build/host_<x> 120The harness prints per-frame time for the emulated CPU and for vector generation. On the cartridge the ratio changes, which is why Tac/Scan also has a telemetry block that measures the split on the console.
Look for an idle spin. Most arcade games wait for their vblank interrupt in a tight loop, and emulating it is pure waste: Tac/Scan spent 94.8% of all Z80 cycles spinning at $7A84. mz80_set_idle_pc(pc) skips it. Find the address with make host-prof, and check that the variable the loop tests has exactly one writer, the interrupt handler.
Does it fit in 496 KB?
The emulated machine's ROM and RAM live in the image, plus the romset buffer, plus the command list. Tac/Scan needs 64 KB of Z80 space, 1 KB of PROM, about 24 KB of romset buffer and the list. A 68000 game with 830 KB of RAM doesn't fit in SRAM at all and needs PSRAM.
The eight pieces you write
Everything else comes from the kit. In game/tacscan/:
| piece | file | what it does |
|---|---|---|
| 1. the driver row | src/aae_machine.c | declares the machine: CPU, clock, interrupt, fps |
| 2. the globals | src/aae_machine.c | the subset of AAE's globals your path touches |
| 3. the ROM table | src/tacscan_romtable.h | which zip member goes where |
4. getport() | src/aae_machine.c | Vectrex controls to the arcade machine's inputs |
| 5. the stubs | src/aae_stubs.c | no-ops for AAE subsystems this game never enters |
| 6. the compat header | src/aae_compat.h | types AAE expects that no header defines here |
| 7. the frame loop | src/main.c | about 20 lines |
| 8. the Makefile | Makefile | sources, flags, include uvm2.mk |
Optionally: sound (src/samples.c, src/ts_audio.c), a libc subset, and a host harness (tools/host_test.c).
1. The driver row
AAE describes every machine as one row of struct AAEDriver. A port keeps only its own row:
struct AAEDriver driver[TACSCAN + 1] =
{
[TACSCAN] =
{ "tacscan", "Tac/Scan", 0,
&init_segag80, 0, &run_segag80, &end_segag80,
0, 0, /* dips, keys: input via getport */
0, 0, /* samples, artwork */
{CPU_MZ80, CPU_NONE, CPU_NONE, CPU_NONE},
{3000000, 0, 0, 0}, /* 3 MHz Z80 */
{1, 0, 0, 0}, /* slices per frame */
{1, 0, 0, 0}, /* interrupt passes per frame */
{INT_TYPE_INT, 0, 0, 0}, /* maskable IRQ, not NMI */
{0, 0, 0, 0},
40, VEC_COLOR, 0, /* fps, video type, rotation */
{0, 1024, 0, 1024} /* gamerect */
}
};
int gamenum = TACSCAN;gamenum must be the real enum value, not 0: driver files switch (gamenum) to pick their port handlers and security PROM.
fps is game speed. On most of these machines the logic advances one interrupt per frame, so the refresh rate is the game speed. Tac/Scan is a 40 Hz board; free-running at 42.8 fps it played 7% fast. So the port calls uvm2_set_refresh(40), and the number comes from the hardware, not from taste.
2. The globals
Declare only the AAE globals your path actually references; the linker tells you which. GI[region] is the base of each memory region. Point them at static arrays, not malloc.
Size them to what's actually used. AAE allocated 64 KB for Tac/Scan's region 1 and never read past 1 KB of it (it's a PROM used as a sine table). Keeping it at 0x400 is part of what makes the game fit.
3. The ROM table
The romset lives on the SD card as roms/<name>.zip and is decompressed directly into GI[]:
const char game_romset_name[] = "tacscan.zip";
static const aae_rom_op ROM_OPS[] = {
/* member name in the zip addr size src_off region mode */
{ "1711a.cpu-u25" , 0x0000, 0x0800, 0x0000, 0, AAE_ROM_PLAIN },
{ "1670c.prom-u1" , 0x0800, 0x0800, 0x0000, 0, AAE_ROM_PLAIN },
/* ... */
{ "s-c.xyt-u39" , 0x0000, 0x0400, 0x0000, 1, AAE_ROM_PLAIN },
};mode:AAE_ROM_PLAIN, orAAE_ROM_EVEN/AAE_ROM_ODDfor 16-bit interleave.src_off: start at this byte of the file (aROM_CONTINUE).- A
NULLfile repeats the previous one (aROM_RELOAD).
The names are the real zip member names, matched case-insensitively. There's deliberately no fuzzy matching: resolving names on the cartridge would turn "a file is missing" into "the game behaves oddly". Generate the table with the zip in front of you.
Interleaved loads and ROM_CONTINUE need a staging buffer: set AAE_ROM_STAGE in the Makefile to the largest such file. The romset buffer size itself is derived from the zip by uvm2.mk.
If the load fails, don't emulate over zeros. That draws nothing, and a black screen can't tell "the ROM is missing" from "the game hung":
if (aae_rom_last_error) {
for (;;) { v_WaitRecal(); aae_rom_error_screen(aae_rom_last_error); }
}aae_rom_error_screen prints the failure and the path it tried.
4. Input
The emulated game reads its controls through getport(n). Map the Vectrex's buttons and stick onto it:
extern unsigned char currentButtonState; /* button N = bit N-1 */
extern signed char currentJoy1X, currentJoy1Y; /* -127..127 */
int getport(int port)
{
int b = currentButtonState, jx = currentJoy1X;
switch (port) {
case 0: return (b & 0x04) ? (0xe0 & ~0x20) : 0xe0; /* coins: ACTIVE LOW */
case 4: { int v = 0; /* buttons: ACTIVE HIGH */
if (b & 0x08) v |= 0x01; /* start */
if (b & 0x01) v |= 0x04; /* fire */
if (b & 0x02) v |= 0x08; /* grab */
return v; }
case 6: if (jx > 20) return (jx > 80) ? 5 : 3; /* spinner delta */
if (jx < -20) return (jx < -80) ? -5 : -3;
return 0;
default: return 0xff;
}
}Two traps:
- Polarity is per port. Sega's coin inputs are active low with a non-zero idle value; its button port is active high. Get one wrong and the controls silently do nothing.
- Some drivers mask.
sega_fix_dipsdoesval & 0xF0, which threw away an earlier mapping on the low nibble. Take the bit layout from the driver's own key table, not from a guess.
A spinner is a delta per read, not a position. The driver accumulates it.
5. Stubs and the compat header
AAE's CPU dispatch names every core it supports, whether the game uses it or not, so stub what never runs:
void m68k_pulse_reset(void) { }
int m68k_execute(int n) { (void)n; return 0; }
unsigned m6502exec(unsigned n) { (void)n; return 0; }
void save_dips(void) { }
int log_it(char *fmt, ...) { (void)fmt; return 0; }aae_compat.h is force-included (-include) into every AAE file and supplies the few types the drivers expect (BYTE, WORD, DWORD) plus the player-2 stick globals.
Stub only what never runs. Stub a core the game does enter and it will appear to work and then misbehave, which costs far more than a link error.
6. Drawing
You write nothing. AAE's vector.h already ends in v_directDraw32(...). Two things happen there:
- Colour becomes intensity as
max(r, g, b) / 2, not the average. These games drove colour monitors, and 85% of strokes have exactly one component lit, so the average would make most of the picture a third as bright as white. Strokes belowAAE_Z_CUT(20) aren't drawn. - The screen transform is
v * AAE_SCREEN_MUL + AAE_SCREEN_OX. The defaults (36, −13356, −13968) were measured over the 1st to 99th percentile of every endpoint for one hardware family, not min/max, because games happily draw objects far outside their own screen. A game with different coordinates defines its own values before includingvector.h.
7. Sound
Three routes, cheapest first:
- Stub it. Silent, and the game plays.
- PSG events. If the machine's sound chip is an AY-3-8910 or close, route its register writes to
v_writePSG: the Vectrex has the same chip. - Samples. If the sound is analog or an undumped MCU, record
.wavfiles and play them as digitised samples, or through the UVMC2's jack.
For samples, src/samples.c maps AAE's sample calls onto v_playSample, src/ts_audio.c defines v_sampleData(idx) (the SDK declares it weak, because only the game knows which sample is its laser), and a script converts samples/*.wav into a bundle.
The bundle ships on the SD card, not in the image, and keeps an 8.3 name.
8. The Makefile
UVMC2_KIT ?= $(abspath $(dir $(lastword $(MAKEFILE_LIST)))../..)
AAE_SRC ?= $(UVMC2_KIT)/third_party/aae
UVM2_SDK ?= $(UVMC2_KIT)/sdk/uvm2-sdk
VPY_C_SDK ?= $(UVMC2_KIT)/sdk/vpy-c
PITREX_INC ?= $(UVMC2_KIT)/sdk/pitrex-sim/include
CFLAGS = -DROMZIP_NO_STDIO -DMZ80_IDLE_SKIP -mthumb -mcpu=cortex-m33 \
-mfloat-abi=soft -ffreestanding -O3 -std=gnu11 \
-ffunction-sections -fdata-sections -DVPY_RP2350 \
-include src/aae_compat.h \
-Iinclude -Isrc -I$(AAE_SRC) -I$(VPY_C_SDK)/include -I$(PITREX_INC)
AAE_SRCS = $(AAE_SRC)/aae_romload.c $(AAE_SRC)/aae_romload_gi.c \
$(AAE_SRC)/romzip.c $(AAE_SRC)/puff.c \
$(AAE_SRC)/SegaG80.c $(AAE_SRC)/SegaG80snd.c \
$(AAE_SRC)/mz80/mz80.c $(AAE_SRC)/cpuintrf.c \
$(AAE_SRC)/cpu_control.c $(AAE_SRC)/rand.c $(AAE_SRC)/acommon.c
LOCAL_SRCS = src/main.c src/libc_stub.c src/aae_stubs.c src/aae_machine.c \
src/samples.c src/ts_audio.c
UVM2_NAME = aae_tacscan
UVM2_SRCS = $(LOCAL_SRCS) $(AAE_SRCS)
UVM2_CFLAGS = $(CFLAGS)
UVM2_HZ ?= 0
include $(UVM2_SDK)/uvm2.mk-std=gnu11 because AAE is K&R C; -O3 because the interpreter is CPU-bound. UVM2_HZ ?= 0 and uvm2_set_refresh(40) don't conflict: the build value is the SDK's pacing cap, and the runtime call declares the board's own rate. Tac/Scan needs free pacing so samples can be injected. libc_stub.c is dropped automatically on the .um2 path because the pico-sdk brings newlib.
Flags worth knowing
| flag | what it buys |
|---|---|
-DMZ80_IDLE_SKIP | the Z80 idle-spin skip. Stays on. |
-DAAE_DISPATCH_NOINLINE | about 15 KB smaller, noticeably slower. Set it via UVM2_UM2_ONLY. |
-DAAE_RD_BASE | banked ROM reads without a call: 13.7% of emulation time in one port. |
-DAAE_VEC_RAM=4 | drops an 8 KB vector RAM only Quantum uses. |
-DAAE_VEC_COLORS=64 | shrinks a 12 KB palette of which about 17 entries are used. |
-DAAE_ROM_STAGE=N | the staging buffer, only for interleave or ROM_CONTINUE. |
-DAAE_ROM_STAGE_PSRAM=addr | puts that staging buffer in PSRAM. |
-DAAE_ACCESS_COUNT | host only: how often and where the emulated CPU touches memory. |
-DAAE_WATCH_READ | host only: which emulated PC reads or writes a given address. |
Bring-up order
Do these in order; each makes the next one debuggable.
make host: does the emulated CPU run, and does anything get drawn? The harness hashes every coordinate, because a draw count alone proves nothing (a frozen screen keeps its count).make host-prof: where does the emulated CPU spend its time, and is there an idle spin? Check that the draw hash is unchanged by the skip.make uvm2: does it link and fit? Aregion RAM overflowed by N bytesis the moment for the size flags above.- A dumped frame through
uvm2_list_count: how many commands does a real frame cost? See Host tools. - On the console, with
droppedin view (the HUD shows it). If it's not zero, stop and raise the capacity: nothing you see is evidence until it is.
Three failures that look like something else
- A black screen. Check the romset first (
aae_rom_last_error), thendropped, then whetheruvm2_clock_calibrate()returns 0, which means the console is off or the cartridge isn't seated. - Controls do nothing. Almost always
getportpolarity or a mask in the driver, not the SDK. - The game runs too fast or too slow. The refresh rate is the game speed. Pin
uvm2_set_refresh()to the board's real rate.
What the kit doesn't ship
third_party/aae/ contains only what Tac/Scan needs, about 52 files. In particular, the CPU cores other than Z80 are headers only. Porting a 6502 game (most Atari vector games), a 6809 game or a 68000 game means bringing that core's sources and its driver in from AAE. The structure doesn't change; the file list does.