Something went wrong. Try again.
A Playstation One (PS1) emulator fully written in Jam
Something went wrong. Try again.
jamstation sdl.jam
28 kB · 633 lines
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634// SPDX-License-Identifier: MIT// Copyright (c) 2026 Raphael Amorim
// SDL2 frontend.//// Wraps the few SDL2 functions we need to open a window, blit a// pixel buffer to a hardware-accelerated texture, and pump keyboard /// quit events. The PSX framebuffer (VRAM) is 1024 wide × 512 tall, 16// bits per pixel (RGB555 + mask bit). We expose it via SDL2's BGR555// pixel format so the host GPU does the conversion for us.//// macOS desktop only - set up via Homebrew's `sdl2` formula. Build// with the SDL2 linker flags via `make run`.
const { Vec } = import("std").collections;
// SDL_Init flags. We need video + events (and game-controller would// be nice but not required for this build).const SDL_INIT_VIDEO: u32 = 0x00000020;const SDL_INIT_EVENTS: u32 = 0x00004000;
// SDL_WindowFlagsconst SDL_WINDOW_SHOWN: u32 = 0x00000004;const SDL_WINDOW_RESIZABLE: u32 = 0x00000020;
const SDL_PIXELFORMAT_ARGB8888: u32 = 0x16362004;const SDL_TEXTUREACCESS_STREAMING: i32 = 1;
const SDL_RENDERER_ACCELERATED: u32 = 0x00000002;
// SDL_Event type constants we care about.const SDL_QUIT: u32 = 0x100;const SDL_KEYDOWN: u32 = 0x300;const SDL_KEYUP: u32 = 0x301;
// SDL2 surface - pointer-opaque handles, treated as `*mut[] u8` from Jam.extern fn SDL_Init(flags: u32) i32;extern fn SDL_Quit();extern fn SDL_SetHint(name: *const[] u8, value: *const[] u8) i32;extern fn SDL_CreateWindow(title: *const[] u8, x: i32, y: i32, w: i32, h: i32, flags: u32) *mut[] u8;extern fn SDL_DestroyWindow(window: *mut[] u8);extern fn SDL_CreateRenderer(window: *mut[] u8, index: i32, flags: u32) *mut[] u8;extern fn SDL_GetRendererInfo(renderer: *mut[] u8, info: *mut[] u8) i32;extern fn strlen(s: *const[] u8) u64;extern fn SDL_DestroyRenderer(renderer: *mut[] u8);extern fn SDL_CreateTexture(renderer: *mut[] u8, format: u32, access: i32, w: i32, h: i32) *mut[] u8;extern fn SDL_DestroyTexture(texture: *mut[] u8);extern fn SDL_UpdateTexture(texture: *mut[] u8, rect: *const[] u8, pixels: *const[] u8, pitch: i32) i32;extern fn SDL_RenderClear(renderer: *mut[] u8) i32;extern fn SDL_RenderCopy(renderer: *mut[] u8, texture: *mut[] u8, srcrect: *const[] u8, dstrect: *const[] u8) i32;extern fn SDL_RenderPresent(renderer: *mut[] u8);extern fn SDL_Delay(ms: u32);extern fn SDL_PollEvent(event: *mut[] u8) i32;extern fn SDL_GetTicks() u32;// Actual render-target size in PIXELS. On a HiDPI / retina display this is// larger than the window's point size (e.g. 2×), so we must scale the blit// against THIS, not the window size - otherwise the image lands in a corner.extern fn SDL_GetRendererOutputSize(renderer: *mut[] u8, w: *mut[] i32, h: *mut[] i32) i32;// Letterbox/pillarbox bars are painted with the render draw colour on clear.extern fn SDL_SetRenderDrawColor(renderer: *mut[] u8, r: u8, g: u8, b: u8, a: u8) i32;
// macOS App Nap disable (Objective-C runtime)// When the jam window is not focused, macOS App Nap throttles the process's// timers + CPU, stalling the single-threaded emulation loop ("stuck when not// focused"). Disable it by holding a user-initiated NSProcessInfo activity.// objc_msgSend is declared NON-variadic with fixed u64 args so every arg lands// in a register (the ARM64 ABI passes variadic args on the stack, which would// break objc_msgSend); unused trailing args are ignored by the target method.extern fn objc_getClass(name: *const[] u8) u64;extern fn sel_registerName(name: *const[] u8) u64;extern fn objc_msgSend(recv: u64, sel: u64, a: u64, b: u64) u64;
// audio//// Callback-mode SDL audio with an SPSC ring buffer. The SPU is NOT// driven by the audio thread (it's clocked CPU-synchronously inside// runOneFrame, one sample per 768 cycles, so the envelope the game// polls stays deterministic - Brave Fencer's intro depends on this).// Instead the CPU loop PRODUCES samples into a ring buffer, and SDL's// audio callback (psone_audio_cb) CONSUMES from it at the rock-steady// host 44.1 kHz rate. Under/overruns are handled at sample granularity// (silence-fill on underrun, bounded drop on overrun) instead of the// whole-frame drops that queue mode (SDL_QueueAudio) produced - which// were the source of the "robotic" gaps/clicks.//// `cbAddr` is the address of `psone_audio_cb` (computed in main.jam// via Jam's fn-as-u64 coercion - the ptrtoint cast lowering in the// compiler is what makes this expressible without a C shim).// `userdata` is the ring-buffer blob the callback drains (see the// RING_* layout below).//// SDL_OpenAudioDevice's first arg is `const char*`. We need to pass// NULL to mean "use the default playback device"; Jam can't express// a null `*const[] u8` directly, so we declare the arg as u64 and// pass 0.extern fn SDL_OpenAudioDevice(device: u64, iscapture: i32, desired: *mut[] u8, obtained: *mut[] u8, allowed_changes: i32) u32;extern fn SDL_CloseAudioDevice(dev: u32);extern fn SDL_PauseAudioDevice(dev: u32, pause_on: i32);extern fn SDL_QueueAudio(dev: u32, data: *const[] u8, len: u32) i32;extern fn SDL_GetQueuedAudioSize(dev: u32) u32;// Held by the producer (sdlAudioRingPush) while it touches the ring// header; SDL holds the SAME lock while the callback runs, so producer// and consumer are never concurrent. jam/std has no atomics, so this// is how we get cross-thread safety + the needed memory barriers.extern fn SDL_LockAudioDevice(dev: u32);extern fn SDL_UnlockAudioDevice(dev: u32);
const SDL_INIT_AUDIO: u32 = 0x00000010;// AUDIO_S16SYS on little-endian hosts (macOS) is 0x8010 - signed,// 16-bit, little-endian. SDL2 picks the right format automatically// based on system endianness via this alias.const SDL_AUDIO_S16SYS: u32 = 0x8010;
// Bundles the SDL2 window / renderer / texture so we can pass them// through one parameter.pub const Sdl = struct { window: *mut[] u8, renderer: *mut[] u8, texture: *mut[] u8, width: i32, height: i32,};
// Hold a process-lifetime "user initiated" NSProcessInfo activity, which// disables macOS App Nap so the emulator keeps running at full speed when its// window is unfocused/background. The activity token is intentionally leaked// (releasing it would re-enable App Nap).fn disableAppNap() { // Guarded by @isDarwin() so non-macOS builds dead-code-eliminate the body // and never reference the objc_* symbols (no -lobjc needed off Darwin), // mirroring std/process.jam's _NSGetArgv pattern. if (@isDarwin()) { var nmPi: []u8 = "NSProcessInfo"; const clsPi: u64 = objc_getClass(nmPi.ptr); if (clsPi == 0) { return; } var selPi: []u8 = "processInfo"; const pi: u64 = objc_msgSend(clsPi, sel_registerName(selPi.ptr), 0, 0); if (pi == 0) { return; } var nmStr: []u8 = "NSString"; var selStr: []u8 = "stringWithUTF8String:"; var reason: []u8 = "jamstation emulation running"; const nsstr: u64 = objc_msgSend(objc_getClass(nmStr.ptr), sel_registerName(selStr.ptr), reason.ptr as u64, 0); // NSActivityUserInitiated (0x00FFFFFF) -> App Nap disabled while held. var selBegin: []u8 = "beginActivityWithOptions:reason:"; const token: u64 = objc_msgSend(pi, sel_registerName(selBegin.ptr), 0x00FFFFFF, nsstr); var selRetain: []u8 = "retain"; objc_msgSend(token, sel_registerName(selRetain.ptr), 0, 0); }}
pub fn sdlInit(title: []u8, width: i32, height: i32) Sdl { SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS | SDL_INIT_AUDIO); disableAppNap(); // keep running at full speed when the window is unfocused // Window is texture-sized (no upscale) and resizable so the user // can scale up post-hoc. SDL_RenderCopy stretches the texture to // fit whatever the current window dimensions are. var win: *mut[] u8 = SDL_CreateWindow(title.ptr, 0x2FFF0000, 0x2FFF0000, width, height, SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE); // Force the OpenGL renderer instead of macOS's default Metal one. Metal's // SDL_RenderPresent calls [CAMetalLayer nextDrawable], which BLOCKS when // the window isn't being composited (unfocused/occluded) because the // drawable pool starves - this is the "stuck when not focused" freeze, and // it happens even with vsync off. OpenGL's swap has no such pool, so the // loop keeps running when unfocused. We also drop PRESENTVSYNC so the // present never waits on a vblank (which the OS may stop delivering to an // unfocused window); jam's own 59.94 Hz SDL_Delay limiter paces the loop. var hintName: []u8 = "SDL_RENDER_DRIVER"; var hintGl: []u8 = "opengl"; SDL_SetHint(hintName.ptr, hintGl.ptr); var ren: *mut[] u8 = SDL_CreateRenderer(win, -1, SDL_RENDERER_ACCELERATED); if (ren as u64 == 0) { // OpenGL unavailable - clear the hint and fall back to the default // (Metal) renderer so we still get a window rather than crashing. var hintEmpty: []u8 = ""; SDL_SetHint(hintName.ptr, hintEmpty.ptr); ren = SDL_CreateRenderer(win, -1, SDL_RENDERER_ACCELERATED); } // Report the live render driver so it's obvious which backend won (if this // says "metal", the OpenGL hint didn't take and the unfocused freeze will // return). SDL_RendererInfo starts with a `const char *name`; pad to 128 B. var info: [128]u8 = [0; 128]; SDL_GetRendererInfo(ren, info.asMutPtr()); var infoWords: *mut[] u64 = info.asMutPtr() as *mut[] u64; const namePtr: u64 = infoWords[0]; if (namePtr != 0) { const np: *const[] u8 = namePtr as *const[] u8; const nlen: u64 = strlen(np); const nameSlice: []u8 = np[0..nlen]; print("[sdl] render driver: {nameSlice}\n"); } // Opaque black for the RenderClear bars: when the window's aspect doesn't // match the PSX display, sdlBlit centers the image and leaves black // letterbox/pillarbox margins. SDL_SetRenderDrawColor(ren, 0, 0, 0, 255); // Use ARGB8888 (32-bit) so the blit can target both 15bpp and 24bpp // PSX display modes through a single texture. The conversion happens // per-pixel in sdlBlit; SDL gets a canonical 4-byte format. var tex: *mut[] u8 = SDL_CreateTexture(ren, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, width, height); var s: Sdl = Sdl { window: win, renderer: ren, texture: tex, width: width, height: height, }; return s;}
pub fn sdlQuit(s: Sdl) { SDL_DestroyTexture(s.texture); SDL_DestroyRenderer(s.renderer); SDL_DestroyWindow(s.window); SDL_Quit();}
// Open a 44.1 kHz / S16-LE / stereo audio device in callback mode.// `cbAddr` = address of the SDL_AudioCallback (Jam fn coerced to u64// via the new fn-ref / PtrToInt path). `userdata` = address of the// AudioCtx blob handed back to the callback. Returns the device ID// (0 on failure). SDL_AudioSpec is laid out by memcpying the C-struct// layout into a 32-byte buffer.// Local little-endian 8-byte writer for SDL_AudioSpec's pointer slots.pub fn setU64Le(buf: *mut[] u8, off: u32, v: u64) { buf[off] = (v & 0xFF) as u8; buf[off + 1] = ((v >> 8) & 0xFF) as u8; buf[off + 2] = ((v >> 16) & 0xFF) as u8; buf[off + 3] = ((v >> 24) & 0xFF) as u8; buf[off + 4] = ((v >> 32) & 0xFF) as u8; buf[off + 5] = ((v >> 40) & 0xFF) as u8; buf[off + 6] = ((v >> 48) & 0xFF) as u8; buf[off + 7] = ((v >> 56) & 0xFF) as u8;}
pub fn sdlAudioOpen(samplesPerBuffer: u32, cbAddr: u64, userdata: u64) u32 { // SDL_AudioSpec is a 32-byte C struct; we hand-pack it and let SDL // overwrite the obtained side. Stack arrays beat malloc for these // single-call scratch buffers - they auto-vanish on function exit. var desired: [32]u8 = [0; 32]; var obtained: [32]u8 = [0; 32]; var dp: *mut[] u8 = desired.asMutPtr(); // freq @ 0 (i32) = 44100 dp[0] = 0x44; dp[1] = 0xAC; dp[2] = 0; dp[3] = 0; // format @ 4 (u16) - AUDIO_S16SYS dp[4] = (SDL_AUDIO_S16SYS & 0xFF) as u8; dp[5] = ((SDL_AUDIO_S16SYS >> 8) & 0xFF) as u8; // channels @ 6 (u8) = 2 dp[6] = 2; // silence @ 7 (u8) - SDL fills // samples @ 8 (u16) - buffer size in stereo samples dp[8] = (samplesPerBuffer & 0xFF) as u8; dp[9] = ((samplesPerBuffer >> 8) & 0xFF) as u8; // padding, size - SDL fills // callback @ 16 (8-byte fn pointer) setU64Le(dp, 16, cbAddr); // userdata @ 24 (8-byte ptr) - handed back to the callback as-is setU64Le(dp, 24, userdata); // First arg = NULL (default playback device). const dev: u32 = SDL_OpenAudioDevice(0 as u64, 0, dp, obtained.asMutPtr(), 0); if (dev != 0) { SDL_PauseAudioDevice(dev, 0); // unpause } return dev;}
pub fn sdlAudioClose(dev: u32) { if (dev != 0) { SDL_CloseAudioDevice(dev); }}
// SPSC audio ring buffer// The CPU loop (producer) pushes the per-frame sample batch; SDL's// audio callback (consumer) drains it at the host rate. Layout of the// `userdata` blob handed to SDL (indices are byte offsets into the// CAP-byte data region, CAP a power of two so wrap == & MASK):// @0 wr u32 producer-owned write offset [0, CAP)// @4 rd u32 consumer-owned read offset [0, CAP)// @8 primed u32 0 until PRIME bytes buffered; gates draining so// we don't thrash on startup / after an underrun// @16 data [CAP]u8 the ring storage// Total blob = RING_DATA_OFF + CAP bytes (see main.jam's audioRing).const RING_CAP: u32 = 16384; // 4096 stereo samples ≈ 93 msconst RING_MASK: u32 = 16383; // CAP - 1 (CAP is 2^14)const RING_DATA_OFF: u32 = 16; // header bytes before the dataconst RING_PRIME: u32 = 8192; // prebuffer ≈ 46 ms before play
// Little-endian u32 accessors for the ring header. Byte-wise so there// are no alignment assumptions on the blob.pub fn getU32Le(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);}pub fn setU32Le(buf: *mut[] u8, off: u32, v: u32) { buf[off] = (v & 0xFF) as u8; buf[off + 1] = ((v >> 8) & 0xFF) as u8; buf[off + 2] = ((v >> 16) & 0xFF) as u8; buf[off + 3] = ((v >> 24) & 0xFF) as u8;}
// PRODUCER (CPU thread): append `n` bytes of S16LE stereo samples from// `src` into the ring. Locks the device so it can't race the callback.// On overrun (ring nearly full - rare, since the audio-paced frame// limiter keeps production matched to the host rate) the OLDEST whole// frames are dropped to make room, keeping wr/rd frame-aligned.pub fn sdlAudioRingPush(dev: u32, ring: *mut[] u8, src: *mut[] u8, n: u32) { if (dev == 0) { return; } SDL_LockAudioDevice(dev); var wr: u32 = getU32Le(ring, 0); var rd: u32 = getU32Le(ring, 4); // `n` is always a whole number of stereo frames (4 bytes each). EVERY // index movement here MUST stay a multiple of 4, or wr/rd drift off the // L/R grid and the callback reads half-frames - which swaps/tears the // channels into loud noise. (The old code clamped toWrite to `free`, // but free = RING_CAP-1-avail is ODD, so an overrun wrote an odd byte // count and permanently misaligned the ring - the "weird audio" bug.) const avail: u32 = (wr - rd) & RING_MASK; const free: u32 = RING_CAP - 1 - avail; // 1-byte gap = empty/full if (n > free) { // Overrun: jam emits a hair above 44.1 kHz (≈+300 B/s), so the ring // slowly fills. Make room by dropping the OLDEST whole frames - this // keeps latency bounded and plays the freshest audio, with at worst a // faint periodic skip instead of channel-tearing. Safe to move rd // here: SDL holds the device lock, so the callback can't be running. var need: u32 = n - free; need = (need + 3) & 0xFFFFFFFC; // round up to whole frames rd = (rd + need) & RING_MASK; setU32Le(ring, 4, rd); } // n now fits and is frame-aligned; write the whole batch. var i: u32 = 0; while (i < n) { ring[RING_DATA_OFF + wr] = src[i]; wr = (wr + 1) & RING_MASK; i = i + 1; } setU32Le(ring, 0, wr); // Once enough is buffered, allow the callback to start draining. const after: u32 = (wr - rd) & RING_MASK; if (after >= RING_PRIME) { setU32Le(ring, 8, 1); } SDL_UnlockAudioDevice(dev);}
// CONSUMER (SDL audio thread): SDL calls this with the device lock// already held, so it's serialized against sdlAudioRingPush. Drains up// to `len` bytes from the ring into `stream`; zero-fills any shortfall.pub export fn psone_audio_cb(userdata: u64, stream: *mut[] u8, len: i32) { var ring: *mut[] u8 = userdata as *mut[] u8; const want: u32 = len as u32; var i: u32 = 0; // Not yet primed (startup or post-underrun rebuffer) -> silence. if (getU32Le(ring, 8) == 0) { while (i < want) { stream[i] = 0; i = i + 1; } return; } const wr: u32 = getU32Le(ring, 0); var rd: u32 = getU32Le(ring, 4); const avail: u32 = (wr - rd) & RING_MASK; var toRead: u32 = want; if (toRead > avail) { toRead = avail; } while (i < toRead) { stream[i] = ring[RING_DATA_OFF + rd]; rd = (rd + 1) & RING_MASK; i = i + 1; } setU32Le(ring, 4, rd); // Underrun: zero-fill the rest and re-arm priming so we rebuffer // ~PRIME bytes before resuming (avoids repeated underrun thrash). if (toRead < want) { while (i < want) { stream[i] = 0; i = i + 1; } setU32Le(ring, 8, 0); }}
// Pack an SDL_Rect (4 × i32 = 16 bytes) into a heap buffer. PSX// coordinates are positive and ≤ 1024, so the upper bytes are always// zero.pub fn packRect(buf: *mut[] u8, x: i32, y: i32, w: i32, h: i32) { buf[0] = (x & 0xFF) as u8; buf[1] = ((x >> 8) & 0xFF) as u8; buf[2] = 0; buf[3] = 0; buf[4] = (y & 0xFF) as u8; buf[5] = ((y >> 8) & 0xFF) as u8; buf[6] = 0; buf[7] = 0; buf[8] = (w & 0xFF) as u8; buf[9] = ((w >> 8) & 0xFF) as u8; buf[10] = 0; buf[11] = 0; buf[12] = (h & 0xFF) as u8; buf[13] = ((h >> 8) & 0xFF) as u8; buf[14] = 0; buf[15] = 0;}
// Blit a VRAM-formatted buffer (BGR555 16bpp) to the SDL texture and// present it. Updates a sub-rect of the texture at (dx, dy, dw, dh)// using the pixel data starting at the matching offset inside the// 1024×512 VRAM buffer, then renders that sub-rect (scaled) to the// window. This is how we crop the on-screen image to the BIOS's actual// display window - without it, the SDL texture would also show the// off-screen texture pages and CLUTs that live elsewhere in VRAM.// sdlBlit - copy a rectangle of VRAM to the SDL texture, expanding to// ARGB8888. `is24bpp` selects between PSX 15bpp BGR555 (1 pixel per// 16-bit VRAM cell) and PSX 24bpp packed (3 bytes per pixel in raw VRAM// byte order R,G,B,R,G,B,…). `displayOff` forces a black frame.//// VRAM row stride is fixed at 2048 bytes (1024 cells × 2 bytes) for both// modes; only the per-pixel size and the unpack differs.pub fn sdlBlit(s: Sdl, pixels: *mut[] u8, dx: i32, dy: i32, dw: i32, dh: i32, is24bpp: bool, displayOff: bool) { // SDL_Rect = 4 × i32 = 16 bytes. dstOnTex selects the dw×dh sub-rect of // the texture we actually filled; dstRect (the on-screen target) is // computed against the live output size just before present. var dstRect: [16]u8 = [0; 16]; var dstOnTex: [16]u8 = [0; 16]; packRect(dstOnTex.asMutPtr(), 0, 0, dw, dh);
// Destination buffer is always ARGB8888 = 4 bytes/pixel. `displayOff` // wants every byte zero; the active path overwrites every byte in the // conversion loop. `filled(0, N)` covers both - the zero-fill is one // pass through the same cache lines we touch immediately after. const dwU: u32 = dw as u32; const dhU: u32 = dh as u32; const cropBytes: u64 = (dwU * dhU * 4) as u64; var cropped: Vec(u8) = Vec(u8).filled(0, cropBytes as u32);
if (displayOff) { // already zero-filled } else { var row: u32 = 0; while (row < dhU) { const dstBase: u32 = row * dwU * 4; // VRAM row stride: 2048 bytes. dx is in cell units (16-bit), // so byte offset is dx*2 for 15bpp. For 24bpp the source x is // still in 16-bit-cell units but each pixel is 3 bytes, so // bytes-per-row read = dwU * 3 starting at dx*2. const srcRowBase: u32 = (dy as u32 + row) * 2048 + (dx as u32) * 2; if (is24bpp) { var x: u32 = 0; while (x < dwU) { const sb: u32 = srcRowBase + x * 3; const r: u8 = pixels[sb]; const g: u8 = pixels[sb + 1]; const b: u8 = pixels[sb + 2]; const db: u32 = dstBase + x * 4; cropped[db] = b; // ARGB8888 little-endian: B,G,R,A cropped[db + 1] = g; cropped[db + 2] = r; cropped[db + 3] = 0xFF; x = x + 1; } } else { var x: u32 = 0; while (x < dwU) { const sb: u32 = srcRowBase + x * 2; const lo: u32 = pixels[sb] as u32; const hi: u32 = pixels[sb + 1] as u32; const px: u32 = lo | (hi << 8); // BGR555 -> 8888 (5->8 by left-shift + replicate high // bits; cheap "x<<3 | x>>2" expansion). const r5: u32 = px & 0x1F; const g5: u32 = (px >> 5) & 0x1F; const b5: u32 = (px >> 10) & 0x1F; const r8: u32 = (r5 << 3) | (r5 >> 2); const g8: u32 = (g5 << 3) | (g5 >> 2); const b8: u32 = (b5 << 3) | (b5 >> 2); const db: u32 = dstBase + x * 4; cropped[db] = b8 as u8; cropped[db + 1] = g8 as u8; cropped[db + 2] = r8 as u8; cropped[db + 3] = 0xFF; x = x + 1; } } row = row + 1; } }
// Scale the dw×dh image to fill the CURRENT window, preserving the source // aspect ratio (the leftover margin becomes black letterbox/pillarbox // bars). We size against the renderer's OUTPUT size in pixels - not the // window's point size - so this is correct on HiDPI/retina (output is // larger than the window) AND tracks live user resizes. Same approach as // psxe's screen.c (SDL_GetRendererOutputSize + a computed dstrect); jam's // window is resizable, so we recompute every frame instead of on a // display-mode event. var owBuf: [1]i32 = [0; 1]; var ohBuf: [1]i32 = [0; 1]; SDL_GetRendererOutputSize(s.renderer, owBuf.asMutPtr(), ohBuf.asMutPtr()); var ow: i32 = owBuf[0]; var oh: i32 = ohBuf[0]; if (ow <= 0 || oh <= 0) { ow = s.width; oh = s.height; } // Aspect fit by cross-multiplying (ow/oh vs dw/dh) to stay in integers. var fitW: i32 = ow; var fitH: i32 = oh; if (ow * dh > oh * dw) { // Window wider than the image -> full height, pillarbox the sides. fitH = oh; fitW = (oh * dw) / dh; } else { // Window taller/narrower -> full width, letterbox top/bottom. fitW = ow; fitH = (ow * dh) / dw; } const offX: i32 = (ow - fitW) / 2; const offY: i32 = (oh - fitH) / 2; packRect(dstRect.asMutPtr(), offX, offY, fitW, fitH);
SDL_UpdateTexture(s.texture, dstOnTex.asMutPtr(), cropped.ptr, dw * 4); SDL_RenderClear(s.renderer); SDL_RenderCopy(s.renderer, s.texture, dstOnTex.asMutPtr(), dstRect.asMutPtr()); SDL_RenderPresent(s.renderer); // cropped auto-drops on function-scope exit. dstRect / dstOnTex are stack // arrays that vanish with the frame; no manual cleanup.}
// SDL scancodes (USB HID page 7) - see SDL2 SDL_scancode.h.const SC_ESC: u32 = 41;const SC_UP: u32 = 82;const SC_DOWN: u32 = 81;const SC_LEFT: u32 = 80;const SC_RIGHT: u32 = 79;const SC_Z: u32 = 29;const SC_X: u32 = 27;const SC_A: u32 = 4;const SC_S: u32 = 22;const SC_Q: u32 = 20;const SC_W: u32 = 26;const SC_1: u32 = 30;const SC_2: u32 = 31;const SC_RETURN: u32 = 40;const SC_RSHIFT: u32 = 229;
// PSX SDA digital-pad button bits (active-low: 0 = pressed). Layout// matches the second-byte / third-byte response of a poll(0x42) command.const BTN_SELECT: u32 = 0x0001;const BTN_START: u32 = 0x0008;const BTN_UP: u32 = 0x0010;const BTN_RIGHT: u32 = 0x0020;const BTN_DOWN: u32 = 0x0040;const BTN_LEFT: u32 = 0x0080;const BTN_L2: u32 = 0x0100;const BTN_R2: u32 = 0x0200;const BTN_L1: u32 = 0x0400;const BTN_R1: u32 = 0x0800;const BTN_TRIANGLE: u32 = 0x1000;const BTN_CIRCLE: u32 = 0x2000;const BTN_CROSS: u32 = 0x4000;const BTN_SQUARE: u32 = 0x8000;
// Translate a USB-HID scancode to a PSX SDA button mask bit. Returns 0// for unmapped keys. Keep close to the standard PCSX / DuckStation// keybinding layout so it feels familiar.pub fn scancodeToBtn(scan: u32) u32 { match (scan) { SC_UP { return BTN_UP; } SC_DOWN { return BTN_DOWN; } SC_LEFT { return BTN_LEFT; } SC_RIGHT { return BTN_RIGHT; } SC_Z { return BTN_CROSS; } SC_X { return BTN_CIRCLE; } SC_A { return BTN_SQUARE; } SC_S { return BTN_TRIANGLE; } SC_Q { return BTN_L1; } SC_W { return BTN_R1; } SC_1 { return BTN_L2; } SC_2 { return BTN_R2; } SC_RETURN { return BTN_START; } SC_RSHIFT { return BTN_SELECT; } _ { return 0; } }}
// Pump SDL events. Returns non-zero when the user has requested quit// (close button, Cmd-Q, ESC). The `buttons` slot is a 1-element u32// array holding the active-low PSX SDA mask (0xFFFF = no buttons).// KEYDOWN clears the bit, KEYUP sets it; updates take effect on the// next controller poll.pub fn sdlPump(buttons: *mut[] u32) u32 { // SDL_Event is a 56-byte union in SDL 2.x; pad to 64 bytes on the // stack so newer SDL releases that grow the union don't trample. var ev: [64]u8 = [0; 64]; var evP: *mut[] u8 = ev.asMutPtr(); var quit: u32 = 0; while (SDL_PollEvent(evP) != 0) { const t: u32 = (evP[0] as u32) | ((evP[1] as u32) << 8) | ((evP[2] as u32) << 16) | ((evP[3] as u32) << 24); if (t == SDL_QUIT) { quit = 1; } if (t == SDL_KEYDOWN || t == SDL_KEYUP) { // SDL_KeyboardEvent layout: type(4) + timestamp(4) + // windowID(4) + state(1) + repeat(1) + padding2(1) + // padding3(1) = 16 bytes before SDL_Keysym, whose first // field is the i32 scancode. So scancode bytes live at // offsets 16..19 of the event blob. const scan: u32 = (evP[16] as u32) | ((evP[17] as u32) << 8) | ((evP[18] as u32) << 16) | ((evP[19] as u32) << 24); if (scan == SC_ESC && t == SDL_KEYDOWN) { quit = 1; } const bit: u32 = scancodeToBtn(scan); if (bit != 0) { if (t == SDL_KEYDOWN) { buttons[0] = buttons[0] & (~bit); } else { buttons[0] = buttons[0] | bit; } } } } return quit;}
pub fn sdlDelay(ms: u32) { SDL_Delay(ms);}
// Milliseconds since SDL_Init — the frame limiter's wall-clock source.pub fn sdlTicks() u32 { return SDL_GetTicks();}