PSX
A work-in-progress PlayStation 1 emulator written in pure Ruby.
It boots the SCPH1001 BIOS through the PlayStation license screen
into real retail games. Rage Racer loads through its intro FMV and
title screen into the in-engine attract / race scene; Ridge Racer
hands off from the BIOS to its own kernel. .bin/.cue discs are read
through a real CD-ROM state machine — no patching. MDEC decodes through
Phase 3 (RLC + dequant + IDCT + YCbCr). Save states (F5/F6/F8 in the
SDL window) round-trip the full machine state. SPU audio mixes ADPCM
voices plus CDDA/CD-XA through the SPU path with basic reverb.
There's no PGXP, ADSR isn't conformance-grade, DMA timing is still
approximate in places, and many retail games hit content-specific
blockers (FMV codec init, timing-sensitive CD paths), so don't expect
every disc to play yet. Think of it as an executable spec for the PS1.
472/472 unit tests pass, plus 18/21 of the
JaCzekanski/ps1-tests cases
with a psx.log reference and the CD-ROM ps1-tests baseline at 3/3.
Install
gem install psxYou will also need SDL2 on your system (the gem depends on ruby-sdl2):
- macOS:
brew install sdl2 - Debian / Ubuntu:
sudo apt install libsdl2-dev - Arch:
sudo pacman -S sdl2
Usage
You must supply your own BIOS ROM (typically SCPH1001.BIN, 512 KB). PSX
does not ship one — BIOS images are copyrighted.
psx path/to/SCPH1001.BIN # BIOS shell only
psx path/to/SCPH1001.BIN game.cue # with a disc inserted
psx --fast-boot path/to/SCPH1001.BIN homebrew.cue # boot the disc's PS-EXE directly--fast-boot skips the BIOS shell UI and reads SYSTEM.CNF from the
disc's ISO9660 filesystem. Retail discs still use the BIOS bootstrap path
when the license check can complete; synthetic / homebrew discs fall back
to loading the listed PS-EXE after BIOS kernel init.
Run psx --help for the option list. Keyboard controls (slot 1 digital pad):
| Key | Button |
|---|---|
| Arrow keys | D-pad |
| Z | Cross |
| X | Circle |
| A | Square |
| S | Triangle |
| Enter | Start |
| Space | Select |
| Q / W | L1 / R1 |
| E / R | L2 / R2 |
| Tab | Open the in-window settings overlay |
| F5 | Quicksave to tmp/quicksave.psxstate
|
| F6 | Debug snapshot: state + screenshot (PSX_DEBUG_SNAPSHOT prefix) |
| F8 | Quickload from tmp/quicksave.psxstate
|
| Escape | Quit (or cancel a rebind / close the overlay) |
The BIOS shell idles on the Sony logo until you press Triangle, which opens the Memory Card / CD-ROM menu.
Configuration
Settings live at ~/.config/psx/config.yml (override with PSX_CONFIG=path
or XDG_CONFIG_HOME=...). A first run with no file uses built-in defaults;
the file is created on the first rebind in the overlay.
# ~/.config/psx/config.yml
target_fps: 60
frameskip: true
quicksave_path: tmp/quicksave.psxstate
debug_snapshot_prefix: tmp/debug-snapshot
keys:
cross: Z
circle: X
square: A
triangle: S
start: Return
select: Space
l1: Q
r1: W
l2: E
r2: R
up: Up
down: Down
left: Left
right: RightKey names are SDL scancode constants — letters A..Z, 0..9, Up /
Down / Left / Right, Return, Space, Tab, LShift, LCtrl,
function keys F1..F12, etc. Tab, Escape, and the F5 / F6 / F8
slots are reserved for the front-end.
The in-window overlay (press Tab) shows every binding and current
path. Use ↑ / ↓ to highlight a button, Enter to start a rebind, then
press the new key — it writes back to the config file immediately and
takes effect on the next keystroke. Esc cancels a rebind or closes
the overlay. The text overlay needs sdl2_ttf and a system monospace
font; without those, a dimmed banner appears and the rebind machinery
still works, but the per-row list isn't drawn. On macOS, brew install sdl2_ttf is enough. Override the font with PSX_FONT=/path/to/font.ttf.
PSX_QUICKSAVE and PSX_DEBUG_SNAPSHOT env vars still override the
config's path defaults for F5 / F6 / F8 in a single run.
The SDL front-end defaults to the portable SDL texture presenter. For
experiments, PSX_DISPLAY_RENDERER=gl uses an OpenGL presenter for the final
frame upload and scaling:
PSX_DISPLAY_RENDERER=gl psx path/to/SCPH1001.BIN game.cueThis does not replace the emulated PSX GPU rasterizer yet; it only makes the host GPU handle the final RGBA frame presentation. If OpenGL is unavailable, the front-end falls back to SDL.
Programmatic use
require "psx"
# BIOS shell only
emu = PSX::Emulator.new("SCPH1001.BIN")
emu.run(steps: 300_000_000)
emu.controller_state_proc = -> { 0xEFFF } # press Triangle
emu.run(steps: 30_000_000)
emu.save_screenshot("menu.ppm")
# Fast-boot a PS-EXE from a CD image
emu = PSX::Emulator.new("SCPH1001.BIN", disc_path: "homebrew.cue")
emu.fast_boot_from_disc # loads the PS-EXE listed in SYSTEM.CNF
emu.run(steps: 50_000_000)The emulator exposes the major components individually: emu.cpu,
emu.memory, emu.gpu, emu.dma, emu.cdrom, emu.sio0, emu.interrupts,
emu.timers, emu.spu, emu.mdec. Save/restore the whole machine with
emu.save_state("scene.psxstate") / PSX::Emulator.from_state(path, bios:, disc:).
Development
git clone https://github.com/khasinski/psx
cd psx
bundle install
bundle exec rake testThe bin/ directory contains development tools that are not shipped with
the gem.
Runners and test harnesses:
| Script | Purpose |
|---|---|
bin/psx-ruby |
Headless boot of the BIOS (no SDL window). |
bin/psx-test |
Run a PS-EXE on top of the BIOS, diff against ps1-tests reference logs. |
bin/psx-conformance |
Run the amidog CPU/CPx/GPU/GTE conformance tests, tally per-subtest pass/fail. |
bin/psx-amidog-smoke |
Boot every amidog test, check it reaches its startup banner. |
bin/build-test-disc |
Wrap a PS-EXE in a minimal MODE2/2352 disc image (needs mkisofs). |
bin/fetch-amidog-tests |
Download and unpack the amidog test suite into .tests-amidog/. |
bin/fetch-redux-tests |
Sparse-clone PCSX-Redux's src/mips/ into .tests-redux/. |
bin/_ps1tests-baseline |
Run the JaCzekanski ps1-tests baseline and print pass/fail tally. |
Tracing and inspection:
| Script | Purpose |
|---|---|
bin/psx-trace |
PC histogram + I_STAT/I_MASK summary at checkpoints. |
bin/psx-disasm |
Disassemble a range of physical addresses after N cycles of boot. |
bin/psx-biostrace |
Tally A/B/C jump-table calls; dump the last N calls. |
bin/psx-puts-trace |
Capture the kernel's debug TTY (puts2, printf, …). |
bin/psx-memwatch |
Log reads/writes to specific addresses with the PC that did them. |
bin/psx-dumpmem |
Hex-dump a range of memory after N cycles of boot. |
bin/psx-smoke |
Boot the BIOS and dump PPM screenshots at intervals. |
Benchmarks, save states, and game-specific drivers:
| Script | Purpose |
|---|---|
bin/_psx-bench |
Headless throughput bench (cycles/sec) on the BIOS boot path. |
bin/_psx-state-bench |
Bench forward from a .psxstate snapshot (skip BIOS-boot replay). |
bin/_psx-makestate |
Build a .psxstate snapshot from a fresh BIOS-boot + N cycles. |
bin/_psx-bootmap |
Flame graph + ASCII histogram of where time goes during BIOS boot. |
bin/_psx-profile |
stackprof a BIOS-boot run, flat report. |
bin/_psx-gtebench |
Synthetic GTE micro-bench in a tight Ruby loop. |
bin/_psx-gteprofile |
stackprof the GTE bench. |
bin/_psx-exe-display |
Boot BIOS then load a PS-EXE into the SDL window (for visual GPU tests). |
bin/_gte-timing-shot |
Render amidog psxtest_gte long enough for the OFFICIAL.TIMING block. |
bin/_ridge-boot |
Headless boot of Ridge Racer (EU) with periodic PPM screenshots. |
Running test suites
Three test corpora are wired in; all live under .tests*/ (gitignored).
JaCzekanski/ps1-tests — pass/fail style, with reference psx.log files.
git clone https://github.com/JaCzekanski/ps1-tests.git .tests
bundle exec ruby bin/psx-test -e .tests/cpu/cop/psx.log .tests/cpu/cop/cop.exeamidog test programs (https://psx.amidog.se) — CPU/GPU/GTE conformance
plus a few weird-edge demos. Several ship as proper MODE2/2352 .bin/.cue
discs with real Sony license sectors, so they exercise the CD-boot path.
bundle exec ruby bin/fetch-amidog-tests # one-time download
bundle exec ruby bin/psx-amidog-smoke # boot-and-banner checkPCSX-Redux (https://github.com/grumpycoders/pcsx-redux) — source-only.
Sparse-clones src/mips/ (OpenBIOS, in-tree tests, demos). Building the
PS-EXEs needs a MIPS cross-toolchain — see src/mips/README.md inside the
clone for instructions. Useful as a reference for hardware behaviour even
without building.
bundle exec ruby bin/fetch-redux-testsStatus
What works:
- MIPS R3000A CPU with delay slots, exceptions, COP0, branch-target handling, load-delay slots
- GTE (passes
gte/test-all, nocash per-opcode timing through Timer 2 sysclock; some coverage gaps) - GPU: GP0 polygons / lines / rectangles, textured / shaded / semi-transparent primitives, CPU↔VRAM blits, mask bit. Software rasteriser, no PGXP.
- DMA channels for OTC, GPU (block / linked list), SPU, CD-ROM, MDEC in/out
- Interrupt controller, root counters (Timer 0/1/2 with sysclock / dotclock / hblank sources)
- CD-ROM controller backed by
.bin/.cueimages, with SetLoc / ReadN / ReadS / Pause / SeekL / GetlocL / GetTN / GetTD / GetID / Init / SetMode (includingwhole_sectorbit-5 mode for CD-XA reads) and cycle-paced INT1 sector delivery - ISO9660 reader for SYSTEM.CNF + PS-EXE extraction
- BIOS shell boots real retail discs end-to-end via the license check,
no patching required (
--fast-bootis still available to jump straight to the disc's PS-EXE) - SIO0 digital pad (slot 1)
- SPU: register window read-back, RAM DMA/FIFO transfers, IRQ9 RAM checks, ADPCM block decode, Gaussian voice interpolation, basic voice stepping/ADSR, basic pitch modulation, fixed/current volume registers, basic volume sweep, noise voices, basic DuckStation/Mednafen-style reverb work-buffer processing, SPUCNT mute, capture buffers, CDDA/CD-XA queue mixing, and stereo PCM output. Full ADSR conformance and full audio conformance are still open.
- MDEC: register surface (Phase 1), quant + IDCT tables and DMA 0/1 ingress / egress (Phase 2), real RLC + dequant + IDCT + YCbCr → RGB decoder (Phase 3). DMA1 completion IRQ wired. CD-XA sectors decode and feed the SPU CD-audio path.
- Save states: F5 / F8 in the SDL window quicksave / quickload, F6
debug snapshot (state + screenshot together), full machine
round-trip via
Marshal(CPU + GTE + COP0 + RAM + GPU VRAM + SPU + MDEC + CD-ROM + timers + interrupts). - Symmetric upper-RAM mirror (matches real hardware — writes to
0x80200000+alias back to the populated 2 MB chip). - Bus-error on instruction fetch from forbidden regions (scratchpad, IRQ, MDEC, timers, JOY/SIO); fetch from DMA / SPU / GPU register space goes through (matches real hardware)
- 472/472 unit tests pass.
- JaCzekanski/ps1-tests baseline passes for the checked non-CD-ROM
cases (
TOTAL 18 OK 18 FAIL 0). CD-ROM ps1-tests are run separately with a disc image viaPSX_TEST_DISCand currently pass (TOTAL 3 OK 3 FAIL 0). - Game compatibility: Rage Racer (Europe and NTSC) boots through the
intro FMV and title screen into the in-engine attract / race scene
in the SDL front-end. Ridge Racer (Europe) hands off from the BIOS
bootstrap loader to its own kernel (
CD_init,ResetGraph,tim_load).
What doesn't:
- SPU audio is incomplete: the reverb path now follows the DuckStation / Mednafen work-buffer structure but still needs broader conformance testing; ADSR is not conformance-grade yet, and noise/pitch modulation still need broader conformance testing.
- DMA timing isn't cycle-accurate. GPU block DMA now keeps CHCR busy for a
DuckStation-style RAM access delay and manual chopping adds a capped CPU
window delay, but
dma/choppingis still not bit-perfect against real hardware. - GPU, CD-ROM, and MDEC timing are still approximate in places even where structural conformance tests pass.
License
MIT. See LICENSE.
