Kether static recomp

Kether static recomp is a Windows port of Kether for the Philips CD-i that translates the game's 68000 program into C.

README

Kether: CD-i static recompilation

A native Windows build of Kether (Philips CD-i, USA disc, English/French). The game's 68000 program (cdi_appl) is translated ahead of time into C and runs on the CD-i runtime below.

Installing a release

Download from Releases. You need your own copy of the disc as an image (Kether (USA) (En,Fr).bin and its .cue, raw 2352-byte sectors); it is not included.

  • Kether-<version>-Setup.exe: installs for the current user, without administrator rights (default %USERPROFILE%\Games\Kether), and asks for the folder that holds the disc image. The location goes into kether.ini.
  • Kether-<version>-portable.zip: unzip anywhere and run. Put the disc image in rom\, or pick it when the game asks.

Windows 10 or later, 64-bit. Nothing else needs installing: the C runtime is linked in, the shaders are compiled into the executables, and Direct3D 11 is part of Windows.

Portable. Everything the game writes stays in its own folder: saves, quicksaves, settings and the log in saves\, the disc location and update setting in kether.ini. Nothing goes to AppData or the registry (apart from the installer's uninstall entry). The game is always portable; the shortcuts pass --portable anyway. To move the game, move the folder.

Disc location. The game looks for the disc image in this order: the path given on the command line, rom= in kether.ini (a folder, a .cue or a .bin), then rom\ next to the executable or the working directory. If none has it, a file dialog asks for it and remembers the answer in kether.ini.

Updates. At start the game asks GitHub for the latest release. When it is newer, it offers to download the portable zip, closes, copies the new files over its folder (keeping saves\ and kether.ini) and starts again. kether.ini: update_check=1 asks first (default), auto installs without asking, 0 turns it off. --no-update skips it for one run; --version prints the version.

Making a release. package.bat builds both editions and writes dist\Kether-<version>-portable.zip, dist\Kether-<version>-Setup.exe (needs Inno Setup 6: winget install JRSoftware.InnoSetup) and dist\SHA256SUMS.txt. The version comes from VERSION; package.bat -NoBuild packages the current executables. The installer script is installer\kether.iss. The disc image is never packaged.

Building and playing

The repository has the tools and runtime, not the translated game code: build.bat generates gen\ from your disc image.

  1. Put Kether (USA) (En,Fr).bin / .cue in rom/.
  2. Run build.bat (needs Python 3, CMake 3.19+ and Visual Studio 2022).
  3. Run kether.exe (original picture) or kether_hd.exe (Cartoon HD upscaling). Both builds have the same game and the same gameplay features; they differ only in the picture filters.
KeyAction
Arrow keys (Shift = faster)move the pointer / steer the ship
Z, Space, Abutton 1 (select, fire)
X, Ctrl, Sbutton 2
C, Enterboth buttons
F2quicksave to the current slot
F3next quicksave slot (0-9)
F4load the current slot
F5invincibility on/off
F6run-ahead: 1, 2, 3 frames, off
F760 fps sprite interpolation on/off
Tab (hold)fast forward
F9 / F10 / F11 (or Alt+Enter)aspect ratio / filter / fullscreen
Escquit

An Xbox controller works too (left stick or d-pad, A = button 1, B = button 2). Saves (the game's NVRAM file), quicksaves (quicksave_N.sav), settings and logs go to saves\ next to the executable. The window title shows the filter, run-ahead setting and current slot. Hotkeys confirm with a message at the bottom of the picture.

Player features
  • Quicksaves: ten slots holding a snapshot of the whole machine (CPU, RAM, OS, CD, video, audio and the game's NVRAM file), kept in saves\quicksave_0.sav..._9.sav (about 0.9 MB each), so they survive restarts. Loading restores the exact moment: playback continues bit for bit as it did after the save. Saves made with a build whose state layout differs are refused ("incompatible save"), not loaded.
  • Invincibility (F5): the player's ship takes no damage and no lives are lost. A failed space segment, such as crashing or losing the ship you chase, is simply replayed, so the game never ends. Enemies and everything else behave normally, and energy refills still work. See runtime/cheat.c.
  • Run-ahead (F6, default 1 frame): lower input lag. After each real frame the machine is snapshotted, the next N frames are run with the current input and the last one is shown, then the snapshot is restored. Sound comes from the real frames only. The real timeline is unchanged: a session with run-ahead produces the same audio, bit for bit, as one without.
  • Sprite interpolation (F7, default on): Kether draws its gameplay picture only every 4-7 fields (10-12 frames per second). The runtime looks ahead to the game's next picture (snapshot, hidden fields, restore, so no added delay), finds the objects in both pictures and draws every field in between with each object moved and scaled part of the way, giving 60 fps motion. Details are in runtime/frameinterp.c. Only scenes the object model explains are interpolated: objects over a backdrop, with a steady background, few objects, and most of them found again in the next picture. Menus, maps, maze rooms, films and screens being loaded are shown exactly as the game drew them.
  • Cartoon HD / Cartoon HD+ (kether_hd.exe, F10): GPU upscalers. Cartoon HD follows edges on the game's 384x240 pixel grid and turns staircases into smooth, sharp contours with flat colour regions. Cartoon HD+ also flattens noise inside regions, draws dark ink lines on strong edges and livens the colours. The HD build keeps the original filters on F10 as well, and stores its settings in settings_hd.ini. kether.exe has only the original filters.

Command-line equivalents: --runahead N, --frameinterp 0|1, --invincible, --slot N (current slot), --load-slot N (start from a quicksave), --filter N.

State of the port

Tested headless: boot, Philips and Infogrames logos, title menu, language switch, new game, NVRAM save, the intro film and the first level in flight, and four 40,000-field fuzzed sessions. A scripted 12,000-field session (about 3⅓ minutes, into gameplay) produces bit-identical frames and audio with --interp, which runs everything in the reference interpreter.

What Kether needed beyond the template
  • Disc: every file on the disc, the program included, has file number 1, so cdi_extract.py now skips only .rtf/.rtr streams.

  • Code discovery (tools/analyze.py): Ultra C code is reached mostly through function pointers. The analyzer now also uses

    • every code-relative iref, including the jmp abs.l trampoline table called with jsr d16(a6);
    • lea x(pc),aN ... jsr/jmp (aN);
    • moveq #N,dK / cmp.l dK,d0 and movea.w switch tables;
    • computed jumps into unrolled loops (lea x(pc),aN / adda.w dK,aN / jmp (aN));
    • speculative decoding of gaps and of lea/pea targets. A candidate is accepted only if all of its reachable code decodes cleanly without overlapping known instructions.

    Coverage is 89 KB of the 94.6 KB code area; the rest is tables and strings.

  • Missed code doesn't crash. A jump to an undiscovered address inside the module runs in the interpreter, which hands back at the next translated entry. It is logged as undiscovered code ...; none occur in the tested sessions.

  • Self-modifying code. Kether patches the instructions in one sprite loop (10f38c–10f3a6: it flips move.w/move.b and inserts ext/neg). game.json interp_ranges makes the interpreter run that loop from the live bytes. The runtime compares the code area with what was translated once per field and reports any other change.

  • UCM drawmaps (SS_DM = 0x51): DM_Creat returns a drawmap descriptor in a0, with the pixel data address at +4.

  • CDFM:

    • a PCL with no buffer and unbounded size discards its sectors;
    • SS_Abort ends the play with an error status and code 0x060A, and sends the PCB signal;
    • SS_Pause/SS_Cont are implemented.
  • Graphics cursor: GC_Blnk is 5 and GC_Org is 6.

  • DC_NOP (display control function 0x10): fills a region of a line control table with NOPs. Kether uses it to remove the cockpit from the display program before planet intros. Without it, the cockpit buffer showed as coloured noise, or part of the picture stayed black, while the next cockpit image streamed in.

  • A second self-modifying routine: the maze sprite drawer patches its coordinate loop (10d510-10d52a) to mirror sprites, so that loop is in interp_ranges too. Without that, maze sprites were placed wrongly and the game eventually crashed.

How the player features are built
  • Snapshots (runtime/state.c): translated code always unwinds to the dispatcher when it yields, so between frames the whole machine is the CPU registers, RAM and the runtime modules' state, and each module saves and loads its part. RAM is allocated with Windows write-watch, so a run-ahead snapshot copies only the 4 KB pages written since the last one. To make this possible:
    • a blocking F$Sleep now returns to the dispatcher at each field and continues afterwards;
    • NVRAM files live in memory and are written through to saves\. Run-ahead frames never write to disk, because the real frame repeats the write.
    • F$Time counts emulated time from boot.
  • Frame driver (runtime/frontend.c): runs one field at a time and handles run-ahead, the lookahead for interpolation, hotkeys and on-screen messages. Run-ahead frames skip pixel composition, which has no effect on the machine, so run-ahead 1 costs about 0.6 ms per field.
  • Invincibility uses a new game.json feature, "hooks". A hook is a C function called just before the instruction at an address, in translated and interpreted code alike. Kether's energy routine (10ac26) adds a signed amount to the player's energy (0x141BBE, 0-300, 13-segment bar). The hook drops negative amounts while the cheat is on.

Measured cost per field on this machine (headless, gameplay):

ConfigurationTime per field
Plain3.0 ms
Run-ahead 13.5 ms
Run-ahead 1 + interpolation7.4 ms

A field lasts 16.7 ms.

Known differences
  • The game sets all four audio attenuation paths (including the cross paths) to 0 dB, so left and right are summed. The Philips/Infogrames jingles clip on up to 0.5% of samples; the game's music doesn't.
  • The game builds a 280-line display and starts it 20 lines down. The runtime shows the 240 NTSC lines, as an NTSC player does.
  • Sprite interpolation is a reconstruction. Objects are told apart by colour-table ramp and matched between pictures, so objects of the same material that touch move as one piece. A large object that gets covered stays behind the nearer one until the next picture. Occasionally a small fragment is matched wrongly and shows as a brief speck for a few fields. Palette-cycling lights inside objects update once per game frame in the in-between fields. F7 turns interpolation off.
  • Cartoon HD can bring out colour fringes that are present in the source images (for example dark specks along the edge of the Philips logo).

CD-i static recompilation template

This is a starting point for turning a Philips CD-i game into a native Windows program. The game's 68000 code is translated ahead of time into C. A runtime provides the parts of the CD-i the code expects: the CD-RTOS (OS-9) kernel, the CD file manager, the video decoder (with a Direct3D 11 presenter at any resolution) and the ADPCM audio processor.

It was extracted from the Link: The Faces of Evil project. That build is bit-identical to a pure-interpreter run of the same game, and this template reproduces it exactly.

Starting a new game

  1. Copy this folder to a new project folder.
  2. Put the disc image (.bin + .cue, raw 2352-byte sectors) in rom/.
  3. Edit game.json: set title, exe_name, and the modules to recompile (see below).
  4. Run build.bat. It extracts the program modules from the disc, recompiles them into gen/, builds with MSVC and copies <exe_name>.exe to the project root.
  5. Run the game, and use the debugging workflow below for anything that breaks.

Requirements: Windows 10+, Visual Studio 2022 (C compiler), CMake 3.19+ and Python 3.8+. No third-party libraries are needed.

game.json
{
  "title": "My CD-i Game",
  "exe_name": "mygame",
  "modules": [
    {"file": "auto", "base": "0x100000", "extra_entries": [], "a5_table": "auto"},
    {"file": "cdi_bumper", "base": "0xE0000", "a5_table": "auto"}
  ]
}
  • The first module is the program the player boots. "auto" reads its name from the disc label's application identifier (extracted/_app.txt).
  • Other modules are programs the game loads at run time with F$Load or F$Fork, for example the Philips logo "bumper". Each one is recompiled for its base address, and the runtime places it there. Ranges must not overlap and must stay below 0x140000, where the heap starts. Program modules the game loads that are not listed still run, slowly, in the built-in interpreter.
  • extra_entries are code addresses the analyzer can't discover, as absolute hex values (base + offset). The analyzer already finds:
    • call and branch targets;
    • switch tables;
    • MPW A5 jump tables;
    • Microware move.l #off,dN / jsr (pc,dN.l) calls;
    • F$Icpt handlers;
    • lea/pea function pointers that start with link or movem.
  • a5_table: "auto" detects programs built with Apple MPW-style toolchains, whose calls go through jsr N(a5).
  • interp_ranges: [["0xLO", "0xHI"], ...] lists address ranges (HI exclusive) that always run in the interpreter. Use it for self-modifying code. When the game patches its own code, the runtime logs self-modifying code: X-Y changed with the range to add.
  • hooks: {"0xADDR": "c_function"} calls a native void c_function(void) (defined in the runtime) just before the instruction at ADDR runs, in translated and interpreted code alike. The hook may read and change the 68k registers and memory. Kether uses one for its invincibility cheat (runtime/cheat.c).

To see what the analyzer found, run python tools/analyze.py [module index]. It writes a disassembly with recovered function names, from MacsBug symbols if present, to build/listing_<module>.asm.

Layout

PathContents
tools/cdi_extract.pyGreen Book filesystem reader. Writes program files and _app.txt (--all also dumps the real-time streams).
tools/m68k.pyMC68000 decoder
tools/os9mod.pyOS-9 module header, idata and irefs
tools/analyze.pyCode discovery (recursive descent plus the patterns above)
tools/gen.py68000→C generator, flag liveness, dispatch tables, gen/game_config.h
tools/config.pygame.json loader
tools/gen_tests.pyPer-instruction differential tests
tools/bmp2png.pyConverts frame dumps to PNG
runtime/cpu.cDispatcher and run loop
runtime/interp.c68000 interpreter, for RAM code and the --interp reference mode
runtime/alu.cShifts, division and BCD with exact flags
runtime/os9.cOS-9 kernel: memory, processes (fork/wait/exit), signals, alarms, paths, NVRAM, CSD
runtime/cdfm.cCD file manager: reads, and real-time SS_Play with PCB/PCL/CIL
runtime/video.cUCM display control (FCT/LCT), MCD212-style renderer, cursor, pointer
runtime/audio.cADPCM levels A/B/C, sound maps with gapless SM_Out, attenuation
runtime/render_d3d11.cGPU presenter: aspect modes and sharp/smooth/CRT filters
runtime/host_win32.cWindow, input (keyboard and XInput), waveOut, pacing, scripted and fuzz input
runtime/state.cMachine snapshots: write-watch RAM tracking, run-ahead, quicksave files
runtime/frontend.cFrame driver: run-ahead, interpolation lookahead, hotkeys, on-screen messages
runtime/frameinterp.cSprite interpolation to 60 fps
runtime/cheat.cInvincibility (Kether-specific game.json hook)
test/Instruction differential tester and ALU reference vectors
docs/CDI_NOTES.mdCD-i facts and API codes worked out during the first port

Debugging workflow

  • Find what fails. Unimplemented OS calls and SetStat/GetStat codes are logged to saves/recomp.log. Fatal errors also show a 68k register dump and a stack backtrace with function names.
  • Debug options:
    • --trace / --trace3 log syscalls with callers, signals and disc activity.
    • --headless --frames N --dump K runs fast without a window and writes every Kth frame as a BMP.
    • --script "field:key:dur,..." scripts input (keys 1 2 L R U D).
    • --fuzz SEED drives the game with random input.
    • --wav out.wav records the audio.
    • --watch ADDR[,ADDR...] (hex) logs 16-bit words in RAM whenever they change, once per field. Useful for game variables such as the pointer position.
    • --pcs logs where the CPU is every 30 fields. A game stuck in a wait loop shows up as the same PC.
    • --hash logs a per-field hash of RAM and registers, and writes saves/ramdump.bin at --frames N. Diff two runs to find the first field where they diverge.
    • --load-slot N starts from a quicksave, which is the quickest way to test one scene repeatedly. --ramdump-every N writes the 256 KB data area (0x140000) to saves/ram_<field>.bin every N fields; comparing dumps over time finds game variables (that is how Kether's energy was found).
    • --dump-phase P shifts --dump so that frames line up between runs (for example run-ahead 2 with --dump-phase 2).
    • --screenshot N saves the presented GPU picture at field N, to check filters.
    • --interp-debug logs the interpolator's decisions.
    • Kether only: --debug-state N sets the level state machine's state once (11 = this level's maze, 12 = leaving it, 3 = the space segment). --debug-segment-win counts every finished segment or map as won. Both work through game.json hooks in runtime/cheat.c, and both are meant for runs without run-ahead.
    • Script keys S, O, N, I, F press F2, F4, F3, F5 and F7 at the given field, which lets headless runs test quicksaves.
    • A host crash logs a symbolized stack trace to saves/recomp.log. The Release build writes a .pdb for this.
  • Check the translation. Run the same headless session twice, once with --interp, which executes everything in the interpreter, and compare the frame dumps and WAVs. Headless runs are deterministic: emulated time is counted in instructions, and the clock uses a fixed epoch. Any difference points to a translation bug. Start both runs from the same NVRAM state: delete saves/nvr_* next to the executable you run.
  • Find the first wrong instruction. Use --hash to find the field, then build the lockstep tracer with cmake --build build\cmake --config Release --target game_bt. Run kether_bt.exe --btrace LO,HI (instruction counts, from the --hash log) once normally and once with --interp. Each run writes btrace.txt with PC, instruction count and registers at every block entry. The first differing line, ignoring the flags column, pins down the block.
  • Unit-level checks. python tools/gen_tests.py and python test/gen_alu_vectors.py gener