Hardware & OS Platform
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:
libEGL.so/libGLESv2.soare symlinks to the Mali blob: the game is a GLES2 client and nothing more (its GLES1 symlinks exist but are unused).- Video goes through CedarX's player abstraction (
XPlayer/AwPlayer), not ffmpeg. - The Allwinner display-engine node
/dev/dispexists in the platform, though the game's own path is EGL-on-fbdev.
| Layer | Original unit | Desktop port (used for this report's captures) |
|---|---|---|
| SoC | Allwinner R16, 4× Cortex-A7 | x86-64 host emulating ARM (qemu-user) |
| GPU | Mali-450 MP blob | Mesa llvmpipe (software GLES2) |
| Kernel | Linux 3.4.113 (Allwinner BSP) | Linux 6.12 (Debian 13) |
| Video | CedarX + libXPlayer | 14-symbol stub (movies self-complete) |
| Audio | OpenAL-soft 1.17.2 + ALSA | stock OpenAL-soft + ALSA null device |
| Input | real evdev: event1 brake + power latch, event2 mascon | faked 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:
| Group | Libraries | Notes |
|---|---|---|
| Portable standard | libc.so.6, libm.so.6, libstdc++.so.6, libgcc_s.so.1, libpthread.so.0, libz.so.1, libbsd.so.0 | native everywhere |
| Audio | libopenal.so.1, libasound.so.2 | OpenAL for effects/voices; ALSA PCM underneath (used both directly and as OpenAL backend) |
| Hardware-specific | libEGL.so, libGLESv2.so → libMali.so | GLES2-only; no desktop GL calls anywhere in the import table |
| Hardware-specific | libxplayer.so, libcdx_*.so, libcdc_*.so, libadecoder.so | CedarX 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);
}
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);
| procID | Task | Cleanup | Stack | Notes |
|---|---|---|---|---|
| 0 | — | — | 4096 | idle sentinel |
| 1 | testProcess 0x2118e4 | — | 4096 | debug harness (train viewer + grade-stage test); unreachable via menus |
| 2 | GameTask 0x1e718c | GameEnd 0x1e7560 | 24576 | the driving scene — 6× the stack of any other process |
| 3 | MenuTask 0x1ef81c | MenuEnd 0x1ef628 | 4096 | initial process at boot |
| 4 | EndingTask 0x1b7e78 | EndingEnd 0x1b7e2c | 4096 | ending credits/results |
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.
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) | |
|---|---|
| +0x00 | pointer to TaskList[task_id] (base 0x6c8760 + id*4) |
| +0x04 | direct updater pointer / reserved |
| +0x08 | updater callback (start writes generic UpdateFunc @ 0x198d3c) |
| +0x0c | requested task callback |
| +0x10 | priority, computed as float(priority)/2 + 64.0 |
| +0x14 | opcode: 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 key | code |
|---|---|---|
| N (neutral) | KEY_0 | 11 |
| P1 | KEY_1 | 2 |
| P2 | KEY_2 | 3 |
| P3 | KEY_3 | 4 |
| P4 | KEY_4 | 5 |
| P5 | KEY_5 | 6 |
| Brake detent (event1) | evdev key | code |
|---|---|---|
| release | KEY_B | 48 |
| B1 … B8 | KEY_C … KEY_J | 46…36 |
| EB (emergency) | KEY_K | 37 |
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.
_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 key | ALPad bit | Game mask |
|---|---|---|
| KEY_UP / DOWN | 0x0001 / 0x0002 | 0x1000 / 0x4000 |
| KEY_RIGHT / LEFT | 0x0004 / 0x0008 | 0x2000 / 0x8000 |
| KEY_X / Z / A / S | 0x0010…0x0080 | 0x0020 / 0x0040 / 0x0080 / 0x0010 |
| KEY_ENTER | 0x0100 | 0x0800 (+0x0020 from KEY_X — the “decide” combination) |
| KEY_SPACE | 0x0200 | 0x0100 |
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:
- Aqualead's higher-level OOP frameworks —
ALCollision3D,ALWidgetBase,ALFsm,ALParticleEmitter,ALTileMap: compiled in (vtables exist) but never instantiated by game logic. - Embedded scripting — Lua 5.1.5 is linked in;
ALScriptImp::CreateFiberis a stub returning 0; all Lua call sites live in unrelated engine-internal code (0x37xxxx). - PS2-era leftovers — legacy fog computation paths (
FA/FB), a PS2 GS-style packet layer (WinAdd*,ScreenEffect*tables), and thewriteMCardmemory-card serialization statics; all unreferenced by the live path. - Debug harnesses —
mapViewer3(free-drive train previewer) andtestProcess(procID 1): reachable only by hand-pokingprocman.