Native PC recompilation of Demon's Crest (USA), built on snesrecomp. The 65816 game code is translated to C and runs on the framework's authoritative interpreter, while the SNES hardware — PPU, APU/SPC700, DMA/HDMA and cartridge mapping — is emulated. Same model as N64Recomp and similar projects: recompile the CPU, emulate the silicon.
Play with adaptive widescreen, save states, rewind and the shared launcher, using your own legally dumped ROM.
Quick start | Features | Widescreen | Performance | Recording | Known issues | Build | Run
Widescreen gameplay The expanded field keeps the action and the HUD in view. |
Full-stage widescreen Backgrounds, sprites and effects extend into the margins. |
Mode 7 overworld
The flying map fills the widened field, margins included.
You must legally own a copy of the game. No ROM data is distributed with this project, in the repository or in any release. The recompiled C is generated locally from your own copy and is never committed.
The port boots and plays: the Capcom logo, intro, stage gameplay and boss
fights all run from the generated C. The native Widescreen mod is
implemented and validated through its final phase — background margins,
objects and sprites, HUD anchoring, the Mode 7 overworld, the top-band flicker
and save-state round-trips — with the engineering notes and validation summary
in docs/PORTING_NOTES.md. See Known
issues for what playtesting has since turned up, including the
save-state CPU caveat.
Playtesting continues; general gameplay bugs and broader coverage are the remaining work, so treat a full-game playthrough as not yet certified.
These were confirmed by actually playing (and scripting) the game, not only by frame-accuracy checks:
- Boot → play: logo, intro, title, attract and a new game all run.
- Stage gameplay and bosses: the ice cavern, mountains and castle stages run with their bosses, HUD, crest icons and forms intact.
- Widescreen: the widened field really renders stages, margins, HUD anchoring and the Mode 7 overworld as described below.
- Forms: switching gargoyle forms works and each form's sprites play.
- Scripted input: the debug server can drive the game from reset
(
set_controller,run_to_frame), which is how the boss and door tests in the companion port are run. - Recording (new): input can be recorded and replayed frame-exactly, and
frames or a rolling buffer can be written to disk — see
docs/RECORDING.md.
Found while using the runner as a reference for a native port. None of them stop normal play, but they matter to anyone scripting or capturing the game.
- Save states made by older builds lack the execution chunk. The current
build saves and restores the 65816 execution state too (the v5 chunk in
src/game_rtl.c), so a state loads in a fresh process and plays — verified with in-stage states. A state written by a build from before that chunk (the framework also shipssnes_saveload, which serialises the legacyCpu, not the runtime'sCpuState) has no execution state to restore: it falls back to the guest's boot-time scheduler context, so a resumed boss fight from such a legacy state restarts the scene. screenshot/dump_frame_rawcapture the current frame, and frames only advance throughrun_to_frame. A paused runner that is asked for a screenshot without advancing returns the same (stale) picture, which looks like a drifting capture. Pair every capture with a frame advance.run_to_framereturns before the target frame is reached. Clients that read memory immediately after the command see the old frame; pollframeuntil it reports the target.- Widescreen margins: the level geometry stops where the engine stopped
staging. Demon's Crest streams its BG1 tilemaps just in time for the native
256-pixel view and only keeps 0–48 px of world ahead of the camera, so a
widened margin can ask for columns that still hold the previous page — at the
end of the city stage that put the entrance gate (shop area, EXIT sign) on
the exit side. The port measures the staging (the tilemap lives in VRAM and
the engine is its only writer): when most of a margin is unstaged, BG1 is
clamped to the authentic 256 columns for that frame and the margin shows the
layers below it — the parallax city and backdrop, which are real content. The
city itself is not a bug: it is BG2 and the 4:3 frame already shows it at its
edge.
SNESRECOMP_WS_MARGIN_CLAMP=0disables the clamp for A/B captures;SNESRECOMP_WS_EDGE=1opts into an experimental continuation that repeats the last staged tile instead (off by default — it replaces real content). Seedocs/PORTING_NOTES.md. - Boss sprite composition is only correct in the runner. The native port built from these captures had to reassemble boss OAM parts itself and got them wrong for a while; that is a port problem, recorded here so the distinction is clear when comparing screenshots.
| Available | Feature | What it adds |
|---|---|---|
| ☑ | Adaptive widescreen | Native widened field with adaptive, 16:9, 21:9 and 32:9 views. Disabled by default; with it off the game renders at the original 256-pixel width. |
| ☑ | Save states | Ten slots (F1–F10 load, Shift+F1–Shift+F10 save) plus the save-state browser (F11). The current build restores the 65816 execution state (v5 chunk), so states load in a fresh process and play; states written by builds that predate the chunk resume at the boot-time scheduler context — see Known issues. |
| ☑ | Rewind | Rewind history (F12) over the shared framework's snapshot ring. |
| ☑ | Recording | Alt+R starts/stops a take: one BMP per presented frame plus a replayable input_log.jsonl, in captures/rec_<time>/. Needs no debug server; the protocol can additionally replay the pad stream frame-exactly or keep a rolling capture. See docs/RECORDING.md. |
| ☑ | Launcher | recomp-ui ROM picker with a verification badge, display/audio/input settings and the Mods screen. |
| ☑ | ROM verification | The ROM is checked against the project digests before anything boots; a mismatched dump is refused instead of mis-executing. |
| ☑ | Authentic 4:3 | The original 256-pixel field and layer layout remain the default when the widescreen mod is off. |
Enable Widescreen in the launcher's Mods screen and pick a view: Adaptive, 16:9 (342 px), 21:9 (448 px) or 32:9 (682 px). The mod is off by default.
What it does:
- renders the game directly into the widened field (
native_widescreen), so the scrolling backgrounds continue into symmetric margins; - gates widening to stage gameplay (
$8Din 1..0x7F, or the Mode 7 overworld map with its alternate-HUD variant): menus, cutscenes, password and transitional screens stay pillarboxed; - widens the object-loop cull and the OAM emitter so enemies and effects just outside the native view keep updating and appear in the margins, and widens the attack projectile's on-screen test so shots fly across the whole field;
- anchors the BG3 status bar to the widened left edge and leaves the freed columns transparent, showing the scenery where the bar used to sit; BG3 is widened only on the bar's scanlines, so a dialogue box on the same layer keeps its native columns;
- keeps the Start/status menu pillarboxed (
$00FBmarks it): it is a full-screen native UI, and widening it would show the stage tilemap in the margins; - drops the game's forced-blank upload cover from the raster replay in every scene (a field that stays blank keeps its black), which removes the black band that flickered at the top and lets HDMA tables start at the top of the field, keeping window effects aligned with their targets;
- keeps dialogue boxes and edge windows intact: the fixed-color box cannot leak a strip into the top-left corner, and a parked one-pixel layer window moves with the widened edge instead of cutting a one-pixel seam at the margin boundary;
- opens the Mode 7 overworld map to the widened field; the terrain continues into the margins with no renderer change.
The generated-code hooks are applied by
tools/dc_apply_widescreen_hooks.py, an
idempotent injector run by tools/regen.sh and by the build. At margin 0 every
injected expression evaluates to the authentic constant, so the object and
sprite hooks add nothing to the 4:3 path.
The engine stages its BG1 tilemaps for the 256-pixel view and keeps only 0–48 px
of world ahead of the camera, so the margins can hold the previous page where the
engine has not restaged them. The port measures which columns are staged and, when
most of a margin is not, clamps BG1 to the authentic 256 columns for that frame:
the margin then shows the layers below the level geometry — the parallax city and
the backdrop — instead of a foreign gate. Ordinary frames are untouched, and
nothing is invented; at 21:9/32:9 this is what keeps the extreme edges showing
real background. See
docs/PORTING_NOTES.md.
Diagnostic kill-switches for A/B captures:
SNESRECOMP_WS_OAM=0 (object/sprite widening),
SNESRECOMP_WS_HUD=0 (status-bar anchoring),
SNESRECOMP_WS_NOBLANK=0 (upload-cover policy),
SNESRECOMP_WS_CULL=<px> (cap the margin object window; 0 = authentic cull)
and SNESRECOMP_WS_EXTRA=<px> (fixed margin).
-
Clone with the framework and launcher submodules:
git clone --recurse-submodules https://github.com/magnopaz/DemonsCrestSNESRecomp.git cd DemonsCrestSNESRecomp -
Have your own Demon's Crest (USA) ROM available. Keeping it outside the repository is the better habit;
SNESRECOMP_ROMsets the path once for a shell. -
Generate the recompiled C from the ROM:
bash tools/regen.sh --rom "/path/to/Demon's Crest (USA).sfc" -
Configure and build:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j -
Run:
./build/DemonsCrestSNESRecomp
The launcher asks for the ROM on the first run and caches the choice.
On Windows with the MinGW toolchain, tools/play.ps1 wraps regeneration,
configuration, build and launch in one step.
The default desktop backend is SDL3 (fetched and built from source when no
package is installed). To build against an installed SDL2 development package
instead — for example the MinGW one at M:\SDL on Windows:
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DSNESRECOMP_SDL_BACKEND=SDL2 \
-DSDL2_DIR="M:/SDL/SDL2-2.32.6/x86_64-w64-mingw32/lib/cmake/SDL2"tools/play.ps1 automates regen + configure + build + launch for the Windows
MinGW toolchain (CMake, Ninja, GCC, Rust GNU, SDL2 under M:\SDL). It refuses
to build while a game instance is running (a forced kill would end that
session with no exit report); pass -KillRunning to terminate it explicitly.
The ROM does not have to live in the repository — SNESRECOMP_ROM sets the
path for a shell. Without it, tools/regen.sh looks for
Demon's Crest (USA).sfc at the repo root, then for the path the setup wizard
recorded in rom.cfg; .gitignore blocks both from ever being committed.
tools/regen.sh verifies the ROM, generates src/gen/*.c, and re-syncs
recomp/funcs.h. Re-run it whenever you change anything under recomp/.
The desktop build defaults to the framework's execution policy off — the
faithful LLE floor is what runs, by design, until an optimized path clears the
framework's promotion gates (snesrecomp/docs/PERFORMANCE.md,
recomp-ai-rules/OPTIMIZATION.md). A release that has promoted paths sets
-DSNESRECOMP_EXECUTION_DEFAULT=on; SNESRECOMP_EXECUTION_MODE and
SNESRECOMP_FORCE_FLOOR=1 override it at runtime.
On Windows the MinGW link prints
.rsrc merge failure: multiple non-default manifests: binutils merges the
UTF-8 manifest snesrecomp/runner/windows/snesrecomp_utf8.manifest with the
linker's default one. The explicit manifest is embedded (the built executable
contains activeCodePage=UTF-8) and the host reports its code page at startup,
so this is link noise, not a missing manifest.
The port's invariants run headlessly against the built executable:
cmake --build build -j
cd build && ctest --output-on-failurectest -L unit needs no ROM (the generated-code injector: synthetic anchors,
idempotency, drift rejection, plus --check against src/gen).
ctest -L integration boots the game, checks that a widened field keeps its
authentic center bit-identical to 4:3, that two runs are deterministic, and —
with a save state — that the far-right staging repair touches only the columns
the engine has not staged. Integration tests report SKIP when no ROM is
available and the repair test also wants SNESRECOMP_TEST_SAVE. See
docs/TESTING.md.
./build/DemonsCrestSNESRecomp
# or name the ROM and skip the launcher
./build/DemonsCrestSNESRecomp "/path/to/Demon's Crest (USA).sfc"With no ROM on the command line the build opens the recomp-ui launcher: a ROM
picker with a verification badge, display/audio/input settings and the Mods
screen. Your choice is cached in rom.cfg beside the executable, so the next
launch opens on it and "Skip launcher on boot" makes it immediate. If the
launcher is not built in (--no-recomp-ui), the host falls back to a native
file picker.
Either way the ROM is checked against the digests below before anything boots — a dump that could not have produced this build is refused at the door rather than mis-executing ten frames in.
During play: F11 opens the save-state browser and F12 opens rewind; both
pause gameplay while you choose. Alt+R starts/stops a recording (frames plus
a replayable input log, saved under captures/). Hotkeys, controller mappings
and pixel/aspect options live in the launcher; Alt+R can be moved with
Record = <key> in config.ini's [KeyMap].
The debug server can capture a session and reproduce it, which is how the screenshots above and the port's reference captures are made:
# keep the last ~5 seconds in memory and write them when the window closes
SNESREC_RING_DIR=captures/somulo ./build/DemonsCrestSNESRecomp "Demon's Crest (USA).sfc"
# or, with SNESRECOMP_DEBUG_PORT set, from a debug client
record_input_start
... play the fight ...
record_input_stop captures/fight.jsonl
replay_input captures/fight.jsonl # reproduce it frame-exactly
record_frames captures/somulo 600 1 # one BMP per frame
python tools/bmp_sequence.py captures/somulo --gif somulo.gifInput is sampled where the runner reads the pad, so file frame N is the input
frame N ran with. See docs/RECORDING.md for every command
and its limits.
Environment switches: SNESREC_AUTORECORD=1 starts a take on the first guest
frame (so it is replayable from boot), SNESREC_RECORD_DIR picks the take
directory, SNESREC_RING_DIR / SNESREC_RING=1 arm the rolling buffer, and
SNESREC_INPUT_ONLY=1 records the input log without per-frame BMPs — the last
one is a small local patch to the framework host (see
docs/RECORDING.md).
| File | Demon's Crest (USA).sfc |
| Publisher | Capcom |
| Developer | Capcom |
| Year | 1994 |
| Mapping | lorom |
| Region | USA (North America) |
| Coprocessor | none |
| CRC32 | e8236ad2 |
| SHA-256 | 18d40a807d5f88c5b6a1ad849eec7e0f189225d9a1586037c850f6680b5844de |
tools/regen.sh refuses to run against anything else, so a mismatched revision
fails immediately instead of producing subtly wrong output.
The 65816 CPU code from the ROM is statically translated to C — every function
the analysis can prove is a real generated C function in src/gen/. Execution
is LLE-first: an authoritative 65816 interpreter is the correctness floor,
and the statically compiled bodies are exact, proven materializations on top of
it — anything the static pass cannot prove keeps running through the
interpreter, loudly. The rest of the SNES is not recompiled: PPU rendering, the
APU/SPC700, DMA/HDMA, hardware register I/O and bank mapping run through
snesrecomp's runner implementations.
Rules for contributors:
- Never edit
src/gen/by hand and never synthesise a result to get past uncovered code. Fix the config or the framework and regenerate. - Name routines in
recomp/symbols.toml, then re-runtools/regen.sh. Setemit = trueto promote a routine into ahead-of-time analysis; leave it false to keep it interpreted. Only proven variants become AOT code. Seerecomp/README.md. - Deal with unresolved indirect dispatch targets before anything else — they are the reason a port diverges.
A recompilation removes the cost of interpreting the 65816; it does not make the console free. This port keeps the framework's LLE-first execution model (see How the recompilation works), so every frame still pays for three different things: guest code execution (generated bodies plus everything the analysis left on the interpreter), the emulated PPU and APU/SPC700, and the host's composition and presentation. The measurements below attribute a frame to those buckets, and explain why a mature SNES emulator can still use less CPU on the same machine — and why that is the expected shape of this port rather than a defect.
The runner carries its own attribution, so no rebuild is needed:
cd build
# throughput config: unpaced, audio off, default PPU tier, SDL's own presenter.
# Keep it in its own file and re-create it before every A/B run -- the host
# writes the active config back on exit.
cat > perf-config.ini <<'EOF'
[General]
DisableFrameDelay = 1
Autosave = 0
[Graphics]
WindowScale = 3
Renderer = auto
NewRenderer = 1
[Sound]
EnableAudio = 0
EOF
printf 'loadstate 0\nwait 900\nquit\n' > perf-script.txt
cp saves/lvl_0_murkland-colosseum.sav saves/save0.sav
SDL_VIDEODRIVER=offscreen SDL_AUDIODRIVER=dummy \
SNESRECOMP_FRAME_TIMING="$PWD/perf-timing.csv" \
./DemonsCrestSNESRecomp --no-launcher --config perf-config.ini \
--script perf-script.txt "/path/to/Demon's Crest (USA).sfc"(The working directory also needs mods/preloaded for the widescreen variant;
the build directory already has it.)
SNESRECOMP_FRAME_TIMING writes one row per presented frame
(guest, raster_ms, acquire_ms, compose_ms, present_ms, trace_ms, hook_ms, events_ms, wait_ms); SNESRECOMP_HOST_PROFILE=1 prints the same stage means at
exit. Three practical traps: the host rewrites the active config.ini on
exit, so a variant whose axis lives in that file silently reverts unless you
re-create it per run; Renderer = opengl (the native GL presenter) cannot
initialise headless — use auto or software; and SDL_VIDEODRIVER=dummy has
no render driver at all (the host stops at renderer init FAILED), so headless
means offscreen.
Sample run: Raspberry Pi 4 (aarch64, Ubuntu 24.04, 4× Cortex-A72), Release
build, SDL2 backend, no window, from the gameplay save state
lvl_0_murkland-colosseum.sav, 902 frames, pacing disabled, 4:3, audio off.
Means over the 892 frames that follow the state load.
| Stage | ms/frame | Share | What it is |
|---|---|---|---|
guest |
8.36 | 75% | running the machine: 65816 execution (generated + interpreted), APU stepping, bus and cycle accounting |
raster-capture |
1.51 | 13% | drawing the frame from PPU/VRAM state and capturing it for presentation |
compose |
0.26 | 2% | extra composition (widescreen/blend staging) |
present |
1.02 | 9% | texture upload and present |
acquire / events / others |
0.05 | 0% | surface and event pumping |
| total | 11.20 | 100% | ≈ 89 fps uncapped, one core saturated |
Two things about that table are easy to misread:
guestis not "the PPU". It is the simulated machine minus the draw. The pixel work sits inraster-capture, and it is cheap here: re-running the same scene withSNESRECOMP_LAYER_MASK=0(which keeps no layer at all, so no tile or sprite pixel is fetched or written) drops that stage from 1.51 to 1.00 ms/frame and the whole frame by only 4% — 89.3 → 93.0 fps. This title is bound by executing code, the APU and the bus, not by drawing.presentis where the presentation backend shows up, and it is the only stage whose cost depends on how the frame reaches the window.
Same scene, same host, one axis changed at a time:
| Variant | ms/frame | fps | Delta | Where it lands |
|---|---|---|---|---|
| baseline (above) | 11.20 | 89.3 | — | — |
| audio enabled (32 kHz stereo) | 11.31 | 88.4 | +1% | guest +0.1, present flat |
| widescreen 16:9 | 11.73 | 85.2 | +5% | raster-capture +0.19, compose +0.12, present +0.22 |
| widescreen + audio | 11.79 | 84.8 | +6% | additive, still small |
NewRenderer = 0 (reference PPU) |
17.07 | 58.6 | +53% | raster-capture 1.51 → 7.30 (4.9×) |
Renderer = software |
20.03 | 49.9 | +79% | present 1.02 → 9.77 (9.5×) |
SNESRECOMP_LAYER_MASK=0 (no layer drawn) |
10.75 | 93.0 | −4% | raster-capture 1.51 → 1.00 |
WindowScale = 1 instead of 3 |
11.23 | 89.0 | 0% | the 3× scale is free in this path |
So the two expensive switches are the ones users can flip in the launcher, not the enhancement: the software presentation backend costs almost as much as simulating the whole console, and the legacy PPU tier costs more than everything else combined. Widescreen and audio are in the noise by comparison.
- Only the CPU was recompiled. PPU rendering, APU/SPC700 and DSP synthesis,
DMA/HDMA and hardware register I/O are emulated by the framework. That work is
architecturally the same work an emulator does; a recompilation has no way to
remove it. On this title it is ~25% of the frame (
raster-capture,compose,present), and insideguestthe APU and bus accounting. - The guest tier is LLE-first by design. The interpreter is the correctness
floor and the scheduler/main loop runs on it; generated bodies are entered
through the dispatch table wherever the analysis proved a variant, and anything
indirect or unproven stays interpreted. Proving more of the game (entries in
recomp/symbols.toml, resolved indirect targets) moves time from interpreted to native execution — that is the one lever a recompilation actually owns, and it is deliberately gated on proof rather than on speed. - The interpreter boundary is not free. Every native↔interp transfer re-establishes the guest stack contract, settles DRAM refresh/cycle debt and re-checks width variants. A byte-for-byte-faithful 65816 running a scheduler in this shape costs more than an emulator's tight computed-goto loop that owns the whole machine and can ignore the boundary.
- A mature emulator is very hard to beat at its own game. Snes9x has decades of tuning on exactly this model: per-scanline rendering straight into the texture with no staging step, no widescreen shadow to maintain, no diagnostic or state rings, no recompilation ABI, and no reason to keep LLE paths around for correctness investigations. This port pays for all of those, and it also renders through a composition + upload stage so that enhancement policies can exist at all.
- This title is a light one. ~89 fps uncapped on a 4-core ARM SBC means the frame is ~11 ms on a CPU that is several times slower per thread than a modern desktop. The port is nowhere near CPU-bound at 60 fps.
Task-manager CPU percentage at 60 fps is a poor metric: the loop is paced, the
audio device and the graphics driver own threads of their own, and time spent
waiting for vsync is indistinguishable from time spent emulating. A paced run of
the same scene here took 15.05 s wall for 902 frames (59.9 fps) but 11.98 s of
CPU — the frame cost is unchanged, the remainder is pacing slack. When a number
needs to mean something, use the stage CSV above (or SNESRECOMP_HOST_PROFILE=1)
rather than the percentage.
Nothing above is a promise that the port is as fast as it can get. The
framework's own burn-down (snesrecomp/docs/PERFORMANCE.md) records the levers
it measures and retains, and
HLE_DEFAULTS.md records the one that has
already been qualified on another title: composing each frame in cached host
memory and uploading it once — LLE → HLE frame service — took Mega Man X from
341 to 890 uncapped fps with 67% less process CPU on Windows x64. This port pins
the framework's execution policy to the faithful floor (off) and keeps the LLE
frame service on non-Windows targets, so those gains are available but not
taken: promoting them needs the framework's correctness gates plus a gameplay
pass, exactly like any other optimisation in this tree.
| Path | What lives there |
|---|---|
recomp/ |
Analysis input: bank*.cfg, symbols.toml, generated funcs.h |
rom_identity.txt |
ROM digests — read by the build, tools/regen.sh and CI |
src/ |
Host code you own: main.c, game_rtl.c, widescreen policy |
src/gen/ |
Generated C. Never committed — regenerate locally |
docs/ |
PORTING_NOTES.md — verified state, fixes and roadmap |
mods/ |
Preloaded mod catalog (the widescreen package) |
tools/ |
regen.sh, play.ps1 and the widescreen code injector |
snesrecomp/ |
Framework submodule (owns lib/recomp-net, lib/retcomm-rbengine) |
recomp-ui/ |
Shared launcher UI submodule |
scripts/ |
package_release.sh — player-facing zip |
framework_pins.txt |
Exact framework commits this project was scaffolded against |
Open an issue with the build, the enabled mods and the steps to reproduce the
problem. For gameplay bugs, attach the save state (build/saves/saveN.sav)
and describe the inputs that trigger it. If the game closes unexpectedly,
also attach last_run_report.prev.json (the previous run's report, preserved
on the next launch) and any crash_report_*.json / crash_minidump_*.dmp
from the build directory.
github.com/magnopaz/DemonsCrestSNESRecomp/issues
This project's own source is under the MIT license in LICENSE.
The framework and launcher carry their own terms — see snesrecomp/LICENSE,
snesrecomp/THIRD_PARTY_ATTRIBUTION.md and recomp-ui/LICENSE. Neither
covers the game data, which is not distributed here.





