Something went wrong. Try again.
A Playstation One (PS1) emulator fully written in Jam
Something went wrong. Try again.
jamstation main.jam
15 kB · 442 lines
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443// SPDX-License-Identifier: MIT// Copyright (c) 2026 Raphael Amorim
const { Bus, createBus, busRead32, busRead16, busRead8, busWrite32, busWrite16, busWrite8} = import("bus");const { Gpu } = import("gpu");const { createGte } = import("gte");const { Cdrom } = import("cdrom");const { Disc, createDisc, discOpen } = import("disc");const { createDma, dmaTickSpu, dmaUpdate } = import("dma");const { createPad, padUpdate, padSetButtons } = import("pad");const { createSpu, createSpuRam, spuGetSample } = import("spu");const { Sio1 } = import("sio1");const { Mdec } = import("mdec");const { createIrq, irqRaise, IC_VBLANK } = import("irq");const { Mcd, mcdRamLoad, mcdRamSave } = import("mcd");const { Timer } = import("timer");const { freshCpu, freshCpuAt, createRegFile, gprRead, gprWrite, cop0Read, cop0Write, run, step, biosHook, commitLoad, encR, encI, encJ} = import("cpu");const { Sdl, sdlInit, sdlQuit, sdlBlit, sdlPump, sdlDelay, sdlTicks, sdlAudioOpen, sdlAudioClose, sdlAudioRingPush, psone_audio_cb} = import("sdl");const { Vec } = import("std").collections;const { File, exists } = import("std/fs");const { Args, args, next, exit } = import("std").process;const { print } = import("std/fmt");
const Cpu = struct { pc: u32, nextPc: u32, savedPc: u32, hi: u32, lo: u32, loadD: u32, loadV: u32, branch: u8, delaySlot: u8, branchTaken: u8, halted: u8, cycles: u64,};
const C0_SR: u32 = 12;const C0_PRID: u32 = 15;
const VRAM_WIDTH: i32 = 1024;const VRAM_HEIGHT: i32 = 512;const SCREEN_W: i32 = 640;const SCREEN_H: i32 = 480;
const GPU_CYCLES_HDRAW: f32 = 2560.0;const GPU_CYCLES_SCANL: f32 = 3413.0;const SCANLINES_VDRAW: u32 = 240;const SCANLINES_TOTAL: u32 = 263;
const FloatBits = union { i: u32, f: f32 };
// 0-1 horizontal res code (0=256, 1=320, 2=512, 3=640)// 2 vertical res (0=240, 1=480 - but only if bit 5 interlace=1)// 3 PAL/NTSC// 4 24bpp colour depth// 5 interlace enable// 6 horizontal res 2 override (1=368)fn dmodeWidth(dm: u32) i32 { if ((dm & 0x40) != 0) { return 368; } const hr: u32 = dm & 3; if (hr == 0) { return 256; } if (hr == 1) { return 320; } if (hr == 2) { return 512; } return 640;}
fn dmodeHeight(dm: u32, gpuState: *mut[] u32) i32 { if ((dm & 0x04) != 0 && (dm & 0x20) != 0) { return 480; } if ((dm & 0x04) != 0) { return 480; } const y1: i32 = gpuState[67] as i32; const y2: i32 = gpuState[68] as i32; const disp: i32 = y2 - y1; if (disp > 0 && disp < 239) { return disp; } return 240;}
fn readU32At(buf: *mut[] u8, off: u32) u32 { return (buf[off] as u32) | ((buf[off + 1] as u32) << 8) | ((buf[off + 2] as u32) << 16) | ((buf[off + 3] as u32) << 24);}
fn loadExe(path: []u8, bus: Bus, regs: *mut[] u32, c: mut Cpu) { match (File.open(path)) { Some(f) { var hdr: [2048]u8 = [0; 2048]; var hp: *mut[] u8 = hdr.asMutPtr(); const hdrBytes: u64 = f.read(hp[0..0x800]); if (hdrBytes < 0x800) { f.close(); print("loadExe: header read failed\n"); return; }
const initPc: u32 = readU32At(hdr.asMutPtr(), 0x10); const initGp: u32 = readU32At(hdr.asMutPtr(), 0x14); const ramDest: u32 = readU32At(hdr.asMutPtr(), 0x18); const fileSz: u32 = readU32At(hdr.asMutPtr(), 0x1C); const spBase: u32 = readU32At(hdr.asMutPtr(), 0x30); const spOff: u32 = readU32At(hdr.asMutPtr(), 0x34);
// Body is runtime-sized. Read into a // Vec(u8), then stream byte-by-byte into RAM. var body: Vec(u8) = Vec(u8).withCapacity(fileSz); var bp: *mut[] u8 = body.ptr; f.read(bp[0..fileSz]); const dst: u32 = ramDest & 0x001FFFFF; var ram: *mut[] u8 = bus.ram.ptr; var i: u32 = 0; while (i < fileSz) { ram[dst + i] = body[i]; i = i + 1; } f.close(); c.pc = initPc; c.nextPc = c.pc + 4; gprWrite(regs, 28, initGp); if (spBase != 0) { const sp: u32 = spBase + spOff; gprWrite(regs, 29, sp); gprWrite(regs, 30, sp); }
print("Loaded EXE: {fileSz} bytes\n"); } None { print("loadExe: cannot open file\n"); } }}
fn loadBios(path: []u8, bus: Bus) u64 { match (File.open(path)) { Some(f) { var biosPtr: *mut[] u8 = bus.bios.ptr; var bios: []u8 = biosPtr[0..0x80000]; const got: u64 = f.read(bios); f.close(); return got; } None { return 0; } } return 0;}
fn runOneFrame(c: mut Cpu, bus: Bus, regs: *mut[] u32, cop0: *mut[] u32, audioDev: u32, ring: *mut[] u8) u32 { var gpuBuf: *mut[] u32 = bus.gpu.bufPtr(); var line: u32 = gpuBuf[36]; var accB: FloatBits = FloatBits { i: gpuBuf[37] }; var acc: f32 = accB.f; // GPU cycles per CPU cycle = 53.693175 / 33.868800 MHz. const ratio: f32 = (53.693175 as f32) / (33.868800 as f32); var spuAcc: u32 = gpuBuf[35]; var audioBuf: [4096]u8 = [0; 4096]; var audioLen: u32 = 0; var frameDone: bool = false; while (!frameDone) { biosHook(c, bus, regs); const before: u64 = c.cycles; step(c, bus, regs, cop0); const delta: u32 = (c.cycles - before) as u32; bus.cdrom.update(bus.disc.ptrMut(), bus.spu.ptr, bus.irq.ptr, cop0, delta);
const prevHb: bool = acc >= GPU_CYCLES_HDRAW && acc <= GPU_CYCLES_SCANL; acc = acc + (delta as f32) * ratio; const currHb: bool = acc >= GPU_CYCLES_HDRAW && acc <= GPU_CYCLES_SCANL; if (currHb && !prevHb) { bus.timer.hblankBegin(bus.irq.ptr, cop0); if (line < SCANLINES_VDRAW) { if ((line & 1) != 0) { gpuBuf[46] = gpuBuf[46] | 0x80000000; } else { gpuBuf[46] = gpuBuf[46] & (~0x80000000); } } else { gpuBuf[46] = gpuBuf[46] & (~0x80000000); } line = line + 1; if (line == SCANLINES_VDRAW) { bus.timer.vblankBegin(bus.irq.ptr, cop0); irqRaise(bus.irq.ptr, cop0, IC_VBLANK); } else if (line == SCANLINES_TOTAL) { bus.timer.vblankEnd(); line = 0; frameDone = true; } } else if (prevHb && !currHb) { bus.timer.hblankEnd(); acc = acc - GPU_CYCLES_SCANL; } padUpdate(bus.pad.ptr, bus.irq.ptr, cop0, delta); bus.timer.updateCyc(bus.irq.ptr, cop0, delta); dmaTickSpu(bus.dma.ptr, delta); dmaUpdate(bus.dma.ptr, bus.irq.ptr, cop0);
bus.sio1.update(bus.irq.ptr, cop0, delta); spuAcc = spuAcc + delta; while (spuAcc >= 768) { spuAcc = spuAcc - 768; const sample: u32 = spuGetSample(bus.spu.ptr, bus.spuram.ptr, bus.irq.ptr, cop0); if (audioLen + 4 <= 4096) { var ap: *mut[] u8 = audioBuf.asMutPtr(); ap[audioLen] = (sample & 0xFF) as u8; ap[audioLen + 1] = ((sample >> 8) & 0xFF) as u8; ap[audioLen + 2] = ((sample >> 16) & 0xFF) as u8; ap[audioLen + 3] = ((sample >> 24) & 0xFF) as u8; audioLen = audioLen + 4; } gpuBuf[34] = sample; } } if (audioDev != 0 && audioLen > 0) { sdlAudioRingPush(audioDev, ring, audioBuf.asMutPtr(), audioLen); }
// NOTE! do NOT commitLoad here - each instruction's own commitLoad // handles the R3000 load-delay slot. gpuBuf[36] = line; var outB: FloatBits = FloatBits { f: acc }; gpuBuf[37] = outB.i; gpuBuf[35] = spuAcc; return audioLen / 4;}
fn pathEndsWithExe(p: []u8) bool { var n: u32 = 0; while (p.ptr[n] != 0) { n = n + 1; } if (n < 4) { return false; } const a: u32 = p.ptr[n - 4] as u32; const b: u32 = p.ptr[n - 3] as u32; const cC: u32 = p.ptr[n - 2] as u32; const e: u32 = p.ptr[n - 1] as u32; // ".exe" or ".EXE" (todo: use str with contains) if (a == 0x2E && (b == 0x65 || b == 0x45) && (cC == 0x78 || cC == 0x58) && (e == 0x65 || e == 0x45)) { return true; } return false;}
fn main() { var regs: Vec(u32) = createRegFile(32); var cop0: Vec(u32) = createRegFile(32); var bus: Bus = createBus(); var c: Cpu = freshCpu();
var mcdPath: []u8 = "./slot1.mcd"; if (mcdRamLoad(bus.mcdRam.ptr, mcdPath)) { print("Loaded memcard: ./slot1.mcd\n"); }
cop0Write(cop0.ptr, C0_SR, 0x10900000); cop0Write(cop0.ptr, C0_PRID, 0x00000002);
// jamstation <BIOS_PATH> [GAME_PATH] // argv[1] = BIOS image - REQUIRED, no default. // argv[2] = optional game image: a .cue/.bin disc or a .exe var biosPath: []u8 = ""; var hasBios: bool = false; var argsIt: Args = args(); next(argsIt); match (next(argsIt)) { Some(arg) { biosPath = arg; hasBios = true; } None { } } var gameArg: []u8 = ""; var hasGameArg: bool = false; match (next(argsIt)) { Some(arg) { gameArg = arg; hasGameArg = true; } None { } } if (!hasBios) { print("error: no BIOS image given\nusage: jamstation <BIOS_PATH> [GAME_PATH]\n"); exit(1); } if (!exists(biosPath)) { print("error: BIOS file not found: {biosPath}\n"); exit(1); } const got: u64 = loadBios(biosPath, bus); print("Loaded BIOS: {got} bytes from {biosPath}\n");
var demoPath = "./demo.exe"; var gamePath = "./game.exe"; var cuePath: []u8 = "./game.cue"; var binPath: []u8 = "./game.bin"; var pendingGameExe: bool = false; if (hasGameArg) { if (pathEndsWithExe(gameArg)) { gamePath = gameArg; pendingGameExe = true; print("EXE arg — booting BIOS first, will side-load at PC=80030000\n"); } else if (discOpen(bus.disc.ptrMut(), gameArg) != 0) { bus.cdrom.attachDisc(bus.disc.ptrMut()); } else { print("Could not open game image from arg — running BIOS only\n"); } } else { if (exists(cuePath)) { if (discOpen(bus.disc.ptrMut(), cuePath) != 0) { bus.cdrom.attachDisc(bus.disc.ptrMut()); } } else if (exists(binPath)) { if (discOpen(bus.disc.ptrMut(), binPath) != 0) { bus.cdrom.attachDisc(bus.disc.ptrMut()); } } if (exists(demoPath)) { loadExe(demoPath, bus, regs.ptr, c); } else if (exists(gamePath)) { pendingGameExe = true; print("game.exe found — booting BIOS first, will side-load at PC=80030000\n"); } else { print("No game arg / demo.exe — running from BIOS reset vector only\n"); } }
print("Initializing SDL2...\n"); var title = "jamstation"; var sentinel = "./FULL_VRAM"; const fullVram: bool = exists(sentinel); var sdlW = SCREEN_W; var sdlH = SCREEN_H; if (fullVram) { sdlW = VRAM_WIDTH; sdlH = VRAM_HEIGHT; print("JAM_PSONE_FULL_VRAM set. Showing full VRAM\n"); } var sdl: Sdl = sdlInit(title, sdlW, sdlH); var audioRing: [16400]u8 = [0; 16400]; var ringPtr: *mut[] u8 = audioRing.asMutPtr(); var audioDev: u32 = sdlAudioOpen(1024, psone_audio_cb as u64, ringPtr as u64); if (audioDev == 0) { print("WARN: SDL audio device failed to open\n"); } else { print("Audio device opened (id={audioDev}, 44.1kHz S16 stereo, callback+ring)\n"); } print("Running. Esc or close to quit\n");
var buttons: [1]u32 = [0xFFFF; 1]; var btnPtr: *mut[] u32 = buttons.asMutPtr();
var quit: bool = false; var idleFrames: u32 = 0; var lastPoly: u32 = 0; const IDLE_TRIGGER: u32 = 180; const MIN_POLY_DRAWN: u32 = 200; const FRAME_MS: f64 = 1000.0 / 59.94; var nextFrameMs: f64 = sdlTicks() as f64; while (!quit) { // 1. BIOS jumps to 0x80030000 (real-disc boot handoff). // 2. BIOS shell is parked at the kernel idle loop with no disc; // after IDLE_TRIGGER frames stuck there we sideload. if (pendingGameExe && c.pc >= 0x80030000 && c.pc < 0x80040000) { loadExe(gamePath, bus, regs.ptr, c); pendingGameExe = false; idleFrames = 0; } if (pendingGameExe) { const curPoly: u32 = bus.gpu.familyCount(1); if (curPoly == lastPoly && curPoly >= MIN_POLY_DRAWN) { idleFrames = idleFrames + 1; if (idleFrames >= IDLE_TRIGGER) { print("BIOS shell idle — sideloading game.exe fallback\n"); loadExe(gamePath, bus, regs.ptr, c); pendingGameExe = false; idleFrames = 0; } } else { idleFrames = 0; lastPoly = curPoly; } } const frameSamples: u32 = runOneFrame(c, bus, regs.ptr, cop0.ptr, audioDev, ringPtr); var vram: *mut[] u8 = bus.vram.ptr; var gpuState: *mut[] u32 = bus.gpu.bufPtr(); const dispX: i32 = gpuState[63] as i32; const dispY: i32 = gpuState[64] as i32; // GPUSTAT bit 21 (mode bit 4) = 24bpp display. // GPUSTAT bit 23 = display disabled (black screen). const dm: u32 = gpuState[47]; bus.timer.setDotclock(dm); const is24bpp: bool = (dm & 0x10) != 0; const displayOff: bool = (gpuState[46] & 0x00800000) != 0; if (fullVram) { sdlBlit(sdl, vram, 0, 0, VRAM_WIDTH, VRAM_HEIGHT, false, displayOff); } else { const w: i32 = dmodeWidth(dm); const h: i32 = dmodeHeight(dm, gpuState); sdlBlit(sdl, vram, dispX, dispY, w, h, is24bpp, displayOff); } quit = sdlPump(btnPtr) > 0; padSetButtons(bus.pad.ptr, buttons[0]);
var frameMs: f64 = FRAME_MS; if (frameSamples > 0) { frameMs = (frameSamples as f64) * 1000.0 / 44100.0; } nextFrameMs = nextFrameMs + frameMs; const nowMs: u32 = sdlTicks(); if ((nowMs as f64) < nextFrameMs) { sdlDelay((nextFrameMs - (nowMs as f64)) as u32); } else { nextFrameMs = nowMs as f64; } }
mcdRamSave(bus.mcdRam.ptr, mcdPath); sdlAudioClose(audioDev); sdlQuit(sdl); print("jamstation: quitting...\n");}