OutRunners - Native Port

OutRunners - Native Port is a native PC port of the arcade game OutRunners, built on its decompilation, allowing for features like two-player split-screen and network play.

README

OutRunners — port

Sega's 1992 arcade game OutRunners (Sega System Multi 32) as a native game for computers, the Nintendo Switch, Android and Anbernic's handhelds, built on its decompilation, outrunners-decomp: the game's program decompiled into C and checked frame by frame against MAME in picture and sound. Two games come out of the same C:

  • OutRunners Arcade: the arcade game as it is, both cabinet screens side by side, the same frame by frame as the reference.
  • OutRunners: the console version, the arcade game made for playing at home. The game's functions that console/replaced.txt lists have the console's own versions (console/); the rest is the game's.

This repository contains no game data. As with n64decomp/sm64, you bring your own ROM set (MAME's orunners, World), and the game takes its assets out of it.

The title and its menu, in 16:9: ARCADE, 2 PLAYERS, NETWORK, & KNUCKLES, OPTIONS, EXIT A race at the pre-stage's fork: WEST or EAST, in 16:9 NETWORK: a room with two devices, SEBAS and NICO, and the host's START & KNUCKLES > REVERSE: the goal picked on the route map OPTIONS over the title's opening
2 PLAYERS: both seats in one race, side by side

Playing it

The releases hold a package for Windows, Linux and macOS (an .app for Apple silicon and Intel), with both games and the tools that make the devices' games. Keep orunners.zip beside the game, or drop it on its window when it asks: the first time it starts, it takes the assets out of it (a few seconds) and keeps them. README.txt in the package says what each tool needs.

  • Nintendo Switch: OutRunners NSP makes OutRunners.nsp with your ROM set and your console's keys, for a console with Atmosphere (ports/switch/README.md).
  • Android: OutRunners APK makes OutRunners.apk with your ROM set, signed with a key of yours (ports/android/README.md).
  • Anbernic's H700 handhelds (RG35XX, RG40XX and their kin): OutRunners Anbernic makes the folder for PORTS (ports/anbernic/README.md).

What any tool makes is yours alone, with the game's data in it: never share it.

OutRunners, the console version

  • One screen, the left seat's, and one sound; free play, no coins. The SEGA presentation (the title's SEGA, dark, until three cars race across it), then the title with PRESS START.
  • The title's menu:
    • ARCADE: the arcade's game, one player.
    • 2 PLAYERS: both seats in one race, their screens side by side or one over the other; on a tablet or an open Fold, the two screens facing each other, each player with their own touch controls.
    • NETWORK: rooms of up to four devices on the local network (below).
    • & KNUCKLES: CLASSIC (the arcade's game as it is, the right seat's START joins it), TIME ATTACK (no time limit, the fastest run raced again as a see-through ghost), FULL (all 31 courses in one race), REVERSE (from a goal picked on the route map back to the start).
    • OPTIONS: CONTROLS (keys and game controller buttons for each player), DIFFICULTY, TIME (by stage), VIDEO (the window's size, full screen, PIXELS sharp, smooth, bilinear or CRT, WIDE off, 16:9 or 20:9, 2 PLAYERS' layout), VOLUME, SOUND TEST.
  • The pause in a race (Escape, Enter or a game controller's Start): CONTINUE, QUIT GAME.
  • The keys: the arrows steer, accelerate and brake; Q and A shift up and down; space is the radio, [ and ] its songs; Enter starts and pauses. A game controller: the d-pad or the left stick steers, A accelerates, B brakes, the shoulders shift. OPTIONS > CONTROLS changes them.
  • Android's touch controls: the wheel, GAS and BRAKE, the gears' buttons with the manual transmission, START and the radio. On a tall screen (an open Fold, a tablet) the picture goes to the top and the controls under it, over the game's own route map following the car.

NETWORK

Up to four devices race together, each one of the arcade's linked cabinets (two seats each: NETWORK > PLAYERS 2 where the device has a second player), on the same local network: a house's Wi-Fi, or a phone's hotspot. Computers, phones and the Switch play together.

  1. NETWORK > NAME: your name, as the rooms show it.
  2. One device picks HOST: its room waits for the others, showing each one that joins with its name.
  3. The others pick JOIN: the rooms found on the network, and the one to join.
  4. The host's START (two devices or more) starts an ARCADE race of all of them: each one picks a car, and the race is the game's own, its entries, checkpoints, forks and times as the arcade's linked cabinets have them. After the race, back to the room.

A device that stops being heard holds the race for a moment (WAIT); after 3 seconds it is out of it, as a car out of time, and the race goes on without it. If the one gone hosted the race or the room, the next device hosts it on, from where it was. The game uses UDP: the rooms' beacons on port 27720, and a port of the system's choosing for the race; a firewall that asks about the game should let it reach the private network.

How it works: each device sends its two race records to every other one each frame, as the cabinets' link board would, and the game reads them as it reads the board's (console/network.c, with the sockets in console/net_udp.c). orunners_console_headless --real-time runs two games on one computer for the checks (tests/console/network_*.txt).

Building the port

The window games build as the rest of this repository does ("Getting started" below): cmake --build makes OutRunners Arcade (target orunners) and OutRunners (target outrunners) beside the tools in build/bin/. orunners_console_headless runs the console version without a window, from scripts of its menu's actions (--actions, tests/console/). The devices' builds and their tools are in ports/switch/, ports/android/ and ports/anbernic/, each with its README; console/PLAN.md holds the console version's plan and what is done.

The rest of this README is the decompilation's, which this repository carries (outrunners-decomp).

Status

How much of the C reads as C written by hand, measured on every run of python tools/noise_report.py (clean): 602 of the 762 functions (79.0%) and 26,144 of their 26,759 lines of code (97.7%) hold nothing of what the table under "What is left, and why" counts. Some of that the C has to keep to do what the original does: R12 and R14 where a value goes from one object's handler to the next through them, several fields moved in one access as the original's one instruction moves them, a byte or halfword of a value changed with the rest of it kept, as the original's byte and halfword instructions change it, and the memory the memory test, the checksum and the copy reach by its address, whatever is there (MEMORY_ROUTINES in tools/game_profile.py, each with why). Without counting those (faithful), all 762 functions and all 26,759 lines of code are clean, the goal before the ports.

Apart from all that, the C keeps the game's state where the original had it, laid out as the board's work RAM (one structure, struct game, which the code reaches through game): structures reached by casting an address, addresses of the work RAM, of code and of tables used as values, links within a 64 KB page, memory moved and filled through its address (the memory model's row of the table under "What is left, and why"). 252 of the game's 696 functions (36.2%) and 20,271 of their 22,869 lines of code (88.6%) touch none of it; taking the rest out is the next phase.

  • ROM set verification (MAME set orunners, World), by CRC32 and SHA-1 of every file
  • Multi 32 board model in C (runtime/): memory map, interrupt controller and timers, the two I/O chips, EEPROM, the A/D converter of the wheels and pedals, and the link board with no other cabinet on the line
  • Reference run of the original program on MAME's V70 core against that model (s32ref), with MAME's Multi 32 video drawing both screens: it boots, runs the attract mode and plays a scripted game, the same frame by frame from one run to the next
  • The sound board in the reference: the sound program on MAME's Z80, with ymfm's YM3438 (which the program uses only for its timer) and MAME's MultiPCM, which plays the music and the effects (s32ref --sound-wav), the same sample for sample from one run to the next
  • Scenarios (tests/controls/): the attract mode; a game until GAME OVER; races to all ten goals of the map, the race time held by a poke (race_*, two_players, and the route_* that tools/make_routes.py writes, turning the wheel before the forks a route takes); a name typed into the entry; every page of the test mode and its 122 sounds. Between them they execute 16,103 addresses (python tools/check_scenarios.py --reference-only); the game has no continue
  • Recursive disassembly without traces (tools/disassemble.py, from symbols.txt): the vectors, the tables of code addresses next to the jumps that read them, the object handlers (stored in field 4, from lists, tables of lists and the streams of objects along each stage's road, which fork with the route) and a timeline: 19,708 instructions in 640 functions, no invalid or overlapping paths, and all 16,103 addresses the scenarios execute among them
  • Coverage (tools/coverage.py): every byte of the code chips is code, data that the code or the tables symbols.txt describes point at, or fill; none is unknown. To find the last of it, s32ref --rom-reads FILE lists what a run reads of the ROM as data, by instruction. The code and the tables nothing uses are in symbols.txt too (its last section): 52 functions nothing calls are decompiled with the rest; 1,039 bytes of code are left out of the C, the reset vector's jump and 14 places of dead code that jump or call into the middle of functions the game runs, whose C would cut those functions in two
  • Translation scaffold (tools/recompile.py): the program translated into C one instruction at a time, 615 functions that the V60 runtime (runtime/src/v60.c) runs a frame at a time. orunners_headless matches the reference frame by frame in all 16 scenarios. The game runs two tasks, each on a stack of its own, and its VBLANK handler swaps them; their C runs on native stacks of their own (runtime/src/tasks.c)
  • Decompiler, first pass (tools/decompile.py): the program decompiled into C, in src/: 671 functions with no goto, which the game's runtime (runtime/src/game.c) runs a frame at a time. orunners_game_headless matches the reference frame by frame in all 16 scenarios, and tools/check_structure.py finds the C's ifs, loops and switches doing what the program's jumps do in every function, the ones no scenario runs included (and in the sound program's 60, with --sound). The VBLANK handler asks the runtime to swap the tasks (swap_tasks), and a function that returns for its caller too says so (left_caller). The registers the whole program shares are globals, a copy for each task: current_object, player_car and screen (named by symbols.txt), and R12 and R14, scratch registers whose last value the next function may read: globals named after them only where a value goes into or out of a function, and locals with names of their own elsewhere (716 uses of the globals, which tools/noise_report.py counts, 932 before; R27 and R28 are locals: 1,495 uses with them). The game's structures are typed (tools/core/decompiler/pointers.py, with tools/game_profile.py's structures): the objects (0x100 bytes, 124 in the page of each screen's block), the screen's car (0x200 bytes at 1000 of the block), the course's ring of segments (128 of 0x20 bytes from 2000 of the block) and the block of the screen a task runs (SCREEN_1, SCREEN_2), read as OBJECT(p)->field, CAR(p)->field, ROAD_SEGMENT(p)->field and SCREEN(p)->field, each player's race record (RACE(p), which symbols.txt declares with the fields that point at it) and the objects' area below each screen's block (OBJECT_AREA(screen - 0x8000u): the lists of the objects, their slots); what the code reaches by its own address in the screens' blocks and the race records reads as their fields (SCREEN(SCREEN_1)->game_mode, RACE(RACE_COPY_1)->entry_frames), an object whose handler is set to a family's function as that family's, and the variables' arrays by element (time_difficulty[1], video_registers[16]). The objects' code is named and commented, family by family (the scenery and traffic along the road, the player's car and the race display, the radio and its DJ, the title, attract and select screens, the route map, the name entry), with each family's own fields, and so is the rest of the game's code: the boot and the two tasks' frame loops, the game's modes and steps, the race's logic and the link between cabinets, the sprite lists and the board's services (sound requests, palettes, text, the EEPROM, coins and bookkeeping), the test mode and the interrupt hooks, with the work RAM variables and screen fields they use; the code nothing that runs reaches is named and commented as well ("Unused:"). The sound program is named in sound_symbols.txt: one driver for both screens, each with its page of commands, eight sequence players and a queue of MIDI messages, played on 24 MultiPCM voices and the YM3438's six channels, and the engine and tyre noise the game drives through the page (the game's side of the shared RAM has the same names). Every parameter, result and local variable is named by what it holds (symbols.txt's param, result and local lines: 2,100 were named after a register and 565 values the decompiler works out once were numbered), and so are the values a string move fills with; where a partial write chains two values of a register into one variable, its name says both (model_then_animation). Every variable has a name, and every field but 11 uses of fields still named after their offset (tools/noise_report.py, unk_fields; 79 before): the bytes of two families' arrays, which their start clears as a word, a halfword and two bytes (the car radio's car_models, the checkpoint board's row_steps: a family's field cannot be an array yet), and fields of objects of two families (the choice flags car_select_entries_run starts) or of any (start_object_list's number in its list)
  • The memory the code reaches through pointers, typed: a record of the work RAM, of the program ROM or of the board's plain memories (the video RAM, the sprite RAM, the RAM the sound program shares) a local, a parameter or a field points at is a structure, so mem_read16(projected_segment + 4u) reads PROJECTED_SEGMENT(projected_segment)->x (each screen's ring of views and the road as it projects it, the sprites' depth lists and the sprite lists' entries, the text layer's cells, the cars' hit boxes, riders and sprays, the crash tables, the courses' starts, the coin chutes, the seats' credits and inputs, the lamps, the settings' copies, the bookkeeping and the course records the sound program's RAM keeps, the palettes' and the packed tiles' lists, the text records); a pointer that walks values of one width reads NAME(p)[i] (OBJECT_WORDS(object_bytes)[1] = 0u), an array inside a record by a number the code works out reads SCREEN(screen)->places[player], and an unrolled copy or clear is the loop it does. The board's registers have names: a variable in its plain memories (MACHINE_ID_TEXT_CELL, NBG0_SCROLL_X[layer]), a device's register read and written by name or by the address the code works out, through the bus (io_read8(IO_1_CNT), io_write16(board_records, ...): 371, board_registers, not noise). 150 reads and writes still go through a computed address (tools/noise_report.py, mem_access; 2,877 before this was typed): see "What is left, and why". tools/memory_census.py says what each one reaches over the scenarios, with the game built with ORUNNERS_MEMORY_CENSUS
  • The game's own video and sound: runtime/src/video.c draws both screens; the sound program, decompiled into C (src/sound/, tools/decompile_sound.py from sound_symbols.txt), runs on the board's time with the YM3438 (runtime/src/ym3438.c) and the MultiPCM (runtime/src/multipcm.c, the same as MAME's sample for sample). orunners_game_headless matches the reference frame by frame in state, picture and sound in all 16 scenarios (python tools/check_scenarios.py --video --sound). Left out of the comparisons: the V70's and the Z80's stacks, which the C keeps in no memory, and the copies the test mode's RAM tests leave of them in the sprite RAM (runtime/include/s32/machine.h)
  • A window port (orunners, ports/sdl2/): both screens, the sound, the keyboard and game controllers; a control script played in the window gives the reference's frames (coin_start: state, picture and sound)
  • The assets (extract_assets.py, driven by assets.json, which holds positions only), each with a name, in formats the usual tools open: the tiles as 37 tilesets by subject (the road, each course's backgrounds, the route map, the title...: tilesets.txt), the sprites as 264 sheets by subject (cars/<car>/, scenery/<course>/, hud/, traffic/...: sprite_sheets.txt), the MultiPCM's samples as 219 WAV files by what plays them (pcm_samples.txt) with the 512 sample headers as text, the music and the sound effects as 324 MIDI files, and the program's and the sound program's tables as text records with their fields' names and addresses written as the names of what they reach. python extract_assets.py --check builds the 17 chips back from them, byte for byte (the code from the set)
  • The game is not anchored to the ROM: it loads those files themselves when it starts (runtime/src/assets.c, assets_media.c, assets_midi.c), every piece in a block of memory of its own, with no image of the ROM, no region the size of a chip, no mirrors, banks or interleaving. The C reads a table by its name (game_mode_jumps[mode]: a pointer the loader sets, src/table.c's game_tables); an address the game keeps in its variables reaches its data through the assets' index (data_at); the video draws tiles from the tilesets by their numbers and sprites from the sheets by their places, the MultiPCM plays the WAV files, the sound program reads its tables and rebuilds its sequences from the MIDI files. What the loader reads is the ROM's data byte for byte (the program's 818 pieces and the sound program's 396), and the 16 scenarios are identical in state, picture and sound. The reference and the translation scaffold read the ROM set when they start, as MAME does, and the memory test's ROM part is the boa