Zelda: Wand of Gamelon Static Recomp

Zelda: Wand of Gamelon Static Recomp is a Windows port of The Legend of Zelda: Wand of Gamelon that offers an HD renderer with new filters.

README

The Legend of Zelda: The Wand of Gamelon — static recompilation

This project translates the game's Philips CD-i 68000 program modules into C ahead of time and runs them with a native Windows runtime. The runtime emulates the CD-RTOS (OS-9) kernel, CD file manager, video decoder and ADPCM audio processor. The boot module (cdi_zelda) and the disc's cdi_bumper module are recompiled; the original real-time data and audio streams are read from the disc image at runtime.

Build and run

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

  1. Keep your legally obtained .bin and matching .cue in rom/.
  2. Run build.bat. It extracts the program modules to extracted/, generates C sources in gen/, compiles the native executable and copies wand_of_gamelon.exe to this folder.
  3. Run wand_of_gamelon.exe for the original rendering, or wand_of_gamelon_hd.exe for the HD renderer. Either executable can also be launched with a .cue or .bin path as its argument.

The HD executable keeps its settings and NVRAM in saves_hd/, separately from the original executable's saves/. Its F10 filter cycle includes Cartoon HD and Cartoon HD+, which use edge-aware color interpolation when enlarging sprite edges. This is spatial upscaling rather than temporal per-sprite motion interpolation; both builds retain the CD-i's 60 Hz field timing.

Controls
  • Arrow keys: move the pointer; hold Shift to move faster.
  • Z, Space, A, or Enter: CD-i button 1.
  • X, Ctrl, S, or Enter: CD-i button 2.
  • Escape: quit. F9 changes aspect mode, F10 changes the display filter, and F11 toggles fullscreen. Tab enables turbo speed.
  • F5 toggles player-only invincibility, and F6 toggles run-ahead. F2 saves to the selected quicksave slot, F4 restores it, and F3 selects the next of 10 in-memory slots. Snapshots last for the current executable session.
  • XInput controllers are supported; A/B map to buttons 1/2, and pressing Y opens the inventory (the game's Down + B command). The D-pad or left stick moves the pointer.

One-frame run-ahead is enabled by default to reduce input latency. If the game repeatedly misses its 60 Hz frame deadline, it disables run-ahead automatically to preserve smooth playback; press F6 to toggle it manually. Pass --no-runahead to start with it disabled, or --runahead to start enabled. The HD executable's Cartoon filters use more GPU work than the original renderer; F10 cycles through the faster filters if needed.

Portable distribution

Build the executables, then run powershell -ExecutionPolicy Bypass -File packaging\New-PortableZip.ps1 -Version 1.0.0 to create a portable ZIP in dist\. The ZIP contains both executables and the launch/setup/update scripts, but never copies the rom\ folder or any game images. Users extract it, run Install-Portable.bat to select their own .bin or .cue, then launch with Start-Game.bat. Runtime saves and settings are stored next to the executables, not in AppData.

Portable builds update themselves: on startup, each executable runs Update-Portable.ps1, which checks the latest GitHub release of the repository in update-repository.txt (default tools81/zelda-wand-of-gamelon-static-recomp, override with -UpdateRepository owner/repository). If the release tag is newer than version.txt and has a wand-of-gamelon-X.Y.Z-portable.zip asset, the package is installed in place and the game restarts. Pass --no-update to skip the check. Development builds run from the repository have no Update-Portable.ps1 beside them and never update.

The build uses the game-specific module addresses and title in game.json. The ROM image and extracted/build outputs are ignored by Git. The runtime's headless, input scripting, rendering and logging options are described below.

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).

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
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 (1 2 L R U D for controls; S N W I T for save, next slot, restore, invincibility, and run-ahead).
    • --fuzz SEED drives the game with random input.
    • --wav out.wav records the audio.
  • 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.

  • Unit-level checks. python tools/gen_tests.py and python test/gen_alu_vectors.py generate test sources. Then build the insn_test and alu_test targets:

    cmake --build build\cmake --config Release --target insn_test alu_test
    build\cmake\Release\insn_test.exe rom\<image>.bin 200
    build\cmake\Release\alu_test.exe
    
  • Code that jumps into RAM is not an error. It runs in the interpreter. Jumps to undiscovered addresses inside a recompiled module are fatal. Add those addresses to extra_entries, or teach analyze.py the pattern.

Things a new game may need

  • More CD-RTOS calls. The runtime implements what the first game used. See docs/CDI_NOTES.md for what's known and what isn't. The CD-i Full Functional Specification (Green Book, May 1994) documents the calls but gives no numeric function codes. Infer those from the game's library wrappers: look at the d2 values passed with os9 I$SetStt.
  • UCM drawing functions (DM_/DP_/DR_/RG_ and text). These aren't implemented yet because the first game drew everything itself.
  • PAL games. The CSD in os9.c (write_csd) advertises a 525-line NTSC player and the video runs 240 lines at 60 Hz. A 625-line title needs LI="625", 280 lines (SCREEN_LINES in video.c) and FIELD_RATE 50.
  • Full Motion Video (MPEG cartridge) isn't supported.