02 · Foundations

Hardware & OS Platform

The Plug & Play unit is a quad-core Cortex-A7 set-top board running vendor Linux. The game treats it as a handful of services (two evdev devices, an EGL display, an OpenAL/ALSA audio stack, and a video decoder) and nothing else.

2.1The box itself: Allwinner R16

The unit is built around the Allwinner R16, the same quad-core Cortex-A7 SoC family used in set-top boxes, with a Mali-400/450 MP GPU, the Allwinner CedarX hardware video engine, and a vendor Linux 3.4.113 BSP (kernel uImage dated Dec 2017, load address 0x40008000). On the device, a launcher script runs the game as cd /root/ && ./dgf, which is why the binary's data path is the relative ./Data and the movie path literal is /root/Data/….

Consequences visible inside the binary:

LayerOriginal unitDesktop port (used for this report's captures)
SoCAllwinner R16, 4× Cortex-A7x86-64 host emulating ARM (qemu-user)
GPUMali-450 MP blobMesa llvmpipe (software GLES2)
KernelLinux 3.4.113 (Allwinner BSP)Linux 6.12 (Debian 13)
VideoCedarX + libXPlayer14-symbol stub (movies self-complete)
AudioOpenAL-soft 1.17.2 + ALSAstock OpenAL-soft + ALSA null device
Inputreal evdev: event1 brake + power latch, event2 masconfaked evdev driven by the keyboard

2.2What the game links against

DT_NEEDED entries group cleanly into three classes; only the third required replacement to run anywhere else:

GroupLibrariesNotes
Portable standardlibc.so.6, libm.so.6, libstdc++.so.6, libgcc_s.so.1, libpthread.so.0, libz.so.1, libbsd.so.0native everywhere
Audiolibopenal.so.1, libasound.so.2OpenAL for effects/voices; ALSA PCM underneath (used both directly and as OpenAL backend)
Hardware-specificlibEGL.so, libGLESv2.so → libMali.soGLES2-only; no desktop GL calls anywhere in the import table
Hardware-specificlibxplayer.so, libcdx_*.so, libcdc_*.so, libadecoder.soCedarX stack: 12 imported XPlayer* functions + MemAdapterGetOpsS, GetConfigParamterInt; the rest only need to exist as files

The OS surface is equally small: open/close/read/write/ioctl (no mmap), pthread_*/sem_*, tcgetattr/tcsetattr, gettimeofday, usleep, statvfs, sync.

2.3Threads, fibers, and the frame task

The game has one true render thread (Chapter 12) and otherwise runs everything as cooperative fibers: coroutines that voluntarily yield inside a single-threaded scheduler. On top of that sit two fixed tasks that tick the frame and dispatch the major game processes.

The permanent frame task: motherTask

/* task 0 — the 60 Hz frame boundary. Spawns trainMain as task 100. */
void motherTask(void) {
    ttStartTask(100, trainMain, 0, 0x10000, 0x40);
    _SysCount = 0;
    do {
        _DrawFrameCount = 0;
        _PacketMakeEnd = 1;
        WinSwapDB();            // display-buffer swap
        _PacketMakeEnd = 0;
        ttReadPad();            // poll controller bitmaps
        ReadControl();          // lever/detent translation
        setGameKey();           // derive menu/play key masks
        DenTouchJustState();    // rising-edge ("just pressed") accumulators
        _SysCount++;
        DALModelDrawStart();    // kick off this frame's draw-packet submission
        ttDelayTask(1);         // yield until next tick
    } while (true);
}
Exhibit: motherTask @ 0x1ffa18. Everything per-frame branches from here: input → game logic → draw packets.

The process dispatcher: trainMain and procman

Like a tiny operating system with three programs: menu, driving game, and ending. Each is a task (ID 99) started from a table of {stack_size, task_fn, cleanup_fn} records; a one-line setNextProc(id) requests a switch that takes effect at the next frame boundary.

/* task 100 — top-level process dispatcher (0x213a50) */
do {
    if (procman == 0) {
        // start requested process as task 99 from procfunc[] table
        if (funcTable[nextProc] != NULL)
            ttStartTask(99, funcTable[nextProc], 0, stackSizeTable[nextProc], 0x41);
    } else {
        ttStopTask(99, 0);                 // stop current process task
        if (cleanupTable[procman]) cleanupTable[procman]();  // MenuEnd/GameEnd/EndingEnd
        if (nextProc != 0) goto start;     // a new request is already pending
    }
    procman = nextProc;                    // commit the switch
    nextProc = 0;
    do { ttDelayTask(1); } while (nextProc == 0);   // idle until next request
} while (true);
procIDTaskCleanupStackNotes
0——4096idle sentinel
1testProcess 0x2118e4—4096debug harness (train viewer + grade-stage test); unreachable via menus
2GameTask 0x1e718cGameEnd 0x1e756024576the driving scene — 6× the stack of any other process
3MenuTask 0x1ef81cMenuEnd 0x1ef6284096initial process at boot
4EndingTask 0x1b7e78EndingEnd 0x1b7e2c4096ending credits/results
procfunc table @ 0x006167f4: 12-byte records, all five entries live-read from the running process.

Canonical state globals (live-memory confirmed): procman @ 0x6f0408 (current), 0x6f040c (requested next), 0x6f0410 (previous), 0x6f0414 (mirror). A pending transition = 0x6f040c nonzero; commit = the mirror snapping to procman.

procman state diagram: MenuTask, GameTask, EndingTask with setNextProc requests
The process system. setNextProc(2) from the menu fade-out hands control to GameTask; the game hands back on game-over or quit. Cleanup functions free each process's assets between switches.

Cooperative fiber scheduler

Aqualead's scheduler (ALSystemImp::UpdateAll @ 0x226a08) walks a priority list of update objects every frame. Most run synchronously; objects flagged as needing their own context get a real fiber (ALUpdaterImp::SwitchToFiber, an actual context switch between 256-byte-stack fibers). Task start/stop/sleep requests are queued as 24-byte commands and drained once at the frame boundary by SetAfterUpdateFunc → DALUpdaterExecCommand; handles are generation-validated (ttCountTask checks index/generation), so a stale handle can never touch a recycled task.

Command record DGF_DALUpdaterCommand (24 bytes)
+0x00pointer to TaskList[task_id] (base 0x6c8760 + id*4)
+0x04direct updater pointer / reserved
+0x08updater callback (start writes generic UpdateFunc @ 0x198d3c)
+0x0crequested task callback
+0x10priority, computed as float(priority)/2 + 64.0
+0x14opcode: 0=start · 1=destroy · 2=sleep · 3=awake

2.4Input: two levers, one power latch

The controller is ordinary Linux keyboard bits. The game opens the two evdev nodes and, once per frame, issues a single ioctl per device to fetch the whole 96-byte key bitmap; it never read()s events:

/* per frame, from ReadControl → UpdatePnPCon */
ioctl(_MasconFd /* /dev/input/event2 */, EVIOCGKEY(96) /* 0x80604518 */, &keybitmap);
ioctl(_BrakeFd  /* /dev/input/event1 */, EVIOCGKEY(96), &keybitmap);

Each detent of each lever is simply one held key:

Notch detent (event2)evdev keycode
N (neutral)KEY_011
P1KEY_12
P2KEY_23
P3KEY_34
P4KEY_45
P5KEY_56
Brake detent (event1)evdev keycode
releaseKEY_B48
B1 … B8KEY_C … KEY_J46…36
EB (emergency)KEY_K37

Results land in _PnPNotch ∈ {0…5} and _PnPBrake ∈ {0…9}; lookup tables CSWTCH.4565 @ 0x4250d0 (notch) and CSWTCH.4567 @ 0x4250f8 (brake). Bitmap decode is byte[keycode/8] & (1<<(keycode%8)), computed with the ARM sign-bit trick (int)(byte << (31-(keycode%8))) < 0.

The power latch rides on the brake device A third fd, _PSWFd, is opened on the same /dev/input/event1 and polled every frame by PollPSW (@ 0x1984bc): if bit 0x10 (Linux KEY_POWER (116)) is not held, the game calls Terminate() and exits. On hardware, the power switch reads as KEY_POWER held while the unit is on. Any reimplementation or automation must hold that bit or the game kills itself within a frame.

The “Den” controller subsystem

Beneath the levers lives the original arcade-lineage controller stack: three USB-HID families (dencon, shincon, rjcon) with LED and motor feedback (DenOnLED, DenOnMotor, dual-motor ShinCon_Motor1ON/OFF…). In the shipped ARM build the upper layer is live code but pinned to constants: IsDencon() unconditionally returns 2 (native detent mode), DenconSetControllType() ignores its argument and writes 0, and the raw transport initializer UsbDenconInit is a severed infinite empty loop with zero callers. The per-frame read path is live though: ReadControl unconditionally polls UsbDenconGetInfo/UsbDenconRead, which simply report “nothing attached”. Controller type feeds setNochBrake's dispatch (Chapter 7).

Menu pad buttons

ALPadImpUnix::ProcessDeviceImpl (@ 0x395078) polls the same evdev nodes for the cabinet's direction/decide buttons and translates them into the game's bitmask. The key→mask map (as verified through the port shim's asserts):

Linux keyALPad bitGame mask
KEY_UP / DOWN0x0001 / 0x00020x1000 / 0x4000
KEY_RIGHT / LEFT0x0004 / 0x00080x2000 / 0x8000
KEY_X / Z / A / S0x0010…0x00800x0020 / 0x0040 / 0x0080 / 0x0010
KEY_ENTER0x01000x0800 (+0x0020 from KEY_X — the “decide” combination)
KEY_SPACE0x02000x0100

Menu navigation reads getMenuKey(n) (buffer mnkey @ 0x6d5580); gameplay reads getPlayKey()/getPlayKey_just() (plkey @ 0x6d5574, plkey_just @ 0x6d5578).

2.5Audio backends

Sound is stock OpenAL (sources, buffers, streaming queues) over ALSA PCM. The game-side logic (a 24-channel voice pool, a playlist manager and the engine-sound synthesizer) is inherited from the PS2 era and is covered in Chapter 13. One platform fact matters for reimplementation: several gameplay gates poll audio completion (e.g. departure waits on PcmCheck()), so a stubbed or broken audio backend deadlocks the game rather than merely muting it.

2.6Video: the CedarX XPlayer stack

24 MP4 assets (opening movies, route intros, endings) play through Allwinner's XPlayer API: XPlayerCreate → XPlayerSetNotifyCallback → XPlayerPrepareAsync → XPlayerStart, with the game looping while (IsPlayVideo()) ALYield();. Completion arrives via callback messages (MEDIA_PREPARED, PLAYBACK_COMPLETE). The game uploads decoded frames as a GL texture (XPlayerSetVideoSurfaceTexture), so a reimplementation can swap in ffmpeg-backed decoding without touching game logic. The port's 14-symbol stub simply fires the callbacks after ~300 ms, making every movie self-complete (movies are skipped, not shown).

2.7Save & options plumbing

The save registry lives at /var/lib/save/SaveReg.xml (path set by ALStreamImp::SetBasePathMain("/var/lib/save", 2)). One string, SaveData, holds exactly 0x245c Base64 characters decoding to a 0x1b44-byte payload: options (0x4c), a continue snapshot (0x36c), and player progression (0x178c). No checksum, no encryption (full field map in Chapter 9). initOptionData() zeroes the autosave flag at optiondata+0x48, so autosave is off until enabled in Options → Save/Load; progression does not survive a restart on a fresh unit until the player turns it on.

2.8What the platform carries but never uses

The binary ships a surprising amount of dead weight, all verifiable by xref counts:

Practical consequence A reimplementation does not need fibers, draw packets, or the class hierarchy as mechanisms; it needs their behavior: a 60 Hz loop with cooperative phases, a render thread, and GLES2-equivalent output. Chapter 15 turns this into a concrete build order.
← 01 · The Binary