From 6211eb03b5f5307dabaa85b73fca405e26bf5032 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 12:00:11 -0600 Subject: [PATCH 01/26] feat: PSP overlay plugin PRX crate with in-game overlay and background music Add oasis-plugin-psp, a kernel-mode PRX companion module that stays resident alongside games via CFW PLUGINS.TXT. Hooks sceDisplaySetFrameBuf for overlay UI (menu, OSD, status bar) and streams MP3 playback through a dedicated audio thread. Includes plugin install/remove/status terminal commands in the EBOOT, INI config parser, and full documentation. Also adds sctrlHEN syscall hook bindings and SyscallHook helper to the rust-psp SDK (separate repo, not included in this commit). Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 6 +- CLAUDE.md | 14 +- README.md | 17 +- crates/oasis-backend-psp/src/commands.rs | 156 +++++++ crates/oasis-plugin-psp/Cargo.toml | 32 ++ crates/oasis-plugin-psp/src/audio.rs | 537 +++++++++++++++++++++++ crates/oasis-plugin-psp/src/config.rs | 241 ++++++++++ crates/oasis-plugin-psp/src/font.rs | 131 ++++++ crates/oasis-plugin-psp/src/hook.rs | 96 ++++ crates/oasis-plugin-psp/src/lib.rs | 62 +++ crates/oasis-plugin-psp/src/overlay.rs | 414 +++++++++++++++++ crates/oasis-plugin-psp/src/render.rs | 184 ++++++++ docs/design.md | 19 + docs/psp-plugin.md | 160 +++++++ 14 files changed, 2065 insertions(+), 4 deletions(-) create mode 100644 crates/oasis-plugin-psp/Cargo.toml create mode 100644 crates/oasis-plugin-psp/src/audio.rs create mode 100644 crates/oasis-plugin-psp/src/config.rs create mode 100644 crates/oasis-plugin-psp/src/font.rs create mode 100644 crates/oasis-plugin-psp/src/hook.rs create mode 100644 crates/oasis-plugin-psp/src/lib.rs create mode 100644 crates/oasis-plugin-psp/src/overlay.rs create mode 100644 crates/oasis-plugin-psp/src/render.rs create mode 100644 docs/psp-plugin.md diff --git a/AGENTS.md b/AGENTS.md index 0998689..7294e6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,9 @@ cargo deny check # PSP backend (excluded from workspace, requires nightly + cargo-psp) cd crates/oasis-backend-psp && RUST_PSP_BUILD_STD=1 cargo +nightly psp --release +# PSP overlay plugin PRX (excluded from workspace, kernel mode) +cd crates/oasis-plugin-psp && RUST_PSP_BUILD_STD=1 cargo +nightly psp --release + # UE5 FFI shared library cargo build --release -p oasis-ffi @@ -75,7 +78,8 @@ oasis-types (foundation: Color, Button, InputEvent, backend traits, error ty │ └── oasis-app (binary entry points: oasis-app, oasis-screenshot) ├── oasis-backend-ue5 (software RGBA framebuffer for Unreal Engine 5) │ └── oasis-ffi (cdylib C-ABI for UE5 integration) - └── oasis-backend-psp (excluded from workspace, PSP hardware via sceGu) + ├── oasis-backend-psp (excluded from workspace, PSP hardware via sceGu) + └── oasis-plugin-psp (excluded from workspace, kernel-mode PRX overlay) ``` ### Backend Trait Boundary diff --git a/CLAUDE.md b/CLAUDE.md index 2ec6df2..587cdca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,9 @@ cargo deny check # Build PSP backend (excluded from workspace, requires nightly + cargo-psp) cd crates/oasis-backend-psp && RUST_PSP_BUILD_STD=1 cargo +nightly psp --release +# Build PSP overlay plugin PRX (excluded from workspace, kernel mode) +cd crates/oasis-plugin-psp && RUST_PSP_BUILD_STD=1 cargo +nightly psp --release + # Build UE5 FFI shared library cargo build --release -p oasis-ffi @@ -77,9 +80,18 @@ oasis-types (foundation: Color, Button, InputEvent, backend traits, error ty │ └── oasis-app (binary entry points: oasis-app, oasis-screenshot) ├── oasis-backend-ue5 (software RGBA framebuffer for Unreal Engine 5) │ └── oasis-ffi (cdylib C-ABI for UE5 integration) - └── oasis-backend-psp (excluded from workspace, PSP hardware via sceGu) + ├── oasis-backend-psp (excluded from workspace, PSP hardware via sceGu) + └── oasis-plugin-psp (excluded from workspace, kernel-mode PRX overlay) ``` +### PSP Two-Binary Architecture + +The PSP deployment uses two binaries: +- **`oasis-backend-psp`** (EBOOT.PBP) -- the full shell application, runs standalone +- **`oasis-plugin-psp`** (PRX) -- lightweight companion module loaded by CFW (ARK-4/PRO) via `PLUGINS.TXT`, stays resident in kernel memory alongside games + +The PRX hooks `sceDisplaySetFrameBuf` to draw overlay UI into the game's framebuffer and claims a PSP audio channel for background MP3 playback. No dependency on oasis-core -- direct framebuffer rendering only (<64KB binary). + ### Key Abstraction: Backend Traits `oasis-core/src/backend.rs` defines the only abstraction boundary between core and platform: diff --git a/README.md b/README.md index f0081c9..c677bf8 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Native virtual resolution is 480x272 (PSP native) across all backends. ## Crates -The framework is split into 16 workspace crates plus 1 excluded PSP backend: +The framework is split into 16 workspace crates plus 2 excluded PSP crates: ``` oasis-os/ @@ -101,6 +101,7 @@ oasis-os/ | +-- oasis-backend-sdl/ # SDL2 rendering and input (desktop + Pi) | +-- oasis-backend-ue5/ # UE5 software framebuffer + FFI input queue | +-- oasis-backend-psp/ # [excluded from workspace] sceGu hardware rendering, PSP controller, UMD browsing +| +-- oasis-plugin-psp/ # [excluded from workspace] kernel-mode PRX: in-game overlay + background music | +-- oasis-ffi/ # C FFI boundary for UE5 integration | +-- oasis-app/ # Binary entry points: desktop app + screenshot tool +-- skins/ @@ -129,10 +130,11 @@ oasis-os/ | `oasis-backend-sdl` | SDL2 rendering and input backend for desktop and Raspberry Pi | | `oasis-backend-ue5` | UE5 render target backend -- software RGBA framebuffer and FFI input queue | | `oasis-backend-psp` | PSP hardware backend -- sceGu sprite rendering, PSP controller input, dual-panel file manager, UMD disc browsing, std via [rust-psp](https://github.com/AndrewAltimit/rust-psp) SDK | +| `oasis-plugin-psp` | PSP overlay plugin PRX -- kernel-mode companion module for in-game overlay UI and background MP3 playback | | `oasis-ffi` | C-ABI FFI boundary (`cdylib`) for UE5 and external integrations | | `oasis-app` | Desktop entry point (SDL2) and screenshot capture tool | -The PSP backend is excluded from the workspace (requires `mipsel-sony-psp` target) and depends on the standalone [rust-psp SDK](https://github.com/AndrewAltimit/rust-psp) via git dependency. +The PSP crates are excluded from the workspace (require `mipsel-sony-psp` target) and depend on the standalone [rust-psp SDK](https://github.com/AndrewAltimit/rust-psp) via git dependency. The backend compiles to an EBOOT.PBP (standalone application), while the plugin compiles to a kernel-mode PRX (resident overlay module loaded by CFW via `PLUGINS.TXT`). ## Building @@ -156,6 +158,16 @@ RUST_PSP_BUILD_STD=1 cargo +nightly psp --release # Output: target/mipsel-sony-psp-std/release/EBOOT.PBP ``` +### PSP Overlay Plugin (PRX) + +```bash +cd crates/oasis-plugin-psp +RUST_PSP_BUILD_STD=1 cargo +nightly psp --release +# Output: target/mipsel-sony-psp-std/release/oasis_plugin.prx +``` + +See [PSP Plugin Guide](docs/psp-plugin.md) for installation and usage. + ### UE5 (FFI Library) ```bash @@ -229,6 +241,7 @@ GitHub Actions workflows run the full pipeline automatically on push to `main` a - [Technical Design Document](docs/design.md) -- architecture, backends, skins, UE5 integration, PSP implementation, VFS, plugin system, security considerations, migration strategy (v2.4, 1300+ lines) - [Skin Authoring Guide](docs/skin-authoring.md) -- creating custom skins, TOML file reference, theme derivation, effect system, runtime switching - [PSP Modernization Plan](docs/psp-modernization-plan.md) -- 9-phase, 40-step roadmap for PSP backend modernization using the rust-psp SDK +- [PSP Plugin Guide](docs/psp-plugin.md) -- installation, controls, and configuration for the in-game overlay PRX ## License diff --git a/crates/oasis-backend-psp/src/commands.rs b/crates/oasis-backend-psp/src/commands.rs index 0b0a9ef..d4f7f25 100644 --- a/crates/oasis-backend-psp/src/commands.rs +++ b/crates/oasis-backend-psp/src/commands.rs @@ -47,6 +47,9 @@ pub fn execute_command(cmd: &str, config: &mut psp::config::Config) -> Vec Vec cmd_mem(), _ if trimmed.starts_with("config ") => cmd_config(trimmed, config), "umd" | "umdinfo" => cmd_umd(), + "plugin install" => cmd_plugin_install(), + "plugin remove" => cmd_plugin_remove(), + "plugin status" => cmd_plugin_status(), + "plugin" => vec![ + String::from("Usage:"), + String::from(" plugin install - Install overlay PRX"), + String::from(" plugin remove - Remove overlay PRX"), + String::from(" plugin status - Show load status"), + ], "version" => vec![String::from("OASIS_OS v0.1.0")], "about" => vec![ String::from("OASIS_OS -- Embeddable OS Framework"), @@ -495,6 +507,150 @@ unsafe extern "C" fn me_sum_task(arg: i32) -> i32 { sum } +// --------------------------------------------------------------------------- +// Plugin management +// --------------------------------------------------------------------------- + +/// PRX source path (bundled alongside EBOOT in the GAME directory). +const PLUGIN_SRC: &str = "oasis_plugin.prx"; +/// PRX install destination on Memory Stick. +const PLUGIN_DST: &str = "ms0:/seplugins/oasis_plugin.prx"; +/// PLUGINS.TXT path. +const PLUGINS_TXT: &str = "ms0:/seplugins/PLUGINS.TXT"; +/// Line to add/remove in PLUGINS.TXT. +const PLUGIN_LINE: &str = "game, ms0:/seplugins/oasis_plugin.prx, on"; +/// Default oasis.ini content. +const DEFAULT_INI: &str = "\ +# OASIS OS Overlay Plugin Configuration\n\ +# Trigger button: note or screen\n\ +trigger = note\n\ +# Music directory\n\ +music_dir = ms0:/MUSIC/\n\ +# Overlay opacity (0-255)\n\ +opacity = 180\n\ +# Auto-start music on game launch\n\ +autoplay = false\n"; + +fn cmd_plugin_install() -> Vec { + let mut out = Vec::new(); + + // Ensure seplugins directory exists + let _ = psp::io::create_dir("ms0:/seplugins"); + + // Copy PRX file + match psp::io::read_to_vec(PLUGIN_SRC) { + Ok(data) => match psp::io::write_bytes(PLUGIN_DST, &data) { + Ok(()) => out.push(format!("Copied PRX to {}", PLUGIN_DST)), + Err(e) => { + out.push(format!("Failed to write PRX: {:?}", e)); + return out; + } + }, + Err(e) => { + out.push(format!("PRX not found ({}): {:?}", PLUGIN_SRC, e)); + out.push(String::from("Place oasis_plugin.prx next to EBOOT.PBP")); + return out; + } + } + + // Add to PLUGINS.TXT (if not already present) + let existing = psp::io::read_to_vec(PLUGINS_TXT).unwrap_or_default(); + let text = String::from_utf8_lossy(&existing); + if !text.contains("oasis_plugin.prx") { + let mut new_text = text.to_string(); + if !new_text.is_empty() && !new_text.ends_with('\n') { + new_text.push('\n'); + } + new_text.push_str(PLUGIN_LINE); + new_text.push('\n'); + match psp::io::write_bytes(PLUGINS_TXT, new_text.as_bytes()) { + Ok(()) => out.push(String::from("Added to PLUGINS.TXT")), + Err(e) => out.push(format!("Failed to update PLUGINS.TXT: {:?}", e)), + } + } else { + out.push(String::from("Already in PLUGINS.TXT")); + } + + // Write default config if it doesn't exist + let ini_path = "ms0:/seplugins/oasis.ini"; + if psp::io::read_to_vec(ini_path).is_err() { + let _ = psp::io::write_bytes(ini_path, DEFAULT_INI.as_bytes()); + out.push(String::from("Created oasis.ini with defaults")); + } + + out.push(String::from("Plugin installed. Reboot to activate.")); + out +} + +fn cmd_plugin_remove() -> Vec { + let mut out = Vec::new(); + + // Remove PRX file + match psp::io::remove_file(PLUGIN_DST) { + Ok(()) => out.push(format!("Removed {}", PLUGIN_DST)), + Err(e) => out.push(format!("PRX not found or remove failed: {:?}", e)), + } + + // Remove from PLUGINS.TXT + match psp::io::read_to_vec(PLUGINS_TXT) { + Ok(data) => { + let text = String::from_utf8_lossy(&data); + let filtered: Vec<&str> = text + .lines() + .filter(|l| !l.contains("oasis_plugin.prx")) + .collect(); + let new_text = filtered.join("\n") + "\n"; + match psp::io::write_bytes(PLUGINS_TXT, new_text.as_bytes()) { + Ok(()) => out.push(String::from("Removed from PLUGINS.TXT")), + Err(e) => out.push(format!("Failed to update PLUGINS.TXT: {:?}", e)), + } + } + Err(_) => out.push(String::from("PLUGINS.TXT not found")), + } + + out.push(String::from("Plugin removed. Reboot to deactivate.")); + out +} + +fn cmd_plugin_status() -> Vec { + let mut out = Vec::new(); + + // Check if PRX exists on Memory Stick + let prx_exists = psp::io::read_to_vec(PLUGIN_DST).is_ok(); + out.push(format!( + "PRX file: {}", + if prx_exists { "installed" } else { "not found" } + )); + + // Check PLUGINS.TXT + match psp::io::read_to_vec(PLUGINS_TXT) { + Ok(data) => { + let text = String::from_utf8_lossy(&data); + let entry = text.lines().find(|l| l.contains("oasis_plugin.prx")); + match entry { + Some(line) => { + let enabled = line.contains(", on"); + out.push(format!( + "PLUGINS.TXT: {}", + if enabled { "enabled" } else { "disabled" } + )); + } + None => out.push(String::from("PLUGINS.TXT: not registered")), + } + } + Err(_) => out.push(String::from("PLUGINS.TXT: not found")), + } + + // Check if config exists + let ini_exists = psp::io::read_to_vec("ms0:/seplugins/oasis.ini").is_ok(); + out.push(format!( + "Config: {}", + if ini_exists { "oasis.ini found" } else { "no config" } + )); + + out +} + #[cfg(feature = "kernel-me")] fn me_compute_test() -> Result<(i32, u64), String> { let mut executor = psp::me::MeExecutor::new(4096).map_err(|e| format!("ME init: {e}"))?; diff --git a/crates/oasis-plugin-psp/Cargo.toml b/crates/oasis-plugin-psp/Cargo.toml new file mode 100644 index 0000000..cde734d --- /dev/null +++ b/crates/oasis-plugin-psp/Cargo.toml @@ -0,0 +1,32 @@ +# Standalone crate -- excluded from the oasis_os workspace because it +# requires the mipsel-sony-psp target and the rust-psp SDK. +# +# Builds a kernel-mode PRX plugin that stays resident alongside games, +# providing in-game overlay UI and background MP3 playback. +# +# Build with: RUST_PSP_BUILD_STD=1 cargo +nightly psp --release + +[workspace] + +[package] +name = "oasis-plugin-psp" +description = "PSP overlay plugin PRX: in-game overlay + background music for OASIS_OS" +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/AndrewAltimit/oasis-os" +authors = ["AndrewAltimit"] + +[dependencies] +psp = { git = "https://github.com/AndrewAltimit/rust-psp", branch = "main", features = ["kernel", "std"] } + +# unicode-width workaround for mips target panic. +[patch.crates-io] +unicode-width = { git = "https://git.sr.ht/~sajattack/unicode-width" } + +[profile.release] +opt-level = "z" # minimize size (<64KB target) +lto = true +codegen-units = 1 +panic = "abort" +# Do NOT set strip = true -- it conflicts with --emit-relocs needed by prxgen. diff --git a/crates/oasis-plugin-psp/src/audio.rs b/crates/oasis-plugin-psp/src/audio.rs new file mode 100644 index 0000000..683e919 --- /dev/null +++ b/crates/oasis-plugin-psp/src/audio.rs @@ -0,0 +1,537 @@ +//! Background MP3 playback via the PSP's hardware MP3 decoder. +//! +//! Runs in a dedicated kernel thread, streaming MP3 files from +//! `ms0:/MUSIC/` through `sceMp3*` APIs to a reserved audio channel. +//! +//! The playlist is built by scanning the music directory at startup. +//! No heap allocator is needed for the core decode loop -- static buffers +//! are used for MP3 stream data and PCM output. + +use crate::overlay; + +use core::ffi::c_void; +use core::sync::atomic::{AtomicBool, AtomicI32, AtomicU8, Ordering}; + +/// Maximum number of tracks in the playlist. +const MAX_TRACKS: usize = 32; +/// Maximum filename length (null-terminated). +const MAX_FILENAME: usize = 64; +/// MP3 stream buffer size (fed to the hardware decoder). +const MP3_BUF_SIZE: usize = 8 * 1024; +/// PCM output buffer (stereo i16, 1152 samples * 2 channels). +const PCM_BUF_SIZE: usize = 4608; +/// Audio output volume (0-0x8000). +const DEFAULT_VOLUME: i32 = 0x6000; +/// Volume step for up/down. +const VOLUME_STEP: i32 = 0x800; +/// Audio thread stack size. +const AUDIO_STACK_SIZE: i32 = 8192; +/// Audio thread priority (lower = higher priority, 30 is moderate). +const AUDIO_PRIORITY: i32 = 30; +/// File read buffer for streaming. +const FILE_BUF_SIZE: usize = 16 * 1024; + +/// Playlist: array of null-terminated filenames. +static mut PLAYLIST: [[u8; MAX_FILENAME]; MAX_TRACKS] = [[0u8; MAX_FILENAME]; MAX_TRACKS]; +/// Number of tracks in the playlist. +static mut TRACK_COUNT: usize = 0; +/// Current track index. +static CURRENT_TRACK: AtomicU8 = AtomicU8::new(0); +/// Current volume. +static VOLUME: AtomicI32 = AtomicI32::new(DEFAULT_VOLUME); +/// Playback paused flag. +static PAUSED: AtomicBool = AtomicBool::new(false); +/// Audio thread running flag. +static RUNNING: AtomicBool = AtomicBool::new(false); +/// Track changed flag (signals audio thread to restart decode). +static TRACK_CHANGED: AtomicBool = AtomicBool::new(false); + +/// Music directory prefix (from config). +static mut MUSIC_DIR: [u8; 64] = [0u8; 64]; +static mut MUSIC_DIR_LEN: usize = 0; + +/// Get the current track's display name (for the overlay). +pub fn current_track_name() -> &'static [u8] { + // SAFETY: PLAYLIST is read-only after scan_playlist(). + unsafe { + let idx = CURRENT_TRACK.load(Ordering::Relaxed) as usize; + if idx < TRACK_COUNT { + &PLAYLIST[idx] + } else { + b"\0" + } + } +} + +/// Toggle play/pause. +pub fn toggle_playback() { + let was_paused = PAUSED.load(Ordering::Relaxed); + PAUSED.store(!was_paused, Ordering::Relaxed); + if was_paused { + overlay::show_osd(b"Music: Playing"); + } else { + overlay::show_osd(b"Music: Paused"); + } +} + +/// Skip to next track. +pub fn next_track() { + // SAFETY: TRACK_COUNT is read-only after init. + let count = unsafe { TRACK_COUNT }; + if count == 0 { + return; + } + let cur = CURRENT_TRACK.load(Ordering::Relaxed); + let next = if (cur as usize + 1) >= count { + 0 + } else { + cur + 1 + }; + CURRENT_TRACK.store(next, Ordering::Relaxed); + TRACK_CHANGED.store(true, Ordering::Release); + overlay::show_osd(b"Next track"); +} + +/// Skip to previous track. +pub fn prev_track() { + // SAFETY: TRACK_COUNT is read-only after init. + let count = unsafe { TRACK_COUNT }; + if count == 0 { + return; + } + let cur = CURRENT_TRACK.load(Ordering::Relaxed); + let prev = if cur == 0 { + (count - 1) as u8 + } else { + cur - 1 + }; + CURRENT_TRACK.store(prev, Ordering::Relaxed); + TRACK_CHANGED.store(true, Ordering::Release); + overlay::show_osd(b"Prev track"); +} + +/// Increase volume. +pub fn volume_up() { + let vol = VOLUME.load(Ordering::Relaxed); + let new_vol = (vol + VOLUME_STEP).min(0x8000); + VOLUME.store(new_vol, Ordering::Relaxed); + overlay::show_osd(b"Volume Up"); +} + +/// Decrease volume. +pub fn volume_down() { + let vol = VOLUME.load(Ordering::Relaxed); + let new_vol = (vol - VOLUME_STEP).max(0); + VOLUME.store(new_vol, Ordering::Relaxed); + overlay::show_osd(b"Volume Down"); +} + +/// Start the background audio thread. +pub fn start_audio_thread() { + if RUNNING.load(Ordering::Relaxed) { + return; + } + + // Copy music dir from config + let cfg = crate::config::get_config(); + // SAFETY: Single-threaded init. + unsafe { + let len = cfg.music_dir_len.min(MUSIC_DIR.len() - 1); + let mut i = 0; + while i < len { + MUSIC_DIR[i] = cfg.music_dir[i]; + i += 1; + } + MUSIC_DIR[len] = 0; + MUSIC_DIR_LEN = len; + } + + // Scan playlist + scan_playlist(); + + // SAFETY: TRACK_COUNT is set by scan_playlist. + if unsafe { TRACK_COUNT } == 0 { + overlay::show_osd(b"No MP3 files found"); + return; + } + + // Create kernel thread for audio playback + // SAFETY: Creating a kernel thread with valid parameters. + unsafe { + let tid = psp::sys::sceKernelCreateThread( + b"OasisAudio\0".as_ptr(), + audio_thread_entry, + AUDIO_PRIORITY, + AUDIO_STACK_SIZE, + psp::sys::ThreadAttributes::empty(), + core::ptr::null_mut(), + ); + if tid >= 0 { + RUNNING.store(true, Ordering::Release); + psp::sys::sceKernelStartThread(tid, 0, core::ptr::null_mut()); + } + } +} + +/// Scan the music directory for MP3 files. +fn scan_playlist() { + // Build null-terminated path + // SAFETY: MUSIC_DIR is valid after init. + let dir_path = unsafe { &MUSIC_DIR[..MUSIC_DIR_LEN + 1] }; + + // SAFETY: sceIoDopen with null-terminated path. + let dfd = unsafe { psp::sys::sceIoDopen(dir_path.as_ptr()) }; + if dfd < 0 { + return; + } + + let mut dirent = unsafe { core::mem::zeroed::() }; + + // SAFETY: Iterating directory entries. + unsafe { + while TRACK_COUNT < MAX_TRACKS { + let ret = psp::sys::sceIoDread(dfd, &mut dirent); + if ret <= 0 { + break; + } + + // Check if it's a regular file ending in .mp3 or .MP3 + let name_ptr = dirent.d_name.as_ptr() as *const u8; + let mut name_len = 0; + while name_len < 256 && *name_ptr.add(name_len) != 0 { + name_len += 1; + } + + if name_len < 5 { + continue; + } + + // Check .mp3 extension (case-insensitive) + let ext_start = name_len - 4; + let b1 = (*name_ptr.add(ext_start)).to_ascii_lowercase(); + let b2 = (*name_ptr.add(ext_start + 1)).to_ascii_lowercase(); + let b3 = (*name_ptr.add(ext_start + 2)).to_ascii_lowercase(); + let b4 = (*name_ptr.add(ext_start + 3)).to_ascii_lowercase(); + + if b1 != b'.' || b2 != b'm' || b3 != b'p' || b4 != b'3' { + continue; + } + + // Store filename (just the name, not full path) + let store_len = name_len.min(MAX_FILENAME - 1); + let mut i = 0; + while i < store_len { + PLAYLIST[TRACK_COUNT][i] = *name_ptr.add(i); + i += 1; + } + PLAYLIST[TRACK_COUNT][store_len] = 0; + TRACK_COUNT += 1; + } + + psp::sys::sceIoDclose(dfd); + } +} + +/// Build a full path for a track: music_dir + filename. +/// +/// Returns the length of the path (excluding null terminator). +fn build_track_path(buf: &mut [u8; 128], track_idx: usize) -> usize { + // SAFETY: MUSIC_DIR and PLAYLIST are valid after init. + unsafe { + let mut pos = 0; + + // Copy music dir + let mut i = 0; + while i < MUSIC_DIR_LEN && pos < 127 { + buf[pos] = MUSIC_DIR[i]; + pos += 1; + i += 1; + } + + // Ensure trailing slash + if pos > 0 && buf[pos - 1] != b'/' { + buf[pos] = b'/'; + pos += 1; + } + + // Copy filename + i = 0; + while i < MAX_FILENAME && PLAYLIST[track_idx][i] != 0 && pos < 127 { + buf[pos] = PLAYLIST[track_idx][i]; + pos += 1; + i += 1; + } + + buf[pos] = 0; + pos + } +} + +/// Audio thread entry point. +/// +/// Loops: open MP3 file -> init decoder -> decode+output loop -> next track. +/// +/// # Safety +/// Called as a PSP kernel thread entry point. +unsafe extern "C" fn audio_thread_entry(_args: usize, _argp: *mut c_void) -> i32 { + // Static buffers for MP3 decode (no heap) + static mut MP3_BUF: [u8; MP3_BUF_SIZE] = [0u8; MP3_BUF_SIZE]; + static mut PCM_BUF: [i16; PCM_BUF_SIZE] = [0i16; PCM_BUF_SIZE]; + static mut FILE_BUF: [u8; FILE_BUF_SIZE] = [0u8; FILE_BUF_SIZE]; + + // Reserve an audio channel + let channel = unsafe { + psp::sys::sceAudioChReserve( + psp::sys::AUDIO_NEXT_CHANNEL, + psp::sys::audio_sample_align(1152), + psp::sys::AudioFormat::Stereo, + ) + }; + if channel < 0 { + overlay::show_osd(b"Audio: no channel"); + RUNNING.store(false, Ordering::Release); + return -1; + } + + while RUNNING.load(Ordering::Relaxed) { + let track_idx = CURRENT_TRACK.load(Ordering::Relaxed) as usize; + // SAFETY: TRACK_COUNT is read-only after init. + let track_count = unsafe { TRACK_COUNT }; + if track_idx >= track_count { + // SAFETY: sleep to avoid busy loop. + unsafe { + psp::sys::sceKernelDelayThread(100_000); + } + continue; + } + + // Build full path + let mut path_buf = [0u8; 128]; + build_track_path(&mut path_buf, track_idx); + + // Open MP3 file + // SAFETY: path_buf is null-terminated. + let fd = unsafe { + psp::sys::sceIoOpen( + path_buf.as_ptr(), + psp::sys::IoOpenFlags::RD_ONLY, + 0, + ) + }; + if fd < 0 { + // Skip to next track on error + advance_track(); + continue; + } + + // Get file size + // SAFETY: fd is valid. + let file_size = unsafe { psp::sys::sceIoLseek(fd, 0, psp::sys::IoWhence::End) } as u32; + unsafe { + psp::sys::sceIoLseek(fd, 0, psp::sys::IoWhence::Set); + } + + // Init MP3 resource + // SAFETY: sceMp3 syscalls. + let mp3_ret = unsafe { psp::sys::sceMp3InitResource() }; + if mp3_ret < 0 { + unsafe { + psp::sys::sceIoClose(fd); + } + overlay::show_osd(b"MP3 init failed"); + advance_track(); + continue; + } + + // Reserve MP3 handle with static buffers + // SAFETY: Static buffers are valid, single audio thread. + let mut init_arg = psp::sys::SceMp3InitArg { + mp3_stream_start: 0, + unk1: 0, + mp3_stream_end: file_size, + unk2: 0, + mp3_buf: unsafe { MP3_BUF.as_mut_ptr() as *mut c_void }, + mp3_buf_size: MP3_BUF_SIZE as i32, + pcm_buf: unsafe { PCM_BUF.as_mut_ptr() as *mut c_void }, + pcm_buf_size: (PCM_BUF_SIZE * 2) as i32, + }; + + let handle_id = unsafe { psp::sys::sceMp3ReserveMp3Handle(&mut init_arg) }; + if handle_id < 0 { + unsafe { + psp::sys::sceMp3TermResource(); + psp::sys::sceIoClose(fd); + } + advance_track(); + continue; + } + let handle = psp::sys::Mp3Handle(handle_id); + + // Feed initial data from file + // SAFETY: Static buffers, fd is valid. + let feed_ok = unsafe { feed_mp3_from_file(handle, fd, &mut FILE_BUF) }; + if !feed_ok { + unsafe { + psp::sys::sceMp3ReleaseMp3Handle(handle); + psp::sys::sceMp3TermResource(); + psp::sys::sceIoClose(fd); + } + advance_track(); + continue; + } + + // Initialize decoder + let init_ret = unsafe { psp::sys::sceMp3Init(handle) }; + if init_ret < 0 { + unsafe { + psp::sys::sceMp3ReleaseMp3Handle(handle); + psp::sys::sceMp3TermResource(); + psp::sys::sceIoClose(fd); + } + advance_track(); + continue; + } + + TRACK_CHANGED.store(false, Ordering::Release); + + // Decode + output loop + loop { + if !RUNNING.load(Ordering::Relaxed) || TRACK_CHANGED.load(Ordering::Relaxed) { + break; + } + + // Handle pause + if PAUSED.load(Ordering::Relaxed) { + // SAFETY: Sleep while paused. + unsafe { + psp::sys::sceKernelDelayThread(50_000); + } + continue; + } + + // Feed more data if needed + // SAFETY: handle and fd are valid. + unsafe { + if psp::sys::sceMp3CheckStreamDataNeeded(handle) > 0 { + if !feed_mp3_from_file(handle, fd, &mut FILE_BUF) { + break; // EOF or error + } + } + } + + // Decode a frame + let mut out_ptr: *mut i16 = core::ptr::null_mut(); + let decoded = unsafe { psp::sys::sceMp3Decode(handle, &mut out_ptr) }; + if decoded <= 0 || out_ptr.is_null() { + break; // End of track + } + + // Output decoded PCM to audio channel + let vol = VOLUME.load(Ordering::Relaxed); + // SAFETY: out_ptr is valid PCM data from the decoder. + unsafe { + psp::sys::sceAudioOutputBlocking(channel, vol, out_ptr as *mut c_void); + } + } + + // Cleanup + unsafe { + psp::sys::sceMp3ReleaseMp3Handle(handle); + psp::sys::sceMp3TermResource(); + psp::sys::sceIoClose(fd); + } + + // If track wasn't manually changed, advance to next + if !TRACK_CHANGED.load(Ordering::Relaxed) { + advance_track(); + } + } + + // Release audio channel + unsafe { + psp::sys::sceAudioChRelease(channel); + } + RUNNING.store(false, Ordering::Release); + 0 +} + +/// Feed MP3 data from a file to the decoder's stream buffer. +/// +/// # Safety +/// `handle` must be a valid MP3 handle, `fd` a valid file descriptor, +/// `file_buf` must be a valid mutable buffer. +unsafe fn feed_mp3_from_file( + handle: psp::sys::Mp3Handle, + fd: psp::sys::SceUid, + file_buf: &mut [u8; FILE_BUF_SIZE], +) -> bool { + let mut dst_ptr: *mut u8 = core::ptr::null_mut(); + let mut to_write: i32 = 0; + let mut src_pos: i32 = 0; + + // SAFETY: sceMp3 syscalls with valid handle. + let ret = unsafe { + psp::sys::sceMp3GetInfoToAddStreamData(handle, &mut dst_ptr, &mut to_write, &mut src_pos) + }; + if ret < 0 || to_write <= 0 || dst_ptr.is_null() { + return false; + } + + // Seek to the position the decoder wants + // SAFETY: fd is valid. + unsafe { + psp::sys::sceIoLseek(fd, src_pos as i64, psp::sys::IoWhence::Set); + } + + // Read from file in chunks + let mut total_read = 0i32; + while total_read < to_write { + let chunk = ((to_write - total_read) as usize).min(FILE_BUF_SIZE); + // SAFETY: file_buf is valid, fd is valid. + let bytes_read = unsafe { + psp::sys::sceIoRead(fd, file_buf.as_mut_ptr() as *mut _, chunk as u32) + }; + if bytes_read <= 0 { + break; + } + + // Copy to decoder buffer + // SAFETY: dst_ptr is valid memory from sceMp3GetInfoToAddStreamData. + let mut i = 0; + while i < bytes_read as usize { + unsafe { + *dst_ptr.add(total_read as usize + i) = file_buf[i]; + } + i += 1; + } + total_read += bytes_read; + } + + if total_read <= 0 { + // SAFETY: Notify decoder with 0 bytes (EOF). + unsafe { + psp::sys::sceMp3NotifyAddStreamData(handle, 0); + } + return false; + } + + // SAFETY: Notify decoder of added data. + let ret = unsafe { psp::sys::sceMp3NotifyAddStreamData(handle, total_read) }; + ret >= 0 +} + +/// Advance to the next track (wrap around). +fn advance_track() { + // SAFETY: TRACK_COUNT is read-only. + let count = unsafe { TRACK_COUNT }; + if count == 0 { + return; + } + let cur = CURRENT_TRACK.load(Ordering::Relaxed); + let next = if (cur as usize + 1) >= count { + 0 + } else { + cur + 1 + }; + CURRENT_TRACK.store(next, Ordering::Relaxed); +} diff --git a/crates/oasis-plugin-psp/src/config.rs b/crates/oasis-plugin-psp/src/config.rs new file mode 100644 index 0000000..909955b --- /dev/null +++ b/crates/oasis-plugin-psp/src/config.rs @@ -0,0 +1,241 @@ +//! Configuration file parser for `ms0:/seplugins/oasis.ini`. +//! +//! Simple line-by-line INI parser using `sceIoOpen`/`sceIoRead` -- no serde, +//! no allocator. All config values are stored in a static struct. +//! +//! ```ini +//! # Overlay trigger button (default: NOTE) +//! trigger = note +//! # Music directory +//! music_dir = ms0:/MUSIC/ +//! # Overlay opacity (0-255) +//! opacity = 180 +//! # Auto-start music on game launch +//! autoplay = false +//! ``` + +use core::sync::atomic::{AtomicU8, Ordering}; + +/// Maximum path length for config strings. +const MAX_PATH: usize = 64; + +/// Config file path on Memory Stick. +const CONFIG_PATH: &[u8] = b"ms0:/seplugins/oasis.ini\0"; + +/// Trigger button options. +#[derive(Copy, Clone, PartialEq, Eq)] +#[repr(u8)] +pub enum TriggerButton { + /// NOTE button (0x800000) -- kernel-only, default. + Note = 0, + /// SCREEN button (0x400000) -- kernel-only. + Screen = 1, +} + +/// Static plugin configuration. +#[derive(Copy, Clone)] +pub struct PluginConfig { + /// Which button triggers the overlay. + pub trigger: TriggerButton, + /// Music directory path (null-terminated). + pub music_dir: [u8; MAX_PATH], + /// Music directory path length (excluding null). + pub music_dir_len: usize, + /// Overlay background opacity (0-255). + pub opacity: u8, + /// Auto-start music playback on plugin load. + pub autoplay: bool, +} + +impl PluginConfig { + const fn default() -> Self { + // "ms0:/MUSIC/" as bytes + let mut dir = [0u8; MAX_PATH]; + let src = b"ms0:/MUSIC/"; + let mut i = 0; + while i < src.len() { + dir[i] = src[i]; + i += 1; + } + Self { + trigger: TriggerButton::Note, + music_dir: dir, + music_dir_len: 11, + opacity: 180, + autoplay: false, + } + } + + /// Get music directory as a byte slice (with null terminator). + pub fn music_dir_str(&self) -> &[u8] { + &self.music_dir[..self.music_dir_len + 1] + } + + /// Get the trigger button mask for controller polling. + pub fn trigger_mask(&self) -> u32 { + match self.trigger { + TriggerButton::Note => 0x00800000, + TriggerButton::Screen => 0x00400000, + } + } +} + +/// Atomic opacity (updated from config, read from hook). +static OPACITY: AtomicU8 = AtomicU8::new(180); + +/// Static config storage -- written once at startup, read-only after. +static mut CONFIG: PluginConfig = PluginConfig::default(); + +/// Get the current plugin configuration. +/// +/// # Safety +/// Safe to call after `load_config()` has returned. The config is read-only +/// after initialization. +pub fn get_config() -> PluginConfig { + // SAFETY: CONFIG is only written in load_config() during single-threaded + // init, then read-only afterwards. + unsafe { CONFIG } +} + +/// Get overlay opacity (atomic, safe from any thread). +pub fn get_opacity() -> u8 { + OPACITY.load(Ordering::Relaxed) +} + +/// Load and parse the configuration file. Falls back to defaults on error. +pub fn load_config() { + let mut buf = [0u8; 512]; + + // SAFETY: sceIoOpen with read-only flags, null-terminated path. + let fd = unsafe { + psp::sys::sceIoOpen( + CONFIG_PATH.as_ptr(), + psp::sys::IoOpenFlags::RD_ONLY, + 0, + ) + }; + if fd < 0 { + return; // File doesn't exist, use defaults. + } + + // SAFETY: fd is valid, buf is on stack. + let bytes_read = + unsafe { psp::sys::sceIoRead(fd, buf.as_mut_ptr() as *mut _, buf.len() as u32) }; + // SAFETY: Close the file descriptor. + unsafe { + psp::sys::sceIoClose(fd); + } + + if bytes_read <= 0 { + return; + } + let data = &buf[..bytes_read as usize]; + + // SAFETY: Single-threaded init, CONFIG not yet shared. + unsafe { + parse_config(data, &mut CONFIG); + OPACITY.store(CONFIG.opacity, Ordering::Relaxed); + } +} + +/// Parse INI-style config data into a `PluginConfig`. +fn parse_config(data: &[u8], config: &mut PluginConfig) { + // Process each line + let mut start = 0; + while start < data.len() { + // Find end of line + let mut end = start; + while end < data.len() && data[end] != b'\n' && data[end] != b'\r' { + end += 1; + } + let line = &data[start..end]; + + // Skip to next line + start = end; + while start < data.len() && (data[start] == b'\n' || data[start] == b'\r') { + start += 1; + } + + // Skip empty lines and comments + let line = trim_bytes(line); + if line.is_empty() || line[0] == b'#' { + continue; + } + + // Find '=' separator + if let Some(eq_pos) = line.iter().position(|&b| b == b'=') { + let key = trim_bytes(&line[..eq_pos]); + let val = trim_bytes(&line[eq_pos + 1..]); + + if bytes_eq_ci(key, b"trigger") { + if bytes_eq_ci(val, b"screen") { + config.trigger = TriggerButton::Screen; + } else { + config.trigger = TriggerButton::Note; + } + } else if bytes_eq_ci(key, b"music_dir") { + let len = val.len().min(MAX_PATH - 1); + let mut i = 0; + while i < len { + config.music_dir[i] = val[i]; + i += 1; + } + config.music_dir[len] = 0; + config.music_dir_len = len; + } else if bytes_eq_ci(key, b"opacity") { + if let Some(n) = parse_u8(val) { + config.opacity = n; + } + } else if bytes_eq_ci(key, b"autoplay") { + config.autoplay = + bytes_eq_ci(val, b"true") || bytes_eq_ci(val, b"1") || bytes_eq_ci(val, b"yes"); + } + } + } +} + +/// Trim leading/trailing whitespace from a byte slice. +fn trim_bytes(s: &[u8]) -> &[u8] { + let mut start = 0; + while start < s.len() && s[start].is_ascii_whitespace() { + start += 1; + } + let mut end = s.len(); + while end > start && s[end - 1].is_ascii_whitespace() { + end -= 1; + } + &s[start..end] +} + +/// Case-insensitive byte comparison. +fn bytes_eq_ci(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i].to_ascii_lowercase() != b[i].to_ascii_lowercase() { + return false; + } + i += 1; + } + true +} + +/// Parse a byte slice as a u8 decimal number. +fn parse_u8(s: &[u8]) -> Option { + if s.is_empty() { + return None; + } + let mut result: u16 = 0; + for &b in s { + if !b.is_ascii_digit() { + return None; + } + result = result * 10 + (b - b'0') as u16; + if result > 255 { + return None; + } + } + Some(result as u8) +} diff --git a/crates/oasis-plugin-psp/src/font.rs b/crates/oasis-plugin-psp/src/font.rs new file mode 100644 index 0000000..95501f5 --- /dev/null +++ b/crates/oasis-plugin-psp/src/font.rs @@ -0,0 +1,131 @@ +//! Minimal 8x8 bitmap font for overlay text rendering. +//! +//! Covers printable ASCII (0x20 - 0x7E, 95 glyphs). Each glyph is 8 rows +//! of 8 bits, MSB = leftmost pixel. Based on the classic IBM CGA/BIOS 8x8 +//! font (public domain). +//! +//! Copied from `crates/oasis-backend-psp/src/font.rs` (bitmap data only, +//! no system font dependency). + +/// Width of each glyph in pixels. +pub const GLYPH_WIDTH: u32 = 8; + +/// Height of each glyph in pixels. +pub const GLYPH_HEIGHT: u32 = 8; + +/// First printable ASCII code in the table. +const FIRST_CHAR: u8 = 0x20; + +/// Last printable ASCII code in the table. +const LAST_CHAR: u8 = 0x7E; + +/// 95 glyphs, 8 bytes each (ASCII 32-126). +#[rustfmt::skip] +static FONT_DATA: [[u8; 8]; 95] = [ + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // (32) + [0x18, 0x18, 0x18, 0x18, 0x18, 0x00, 0x18, 0x00], // ! (33) + [0x6C, 0x6C, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00], // " (34) + [0x6C, 0x6C, 0xFE, 0x6C, 0xFE, 0x6C, 0x6C, 0x00], // # (35) + [0x18, 0x3E, 0x60, 0x3C, 0x06, 0x7C, 0x18, 0x00], // $ (36) + [0x00, 0xC6, 0xCC, 0x18, 0x30, 0x66, 0xC6, 0x00], // % (37) + [0x38, 0x6C, 0x38, 0x76, 0xDC, 0xCC, 0x76, 0x00], // & (38) + [0x18, 0x18, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00], // ' (39) + [0x0C, 0x18, 0x30, 0x30, 0x30, 0x18, 0x0C, 0x00], // ( (40) + [0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x18, 0x30, 0x00], // ) (41) + [0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00], // * (42) + [0x00, 0x18, 0x18, 0x7E, 0x18, 0x18, 0x00, 0x00], // + (43) + [0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x30], // , (44) + [0x00, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x00, 0x00], // - (45) + [0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00], // . (46) + [0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0, 0x80, 0x00], // / (47) + [0x3C, 0x66, 0x6E, 0x7E, 0x76, 0x66, 0x3C, 0x00], // 0 (48) + [0x18, 0x38, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00], // 1 (49) + [0x3C, 0x66, 0x06, 0x0C, 0x18, 0x30, 0x7E, 0x00], // 2 (50) + [0x3C, 0x66, 0x06, 0x1C, 0x06, 0x66, 0x3C, 0x00], // 3 (51) + [0x0C, 0x1C, 0x3C, 0x6C, 0x7E, 0x0C, 0x0C, 0x00], // 4 (52) + [0x7E, 0x60, 0x7C, 0x06, 0x06, 0x66, 0x3C, 0x00], // 5 (53) + [0x1C, 0x30, 0x60, 0x7C, 0x66, 0x66, 0x3C, 0x00], // 6 (54) + [0x7E, 0x06, 0x0C, 0x18, 0x30, 0x30, 0x30, 0x00], // 7 (55) + [0x3C, 0x66, 0x66, 0x3C, 0x66, 0x66, 0x3C, 0x00], // 8 (56) + [0x3C, 0x66, 0x66, 0x3E, 0x06, 0x0C, 0x38, 0x00], // 9 (57) + [0x00, 0x00, 0x18, 0x18, 0x00, 0x18, 0x18, 0x00], // : (58) + [0x00, 0x00, 0x18, 0x18, 0x00, 0x18, 0x18, 0x30], // ; (59) + [0x0C, 0x18, 0x30, 0x60, 0x30, 0x18, 0x0C, 0x00], // < (60) + [0x00, 0x00, 0x7E, 0x00, 0x7E, 0x00, 0x00, 0x00], // = (61) + [0x30, 0x18, 0x0C, 0x06, 0x0C, 0x18, 0x30, 0x00], // > (62) + [0x3C, 0x66, 0x06, 0x0C, 0x18, 0x00, 0x18, 0x00], // ? (63) + [0x3C, 0x66, 0x6E, 0x6A, 0x6E, 0x60, 0x3C, 0x00], // @ (64) + [0x18, 0x3C, 0x66, 0x66, 0x7E, 0x66, 0x66, 0x00], // A (65) + [0x7C, 0x66, 0x66, 0x7C, 0x66, 0x66, 0x7C, 0x00], // B (66) + [0x3C, 0x66, 0x60, 0x60, 0x60, 0x66, 0x3C, 0x00], // C (67) + [0x78, 0x6C, 0x66, 0x66, 0x66, 0x6C, 0x78, 0x00], // D (68) + [0x7E, 0x60, 0x60, 0x7C, 0x60, 0x60, 0x7E, 0x00], // E (69) + [0x7E, 0x60, 0x60, 0x7C, 0x60, 0x60, 0x60, 0x00], // F (70) + [0x3C, 0x66, 0x60, 0x6E, 0x66, 0x66, 0x3E, 0x00], // G (71) + [0x66, 0x66, 0x66, 0x7E, 0x66, 0x66, 0x66, 0x00], // H (72) + [0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00], // I (73) + [0x06, 0x06, 0x06, 0x06, 0x06, 0x66, 0x3C, 0x00], // J (74) + [0x66, 0x6C, 0x78, 0x70, 0x78, 0x6C, 0x66, 0x00], // K (75) + [0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x7E, 0x00], // L (76) + [0xC6, 0xEE, 0xFE, 0xD6, 0xC6, 0xC6, 0xC6, 0x00], // M (77) + [0xC6, 0xE6, 0xF6, 0xDE, 0xCE, 0xC6, 0xC6, 0x00], // N (78) + [0x3C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00], // O (79) + [0x7C, 0x66, 0x66, 0x7C, 0x60, 0x60, 0x60, 0x00], // P (80) + [0x3C, 0x66, 0x66, 0x66, 0x6A, 0x6C, 0x36, 0x00], // Q (81) + [0x7C, 0x66, 0x66, 0x7C, 0x6C, 0x66, 0x66, 0x00], // R (82) + [0x3C, 0x66, 0x60, 0x3C, 0x06, 0x66, 0x3C, 0x00], // S (83) + [0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00], // T (84) + [0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00], // U (85) + [0x66, 0x66, 0x66, 0x66, 0x3C, 0x3C, 0x18, 0x00], // V (86) + [0xC6, 0xC6, 0xC6, 0xD6, 0xFE, 0xEE, 0xC6, 0x00], // W (87) + [0x66, 0x66, 0x3C, 0x18, 0x3C, 0x66, 0x66, 0x00], // X (88) + [0x66, 0x66, 0x66, 0x3C, 0x18, 0x18, 0x18, 0x00], // Y (89) + [0x7E, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x7E, 0x00], // Z (90) + [0x3C, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3C, 0x00], // [ (91) + [0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x02, 0x00], // \ (92) + [0x3C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x3C, 0x00], // ] (93) + [0x10, 0x38, 0x6C, 0xC6, 0x00, 0x00, 0x00, 0x00], // ^ (94) + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFE], // _ (95) + [0x30, 0x18, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00], // ` (96) + [0x00, 0x00, 0x3C, 0x06, 0x3E, 0x66, 0x3E, 0x00], // a (97) + [0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x7C, 0x00], // b (98) + [0x00, 0x00, 0x3C, 0x66, 0x60, 0x66, 0x3C, 0x00], // c (99) + [0x06, 0x06, 0x3E, 0x66, 0x66, 0x66, 0x3E, 0x00], // d (100) + [0x00, 0x00, 0x3C, 0x66, 0x7E, 0x60, 0x3C, 0x00], // e (101) + [0x0E, 0x18, 0x18, 0x7E, 0x18, 0x18, 0x18, 0x00], // f (102) + [0x00, 0x00, 0x3E, 0x66, 0x66, 0x3E, 0x06, 0x3C], // g (103) + [0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x00], // h (104) + [0x18, 0x00, 0x38, 0x18, 0x18, 0x18, 0x3C, 0x00], // i (105) + [0x06, 0x00, 0x06, 0x06, 0x06, 0x06, 0x66, 0x3C], // j (106) + [0x60, 0x60, 0x66, 0x6C, 0x78, 0x6C, 0x66, 0x00], // k (107) + [0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00], // l (108) + [0x00, 0x00, 0xEC, 0xFE, 0xD6, 0xC6, 0xC6, 0x00], // m (109) + [0x00, 0x00, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x00], // n (110) + [0x00, 0x00, 0x3C, 0x66, 0x66, 0x66, 0x3C, 0x00], // o (111) + [0x00, 0x00, 0x7C, 0x66, 0x66, 0x7C, 0x60, 0x60], // p (112) + [0x00, 0x00, 0x3E, 0x66, 0x66, 0x3E, 0x06, 0x06], // q (113) + [0x00, 0x00, 0x7C, 0x66, 0x60, 0x60, 0x60, 0x00], // r (114) + [0x00, 0x00, 0x3E, 0x60, 0x3C, 0x06, 0x7C, 0x00], // s (115) + [0x18, 0x18, 0x7E, 0x18, 0x18, 0x18, 0x0E, 0x00], // t (116) + [0x00, 0x00, 0x66, 0x66, 0x66, 0x66, 0x3E, 0x00], // u (117) + [0x00, 0x00, 0x66, 0x66, 0x66, 0x3C, 0x18, 0x00], // v (118) + [0x00, 0x00, 0xC6, 0xC6, 0xD6, 0xFE, 0x6C, 0x00], // w (119) + [0x00, 0x00, 0x66, 0x3C, 0x18, 0x3C, 0x66, 0x00], // x (120) + [0x00, 0x00, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x3C], // y (121) + [0x00, 0x00, 0x7E, 0x0C, 0x18, 0x30, 0x7E, 0x00], // z (122) + [0x0E, 0x18, 0x18, 0x70, 0x18, 0x18, 0x0E, 0x00], // { (123) + [0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00], // | (124) + [0x70, 0x18, 0x18, 0x0E, 0x18, 0x18, 0x70, 0x00], // } (125) + [0x76, 0xDC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // ~ (126) +]; + +/// Look up glyph data for a character. Returns 8 bytes (one per row). +/// Non-printable or out-of-range characters get a filled-block fallback. +pub fn glyph(ch: u8) -> &'static [u8; 8] { + if ch >= FIRST_CHAR && ch <= LAST_CHAR { + &FONT_DATA[(ch - FIRST_CHAR) as usize] + } else { + static FALLBACK: [u8; 8] = [0xFE, 0xFE, 0xFE, 0xFE, 0xFE, 0xFE, 0xFE, 0x00]; + &FALLBACK + } +} diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs new file mode 100644 index 0000000..4ed2cc9 --- /dev/null +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -0,0 +1,96 @@ +//! Display framebuffer hook via CFW syscall patching. +//! +//! Intercepts `sceDisplaySetFrameBuf` to draw the overlay on top of the +//! game's framebuffer after each frame. The hook calls the original function +//! first (so the game renders normally), then draws overlay elements. + +use crate::overlay; + +use core::sync::atomic::{AtomicBool, Ordering}; + +/// Whether the hook is currently installed. +static HOOK_INSTALLED: AtomicBool = AtomicBool::new(false); + +/// Original `sceDisplaySetFrameBuf` function pointer. +static mut ORIGINAL_SET_FRAME_BUF: Option< + unsafe extern "C" fn(*const u8, usize, u32, u32) -> u32, +> = None; + +/// NID for sceDisplaySetFrameBuf. +const NID_SCE_DISPLAY_SET_FRAME_BUF: u32 = 0x289D82FE; + +/// Our hook function that replaces `sceDisplaySetFrameBuf`. +/// +/// Called in the game's display thread context every vsync. Must be fast: +/// - Call original to let the game's frame through +/// - Poll controller for trigger button +/// - If overlay active, blit the pre-rendered overlay buffer +/// +/// # Safety +/// Called by the PSP OS as a syscall replacement. Arguments match +/// `sceDisplaySetFrameBuf` signature. +unsafe extern "C" fn hooked_set_frame_buf( + top_addr: *const u8, + buffer_width: usize, + pixel_format: u32, + sync: u32, +) -> u32 { + // Call original first so the game's frame is displayed + // SAFETY: ORIGINAL_SET_FRAME_BUF is set before the hook is active. + let result = unsafe { + if let Some(original) = ORIGINAL_SET_FRAME_BUF { + original(top_addr, buffer_width, pixel_format, sync) + } else { + 0 + } + }; + + // Only draw overlay on 32-bit ABGR framebuffers (pixel_format == 3) + // and valid framebuffer pointers + if !top_addr.is_null() && pixel_format == 3 { + // SAFETY: top_addr is a valid framebuffer pointer provided by the OS. + // buffer_width is the stride in pixels. We only write within + // screen bounds (480x272). + unsafe { + overlay::on_frame(top_addr as *mut u32, buffer_width as u32); + } + } + + result +} + +/// Install the `sceDisplaySetFrameBuf` hook. +/// +/// Returns `true` on success. Must be called from kernel mode during plugin +/// initialization. +pub fn install_display_hook() -> bool { + if HOOK_INSTALLED.load(Ordering::Relaxed) { + return true; + } + + // SAFETY: We are in kernel mode (module_kernel!). The hook module/library + // names and NID are well-known constants for the PSP display driver. + unsafe { + let hook = psp::hook::SyscallHook::install( + b"sceDisplay_Service\0".as_ptr(), + b"sceDisplay\0".as_ptr(), + NID_SCE_DISPLAY_SET_FRAME_BUF, + hooked_set_frame_buf as *mut u8, + ); + + match hook { + Some(h) => { + // Store the original function pointer for the trampoline + ORIGINAL_SET_FRAME_BUF = Some(core::mem::transmute(h.original_ptr())); + + // Flush caches to ensure the patched syscall is visible + psp::sys::sceKernelIcacheClearAll(); + psp::sys::sceKernelDcacheWritebackAll(); + + HOOK_INSTALLED.store(true, Ordering::Release); + true + } + None => false, + } + } +} diff --git a/crates/oasis-plugin-psp/src/lib.rs b/crates/oasis-plugin-psp/src/lib.rs new file mode 100644 index 0000000..466082a --- /dev/null +++ b/crates/oasis-plugin-psp/src/lib.rs @@ -0,0 +1,62 @@ +//! OASIS Plugin PRX -- kernel-mode PSP plugin for in-game overlay + background +//! music. +//! +//! This is a companion module to the main OASIS_OS EBOOT. It compiles to a +//! relocatable PRX that CFW (ARK-4/PRO) loads via `PLUGINS.TXT` and keeps +//! resident in kernel memory alongside games. +//! +//! ## Architecture +//! +//! - Hooks `sceDisplaySetFrameBuf` to draw overlay UI on top of the game's +//! framebuffer after each vsync +//! - Claims one PSP audio channel for background MP3 playback via the +//! Media Engine coprocessor +//! - Reads config from `ms0:/seplugins/oasis.ini` +//! - Triggered by NOTE button (kernel-only, 0x800000) +//! +//! ## Memory Budget +//! +//! Target: <64KB total (code + data). No heap allocator -- stack + static +//! buffers only. + +#![no_std] +#![no_main] +#![feature(asm_experimental_arch)] + +psp::module_kernel!("OasisPlugin", 1, 0); + +mod audio; +mod config; +mod font; +mod hook; +mod overlay; +mod render; + +use core::sync::atomic::{AtomicBool, Ordering}; + +/// Global flag: plugin is active and hooks are installed. +static PLUGIN_ACTIVE: AtomicBool = AtomicBool::new(false); + +fn psp_main() { + // Load configuration from ms0:/seplugins/oasis.ini + config::load_config(); + + // Install the display framebuffer hook + if hook::install_display_hook() { + PLUGIN_ACTIVE.store(true, Ordering::Release); + + // Start background audio thread if autoplay is enabled + if config::get_config().autoplay { + audio::start_audio_thread(); + } + } + + // Keep the plugin thread alive (it does nothing after setup -- + // all work happens in the display hook and audio thread). + loop { + // SAFETY: Sleep for ~1 second to avoid busy-waiting. + unsafe { + psp::sys::sceKernelDelayThread(1_000_000); + } + } +} diff --git a/crates/oasis-plugin-psp/src/overlay.rs b/crates/oasis-plugin-psp/src/overlay.rs new file mode 100644 index 0000000..7735f1c --- /dev/null +++ b/crates/oasis-plugin-psp/src/overlay.rs @@ -0,0 +1,414 @@ +//! Overlay state machine and menu logic. +//! +//! States: `Hidden` -> `OSD` (brief notification) -> `Menu` (full overlay) +//! +//! The NOTE button toggles the menu. Controller input is polled via +//! `sceCtrlPeekBufferPositive` (non-blocking, kernel-accessible). + +use crate::audio; +use crate::config; +use crate::render::{self, colors, SCREEN_HEIGHT, SCREEN_WIDTH}; + +use core::sync::atomic::{AtomicU8, Ordering}; + +/// Overlay display state. +#[derive(Copy, Clone, PartialEq, Eq)] +#[repr(u8)] +enum OverlayState { + /// No overlay visible. + Hidden = 0, + /// Brief on-screen display (notification, fades after ~120 frames). + Osd = 1, + /// Full menu overlay with cursor. + Menu = 2, +} + +/// Current overlay state (atomic for thread-safe read from hook). +static STATE: AtomicU8 = AtomicU8::new(OverlayState::Hidden as u8); + +/// Menu cursor position. +static mut CURSOR: u8 = 0; + +/// OSD countdown (frames remaining). +static mut OSD_FRAMES: u16 = 0; + +/// OSD message buffer. +static mut OSD_MSG: [u8; 48] = [0u8; 48]; +static mut OSD_MSG_LEN: usize = 0; + +/// Previous frame's button state (for edge detection). +static mut PREV_BUTTONS: u32 = 0; + +/// Number of menu items. +const MENU_ITEMS: u8 = 7; + +/// Menu item labels. +const MENU_LABELS: [&[u8]; 7] = [ + b" Play / Pause", + b" Next Track", + b" Prev Track", + b" Volume Up", + b" Volume Down", + b" CPU Clock", + b" Hide Overlay", +]; + +/// Overlay rendering dimensions. +const OVERLAY_X: u32 = 80; +const OVERLAY_Y: u32 = 40; +const OVERLAY_W: u32 = 320; +const OVERLAY_H: u32 = 192; +const ITEM_H: u32 = 16; +const STATUS_Y: u32 = OVERLAY_Y + 8; +const MENU_START_Y: u32 = OVERLAY_Y + 48; + +/// PSP button masks. +const BTN_UP: u32 = 0x10; +const BTN_DOWN: u32 = 0x40; +const BTN_CROSS: u32 = 0x4000; + +/// Called every frame from the display hook. +/// +/// Polls controller input, updates state machine, and draws overlay +/// elements onto the game's framebuffer. +/// +/// # Safety +/// `fb` must be a valid 32-bit ABGR framebuffer pointer with at least +/// `stride * 272` pixels. Called from the display thread context. +pub unsafe fn on_frame(fb: *mut u32, stride: u32) { + // Poll controller (non-blocking) + let mut pad = core::mem::zeroed::(); + // SAFETY: SceCtrlData is repr(C), zeroed is valid. + unsafe { + psp::sys::sceCtrlPeekBufferPositive(&mut pad, 1); + } + + let buttons = pad.buttons.bits(); + // SAFETY: Single-threaded access from display hook context. + let prev = unsafe { PREV_BUTTONS }; + let pressed = buttons & !prev; // Rising edge + unsafe { + PREV_BUTTONS = buttons; + } + + let trigger = config::get_config().trigger_mask(); + let state = OverlayState::from_u8(STATE.load(Ordering::Relaxed)); + + match state { + OverlayState::Hidden => { + if pressed & trigger != 0 { + STATE.store(OverlayState::Menu as u8, Ordering::Relaxed); + unsafe { + CURSOR = 0; + } + } + } + OverlayState::Osd => { + // SAFETY: OSD state accessed only from display hook. + unsafe { + if OSD_FRAMES > 0 { + OSD_FRAMES -= 1; + draw_osd(fb, stride); + } + if OSD_FRAMES == 0 { + STATE.store(OverlayState::Hidden as u8, Ordering::Relaxed); + } + } + if pressed & trigger != 0 { + STATE.store(OverlayState::Menu as u8, Ordering::Relaxed); + unsafe { + CURSOR = 0; + } + } + } + OverlayState::Menu => { + if pressed & trigger != 0 { + STATE.store(OverlayState::Hidden as u8, Ordering::Relaxed); + } else { + // SAFETY: CURSOR only modified in display hook. + unsafe { + handle_menu_input(pressed); + draw_menu(fb, stride); + } + } + } + } + + // Flush dcache for the overlay region + if state != OverlayState::Hidden { + // SAFETY: Valid framebuffer region. + unsafe { + render::flush_framebuffer(fb, stride, OVERLAY_Y, OVERLAY_H); + } + } +} + +impl OverlayState { + fn from_u8(v: u8) -> Self { + match v { + 1 => Self::Osd, + 2 => Self::Menu, + _ => Self::Hidden, + } + } +} + +/// Show a brief OSD notification. +pub fn show_osd(msg: &[u8]) { + // SAFETY: Called from single-threaded context (audio thread or menu action). + unsafe { + let len = msg.len().min(OSD_MSG.len() - 1); + let mut i = 0; + while i < len { + OSD_MSG[i] = msg[i]; + i += 1; + } + OSD_MSG[len] = 0; + OSD_MSG_LEN = len; + OSD_FRAMES = 120; // ~2 seconds at 60fps + } + STATE.store(OverlayState::Osd as u8, Ordering::Relaxed); +} + +/// Draw the OSD notification bar at the top of the screen. +/// +/// # Safety +/// `fb` must be valid. +unsafe fn draw_osd(fb: *mut u32, stride: u32) { + // SAFETY: OSD_MSG is valid, called from display hook. + unsafe { + let msg_len = OSD_MSG_LEN; + let bar_w = (msg_len as u32 * 8) + 16; + let bar_x = (SCREEN_WIDTH - bar_w) / 2; + render::fill_rect_alpha(fb, stride, bar_x, 4, bar_w, 14, colors::OVERLAY_BG); + render::draw_string(fb, stride, bar_x + 8, 7, &OSD_MSG[..msg_len], colors::WHITE); + } +} + +/// Handle menu navigation and selection. +/// +/// # Safety +/// Accessed from display hook only. +unsafe fn handle_menu_input(pressed: u32) { + // SAFETY: CURSOR only accessed from display hook. + unsafe { + if pressed & BTN_UP != 0 && CURSOR > 0 { + CURSOR -= 1; + } + if pressed & BTN_DOWN != 0 && CURSOR < MENU_ITEMS - 1 { + CURSOR += 1; + } + if pressed & BTN_CROSS != 0 { + execute_menu_action(CURSOR); + } + } +} + +/// Execute the selected menu action. +/// +/// # Safety +/// Called from display hook context. +unsafe fn execute_menu_action(item: u8) { + match item { + 0 => audio::toggle_playback(), + 1 => audio::next_track(), + 2 => audio::prev_track(), + 3 => audio::volume_up(), + 4 => audio::volume_down(), + 5 => cycle_cpu_clock(), + 6 => STATE.store(OverlayState::Hidden as u8, Ordering::Relaxed), + _ => {} + } +} + +/// Cycle CPU clock between 333/266/222 MHz. +fn cycle_cpu_clock() { + // SAFETY: Power syscalls. + let current = unsafe { psp::sys::scePowerGetCpuClockFrequency() }; + let (cpu, bus) = match current { + 333 => (266, 133), + 266 => (222, 111), + _ => (333, 166), + }; + // SAFETY: Setting CPU/bus frequency. + unsafe { + psp::sys::scePowerSetClockFrequency(cpu, cpu, bus); + } + show_osd(match cpu { + 333 => b"CPU: 333 MHz (max)", + 266 => b"CPU: 266 MHz (balanced)", + _ => b"CPU: 222 MHz (power save)", + }); +} + +/// Draw the full menu overlay. +/// +/// # Safety +/// `fb` must be valid. +unsafe fn draw_menu(fb: *mut u32, stride: u32) { + // SAFETY: All render functions check bounds. + unsafe { + // Background + render::fill_rect_alpha(fb, stride, OVERLAY_X, OVERLAY_Y, OVERLAY_W, OVERLAY_H, colors::OVERLAY_BG); + + // Title bar + render::fill_rect(fb, stride, OVERLAY_X, OVERLAY_Y, OVERLAY_W, 12, colors::ACCENT); + render::draw_string(fb, stride, OVERLAY_X + 4, OVERLAY_Y + 2, b"OASIS OVERLAY", colors::BLACK); + + // Status line + draw_status_line(fb, stride); + + // Now playing + draw_now_playing(fb, stride); + + // Menu items + let cursor = CURSOR; + let mut i = 0u8; + while (i as usize) < MENU_LABELS.len() { + let item_y = MENU_START_Y + (i as u32 * ITEM_H); + if i == cursor { + render::fill_rect_alpha( + fb, stride, + OVERLAY_X + 4, item_y, + OVERLAY_W - 8, ITEM_H - 2, + colors::HIGHLIGHT, + ); + render::draw_string( + fb, stride, + OVERLAY_X + 8, item_y + 4, + b">", + colors::ACCENT, + ); + } + render::draw_string( + fb, stride, + OVERLAY_X + 16, item_y + 4, + MENU_LABELS[i as usize], + if i == cursor { colors::WHITE } else { colors::GRAY }, + ); + i += 1; + } + } +} + +/// Draw the status line (battery, CPU, time). +/// +/// # Safety +/// `fb` must be valid. +unsafe fn draw_status_line(fb: *mut u32, stride: u32) { + let mut buf = [0u8; 64]; + let mut pos = 0usize; + + // Battery + // SAFETY: Power syscalls, no side effects. + let bat = unsafe { psp::sys::scePowerGetBatteryLifePercent() }; + let charging = unsafe { psp::sys::scePowerIsBatteryCharging() } != 0; + pos = write_str(&mut buf, pos, b"Bat:"); + pos = write_u32(&mut buf, pos, bat as u32); + pos = write_str(&mut buf, pos, b"%"); + if charging { + pos = write_str(&mut buf, pos, b"+"); + } + pos = write_str(&mut buf, pos, b" CPU:"); + + // CPU clock + let cpu = unsafe { psp::sys::scePowerGetCpuClockFrequency() }; + pos = write_u32(&mut buf, pos, cpu as u32); + pos = write_str(&mut buf, pos, b"MHz "); + + // Time + let mut dt = core::mem::zeroed::(); + // SAFETY: dt is a valid zeroed struct. + if unsafe { psp::sys::sceRtcGetCurrentClockLocalTime(&mut dt) } >= 0 { + pos = write_u32_pad2(&mut buf, pos, dt.hour as u32); + pos = write_str(&mut buf, pos, b":"); + pos = write_u32_pad2(&mut buf, pos, dt.minutes as u32); + } + + // SAFETY: buf is valid, render functions check bounds. + unsafe { + render::draw_string(fb, stride, OVERLAY_X + 8, STATUS_Y, &buf[..pos], colors::GREEN); + } +} + +/// Draw the now-playing track name. +/// +/// # Safety +/// `fb` must be valid. +unsafe fn draw_now_playing(fb: *mut u32, stride: u32) { + let track = audio::current_track_name(); + if track[0] != 0 { + let mut buf = [0u8; 56]; + let mut pos = write_str(&mut buf, 0, b"Now: "); + // Copy track name (truncated) + let mut i = 0; + while i < track.len() && track[i] != 0 && pos < buf.len() - 1 { + buf[pos] = track[i]; + pos += 1; + i += 1; + } + // SAFETY: render functions check bounds. + unsafe { + render::draw_string( + fb, stride, + OVERLAY_X + 8, STATUS_Y + 16, + &buf[..pos], + colors::YELLOW, + ); + } + } +} + +/// Write a byte string into a buffer. Returns new position. +fn write_str(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { + let mut p = pos; + for &b in s { + if p >= buf.len() { + break; + } + buf[p] = b; + p += 1; + } + p +} + +/// Write a u32 as decimal ASCII into a buffer. +fn write_u32(buf: &mut [u8], pos: usize, val: u32) -> usize { + if val == 0 { + if pos < buf.len() { + buf[pos] = b'0'; + return pos + 1; + } + return pos; + } + // Write digits in reverse, then flip + let mut digits = [0u8; 10]; + let mut n = val; + let mut count = 0; + while n > 0 { + digits[count] = b'0' + (n % 10) as u8; + n /= 10; + count += 1; + } + let mut p = pos; + while count > 0 { + count -= 1; + if p >= buf.len() { + break; + } + buf[p] = digits[count]; + p += 1; + } + p +} + +/// Write a u32 as 2-digit zero-padded decimal. +fn write_u32_pad2(buf: &mut [u8], pos: usize, val: u32) -> usize { + let mut p = pos; + if p + 1 < buf.len() { + buf[p] = b'0' + ((val / 10) % 10) as u8; + buf[p + 1] = b'0' + (val % 10) as u8; + p += 2; + } + p +} diff --git a/crates/oasis-plugin-psp/src/render.rs b/crates/oasis-plugin-psp/src/render.rs new file mode 100644 index 0000000..19e5f6e --- /dev/null +++ b/crates/oasis-plugin-psp/src/render.rs @@ -0,0 +1,184 @@ +//! Direct framebuffer drawing for the overlay. +//! +//! All rendering writes directly to the game's framebuffer pointer (obtained +//! from the `sceDisplaySetFrameBuf` hook arguments). No GU, no VRAM alloc -- +//! just pixel writes + dcache flush. +//! +//! Pixel format: ABGR 8888 (PSP native 32-bit). + +use crate::config; +use crate::font; + +/// VRAM stride in pixels (PSP hardware constant). +const VRAM_STRIDE: u32 = 512; + +/// Screen dimensions. +pub const SCREEN_WIDTH: u32 = 480; +pub const SCREEN_HEIGHT: u32 = 272; + +/// Draw a single character at (x, y) using the 8x8 bitmap font. +/// +/// # Safety +/// `fb` must point to a valid framebuffer of at least `stride * SCREEN_HEIGHT` +/// 32-bit pixels. (x, y) must be in bounds such that x+8 <= stride and +/// y+8 <= SCREEN_HEIGHT. +pub unsafe fn draw_char(fb: *mut u32, stride: u32, x: u32, y: u32, ch: u8, color: u32) { + let glyph_data = font::glyph(ch); + let mut row = 0u32; + while row < font::GLYPH_HEIGHT { + let bits = glyph_data[row as usize]; + let mut col = 0u32; + while col < font::GLYPH_WIDTH { + if bits & (0x80 >> col) != 0 { + let px = x + col; + let py = y + row; + if px < SCREEN_WIDTH && py < SCREEN_HEIGHT { + let offset = py * stride + px; + // SAFETY: Bounds checked above. + unsafe { + *fb.add(offset as usize) = color; + } + } + } + col += 1; + } + row += 1; + } +} + +/// Draw a null-terminated byte string at (x, y). +/// +/// # Safety +/// Same requirements as `draw_char`. +pub unsafe fn draw_string(fb: *mut u32, stride: u32, x: u32, y: u32, text: &[u8], color: u32) { + let mut cx = x; + for &ch in text { + if ch == 0 { + break; + } + if cx + font::GLYPH_WIDTH > SCREEN_WIDTH { + break; + } + // SAFETY: fb is valid, cx/y bounds checked. + unsafe { + draw_char(fb, stride, cx, y, ch, color); + } + cx += font::GLYPH_WIDTH; + } +} + +/// Draw a filled rectangle with alpha blending. +/// +/// `color` is ABGR 8888. The alpha channel controls blend intensity. +/// +/// # Safety +/// `fb` must point to a valid framebuffer. +pub unsafe fn fill_rect_alpha( + fb: *mut u32, + stride: u32, + x: u32, + y: u32, + w: u32, + h: u32, + color: u32, +) { + let alpha = config::get_opacity() as u32; + let src_r = color & 0xFF; + let src_g = (color >> 8) & 0xFF; + let src_b = (color >> 16) & 0xFF; + let inv_alpha = 255 - alpha; + + let mut row = 0u32; + while row < h { + let py = y + row; + if py >= SCREEN_HEIGHT { + break; + } + let mut col = 0u32; + while col < w { + let px = x + col; + if px >= SCREEN_WIDTH { + break; + } + let offset = (py * stride + px) as usize; + // SAFETY: Bounds checked above. + let dst = unsafe { *fb.add(offset) }; + let dst_r = dst & 0xFF; + let dst_g = (dst >> 8) & 0xFF; + let dst_b = (dst >> 16) & 0xFF; + + let out_r = (src_r * alpha + dst_r * inv_alpha) / 255; + let out_g = (src_g * alpha + dst_g * inv_alpha) / 255; + let out_b = (src_b * alpha + dst_b * inv_alpha) / 255; + + let blended = 0xFF000000 | (out_b << 16) | (out_g << 8) | out_r; + // SAFETY: Bounds checked above. + unsafe { + *fb.add(offset) = blended; + } + col += 1; + } + row += 1; + } +} + +/// Draw a filled rectangle (opaque, no blending). +/// +/// # Safety +/// `fb` must point to a valid framebuffer. +pub unsafe fn fill_rect(fb: *mut u32, stride: u32, x: u32, y: u32, w: u32, h: u32, color: u32) { + let mut row = 0u32; + while row < h { + let py = y + row; + if py >= SCREEN_HEIGHT { + break; + } + let mut col = 0u32; + while col < w { + let px = x + col; + if px >= SCREEN_WIDTH { + break; + } + let offset = (py * stride + px) as usize; + // SAFETY: Bounds checked above. + unsafe { + *fb.add(offset) = color; + } + col += 1; + } + row += 1; + } +} + +/// Flush dcache for a framebuffer region to ensure writes are visible. +/// +/// # Safety +/// `fb` must point to a valid memory region. +pub unsafe fn flush_framebuffer(fb: *mut u32, stride: u32, y: u32, h: u32) { + let start = fb.add((y * stride) as usize) as *const u8; + let size = (h * stride * 4) as u32; + // SAFETY: Valid memory range within framebuffer. + unsafe { + psp::sys::sceKernelDcacheWritebackRange(start as *const _, size); + } +} + +/// Color constants (ABGR 8888). +pub mod colors { + /// White. + pub const WHITE: u32 = 0xFFFFFFFF; + /// Black. + pub const BLACK: u32 = 0xFF000000; + /// Semi-transparent black for overlay background. + pub const OVERLAY_BG: u32 = 0xFF1A1A2E; + /// Accent blue. + pub const ACCENT: u32 = 0xFFFF9933; + /// Highlight / cursor color. + pub const HIGHLIGHT: u32 = 0xFF4A3520; + /// Green for active/enabled indicators. + pub const GREEN: u32 = 0xFF33FF66; + /// Yellow for warnings. + pub const YELLOW: u32 = 0xFF00DDFF; + /// Gray for dimmed text. + pub const GRAY: u32 = 0xFF999999; +} diff --git a/docs/design.md b/docs/design.md index 9bfeed7..db5bdf0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -702,6 +702,25 @@ The firmware modernization dramatically simplifies the dashboard's application d No KXploit handling, no '%' directory pairing, no title watermark stripping, no firmware-version-specific icon selection. The code reduction is approximately 60% in the discovery module alone. +#### 8.4.5 Overlay Plugin PRX Architecture + +OASIS uses a two-binary design on PSP, inspired by IRShell and similar PSP shells that shipped companion PRX modules for in-game functionality: + +| Binary | Crate | Format | Mode | Purpose | +|--------|-------|--------|------|---------| +| EBOOT.PBP | `oasis-backend-psp` | Standalone executable | User | Full shell (dashboard, terminal, browser, apps) | +| PRX | `oasis-plugin-psp` | Relocatable module | Kernel | Lightweight overlay + background music | + +The PRX is loaded by custom firmware at boot time via a `PLUGINS.TXT` entry and stays resident in kernel memory when games launch. The EBOOT is replaced by the game, but the PRX persists. + +**Hook mechanism:** The PRX patches `sceDisplaySetFrameBuf` via `sctrlHENPatchSyscall` (a CFW-provided API for syscall table manipulation). After the game renders each frame, the hook draws overlay UI elements directly into the framebuffer using alpha-blended pixel writes. + +**Memory budget:** <64KB total (code + static data). No heap allocator for the core overlay path. The plugin uses static buffers for MP3 decode, PCM output, playlist storage, and overlay rendering. Atomics provide thread-safe state sharing between the display hook (game thread) and audio thread. + +**Background audio:** A dedicated kernel thread streams MP3 files from `ms0:/MUSIC/` through the PSP's hardware MP3 decoder (`sceMp3*`) to a reserved audio channel. File data is streamed in chunks rather than loaded entirely into memory. + +**Integration:** The EBOOT includes terminal commands (`plugin install`, `plugin remove`, `plugin status`) to manage the PRX installation, PLUGINS.TXT registration, and configuration file (`ms0:/seplugins/oasis.ini`). + --- ## 9. Linux / Raspberry Pi Platform diff --git a/docs/psp-plugin.md b/docs/psp-plugin.md new file mode 100644 index 0000000..275e332 --- /dev/null +++ b/docs/psp-plugin.md @@ -0,0 +1,160 @@ +# PSP Overlay Plugin Guide + +The OASIS overlay plugin is a kernel-mode PRX that stays resident in memory alongside PSP games, providing an in-game overlay UI and background MP3 playback. + +## Architecture + +OASIS uses a two-binary design on PSP: + +| Binary | Format | Purpose | +|--------|--------|---------| +| `oasis-backend-psp` | EBOOT.PBP | Full shell (dashboard, terminal, browser, apps) | +| `oasis-plugin-psp` | PRX | Lightweight overlay + background music | + +The PRX is loaded by custom firmware (ARK-4, PRO, ME) at boot time via `PLUGINS.TXT` and stays resident when games launch. It hooks `sceDisplaySetFrameBuf` to draw on top of the game's framebuffer. + +## Installation + +### From OASIS Terminal + +The easiest way to install is from the OASIS terminal: + +``` +plugin install +``` + +This copies the PRX to `ms0:/seplugins/oasis_plugin.prx`, adds it to `PLUGINS.TXT`, and creates a default `oasis.ini` configuration file. + +To remove: +``` +plugin remove +``` + +To check status: +``` +plugin status +``` + +### Manual Installation + +1. Copy `oasis_plugin.prx` to `ms0:/seplugins/` +2. Add this line to `ms0:/seplugins/PLUGINS.TXT`: + ``` + game, ms0:/seplugins/oasis_plugin.prx, on + ``` +3. Reboot the PSP + +## Building + +```bash +cd crates/oasis-plugin-psp +RUST_PSP_BUILD_STD=1 cargo +nightly psp --release +``` + +Output: `target/mipsel-sony-psp-std/release/oasis_plugin.prx` + +## Controls + +| Button | Action | +|--------|--------| +| NOTE (default) | Toggle overlay menu | +| D-pad Up/Down | Navigate menu items | +| Cross (X) | Select menu item | + +The trigger button can be changed to SCREEN via configuration. + +### Menu Items + +| Item | Description | +|------|-------------| +| Play / Pause | Toggle music playback | +| Next Track | Skip to next MP3 | +| Prev Track | Go to previous MP3 | +| Volume Up | Increase volume | +| Volume Down | Decrease volume | +| CPU Clock | Cycle between 333/266/222 MHz | +| Hide Overlay | Close the menu | + +## Configuration + +The plugin reads `ms0:/seplugins/oasis.ini` at startup: + +```ini +# Overlay trigger button: note or screen +trigger = note + +# Music directory path +music_dir = ms0:/MUSIC/ + +# Overlay background opacity (0-255) +opacity = 180 + +# Auto-start music on game launch +autoplay = false +``` + +### Configuration Options + +| Key | Values | Default | Description | +|-----|--------|---------|-------------| +| `trigger` | `note`, `screen` | `note` | Button to toggle the overlay | +| `music_dir` | path | `ms0:/MUSIC/` | Directory to scan for MP3 files | +| `opacity` | 0-255 | 180 | Overlay background transparency | +| `autoplay` | `true`/`false` | `false` | Start music automatically on plugin load | + +## Music Playback + +Place MP3 files in `ms0:/MUSIC/` (or the configured `music_dir`). The plugin scans the directory at startup and builds a playlist (up to 32 tracks). + +Playback uses the PSP's hardware MP3 decoder (`sceMp3*` APIs) with streaming file I/O -- MP3 data is fed to the decoder in chunks rather than loaded entirely into memory. + +Audio output uses one of the PSP's 8 hardware audio channels at the configured volume level. + +## Overlay Display + +When active, the overlay shows: + +- **Status bar**: Battery percentage, CPU clock speed, current time +- **Now playing**: Current track name (if music is active) +- **Menu**: Navigable list of actions with cursor highlighting + +The overlay renders directly to the game's framebuffer using alpha-blended rectangles and an 8x8 bitmap font. All rendering happens in the `sceDisplaySetFrameBuf` hook -- the game renders normally first, then the overlay is drawn on top. + +## Technical Details + +### Memory Budget + +The PRX targets <64KB total binary size: +- Code: ~32KB (optimized with `opt-level = "z"` + LTO) +- Static data: ~32KB (font glyphs, playlist, decode buffers) + +No heap allocator is used for the core overlay logic -- all state is in static buffers and atomics. + +### Thread Model + +- **Main thread**: Plugin entry point, installs hooks, then sleeps +- **Display hook**: Runs in the game's display thread context via syscall hook. Polls controller, updates state machine, blits overlay +- **Audio thread**: Dedicated kernel thread for MP3 decode + audio output loop + +### Cache Coherency + +- Data cache is flushed after framebuffer writes (`sceKernelDcacheWritebackRange`) +- Instruction cache is cleared after hook installation (`sceKernelIcacheClearAll`) + +### CFW Compatibility + +The plugin uses `sctrlHENFindFunction` and `sctrlHENPatchSyscall` from the SystemCtrlForKernel library, which is provided by: +- ARK-4 +- PRO CFW +- ME/LME CFW + +These APIs are the standard mechanism for kernel-mode syscall hooking on custom firmware. + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| Overlay doesn't appear | Verify PRX is in `ms0:/seplugins/` and listed in `PLUGINS.TXT` with `, on` | +| No audio | Check that MP3 files are in the configured `music_dir` | +| Game crashes on boot | Some games conflict with overlay hooks -- try disabling the plugin for that game | +| NOTE button doesn't work | Ensure you're using a CFW that exposes kernel-only buttons | From becabe6cf77c2d867c2c23c5676c95ff5fb40a5d Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 12:22:59 -0600 Subject: [PATCH 02/26] fix: restore GU display list after OSK/dialog calls on PSP The PSP on-screen keyboard and confirm dialogs never appeared because the SDK's polling loops now properly manage the GU frame lifecycle (fix/osk-dialog-gu-frame branch of rust-psp). After the dialog returns, the caller's display list is closed and must be re-opened. - Add PspBackend::reinit_gu_frame() to re-open the main DISPLAY_LIST - Call reinit_gu_frame() after OSK in terminal (Square button) - Call reinit_gu_frame() after execute_command (covers rm's confirm_dialog) - Call reinit_gu_frame() after file manager delete dialog - Point both PSP crate Cargo.toml files to rust-psp fix/osk-dialog-gu-frame Co-Authored-By: Claude Opus 4.6 --- crates/oasis-backend-psp/Cargo.toml | 2 +- crates/oasis-backend-psp/src/lib.rs | 19 +++++++++++++++++++ crates/oasis-backend-psp/src/main.rs | 7 +++++++ crates/oasis-plugin-psp/Cargo.toml | 2 +- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/oasis-backend-psp/Cargo.toml b/crates/oasis-backend-psp/Cargo.toml index f585eea..a46ba3a 100644 --- a/crates/oasis-backend-psp/Cargo.toml +++ b/crates/oasis-backend-psp/Cargo.toml @@ -23,7 +23,7 @@ kernel-me-clock = ["psp/kernel"] # scePowerGetMeClockFrequency kernel-me = ["psp/kernel"] # ME coprocessor (me test command) [dependencies] -psp = { git = "https://github.com/AndrewAltimit/rust-psp", branch = "main", features = ["std"] } +psp = { git = "https://github.com/AndrewAltimit/rust-psp", branch = "fix/osk-dialog-gu-frame", features = ["std"] } oasis-core = { path = "../oasis-core" } libm = "0.2" diff --git a/crates/oasis-backend-psp/src/lib.rs b/crates/oasis-backend-psp/src/lib.rs index 4030af6..b5cd769 100644 --- a/crates/oasis-backend-psp/src/lib.rs +++ b/crates/oasis-backend-psp/src/lib.rs @@ -321,6 +321,25 @@ impl PspBackend { } } + /// Re-open the GU display list after a utility dialog closed it. + /// + /// SDK utility dialogs (`psp::osk`, `psp::dialog`) close the caller's + /// open GU list to render their own UI. Call this after any dialog + /// returns to restore the GU state for the render phase. + /// + /// Harmless when no dialog was shown -- `sceGuStart` on an + /// already-open list just restarts it from scratch. + pub fn reinit_gu_frame(&self) { + // SAFETY: Restores the GU display list state after a dialog. + // DISPLAY_LIST is exclusively accessed from the main loop. + unsafe { + sys::sceGuStart( + GuContextType::Direct, + &raw mut DISPLAY_LIST as *mut c_void, + ); + } + } + /// Current cursor position (for rendering the cursor sprite). pub fn cursor_pos(&self) -> (i32, i32) { (self.cursor_x, self.cursor_y) diff --git a/crates/oasis-backend-psp/src/main.rs b/crates/oasis-backend-psp/src/main.rs index 29b7eb2..2d9c4d6 100644 --- a/crates/oasis-backend-psp/src/main.rs +++ b/crates/oasis-backend-psp/src/main.rs @@ -982,6 +982,9 @@ fn psp_main() { }, _ => commands::execute_command(&cmd, &mut config), }; + // Commands like `rm` may invoke confirm_dialog which + // closes the GU display list. Re-open it for rendering. + backend.reinit_gu_frame(); for line in output { term_lines.push(line); } @@ -1004,6 +1007,8 @@ fn psp_main() { }, Ok(None) | Err(_) => {}, // Cancelled or unsupported (PPSSPP) } + // OSK closes the GU display list. Re-open for rendering. + backend.reinit_gu_frame(); }, InputEvent::ButtonPress(Button::Up) if classic_view == ClassicView::Terminal => { term_lines.push(String::from("> help")); @@ -1153,6 +1158,8 @@ fn psp_main() { }, _ => {}, // Cancelled or closed } + // confirm_dialog/error_dialog close the GU list. + backend.reinit_gu_frame(); } }, InputEvent::ButtonPress(Button::Triangle) diff --git a/crates/oasis-plugin-psp/Cargo.toml b/crates/oasis-plugin-psp/Cargo.toml index cde734d..ff9f997 100644 --- a/crates/oasis-plugin-psp/Cargo.toml +++ b/crates/oasis-plugin-psp/Cargo.toml @@ -18,7 +18,7 @@ repository = "https://github.com/AndrewAltimit/oasis-os" authors = ["AndrewAltimit"] [dependencies] -psp = { git = "https://github.com/AndrewAltimit/rust-psp", branch = "main", features = ["kernel", "std"] } +psp = { git = "https://github.com/AndrewAltimit/rust-psp", branch = "fix/osk-dialog-gu-frame", features = ["kernel", "std"] } # unicode-width workaround for mips target panic. [patch.crates-io] From 59e9e34a771c0959f297fa4046e33d30f4787a85 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 12:55:44 -0600 Subject: [PATCH 03/26] fix: PRX plugin build errors (Rust 2024 edition compatibility) - Replace #![no_std] with #![feature(restricted_std)] so module_kernel! macro can resolve std::panic::catch_unwind - Rename lib.rs to main.rs so cargo-psp produces a binary (.prx) output - Fix sceKernelIcacheClearAll -> sceKernelIcacheInvalidateAll (correct name) - Fix SceUid newtype comparisons (SceUid(0) instead of bare 0) - Fix static_mut_refs errors: use &raw mut, raw pointers, and constants instead of creating references to static mut (Rust 2024 deny-by-default) - Fix unsafe_op_in_unsafe_fn: wrap zeroed() and ptr::add() in unsafe blocks - Remove unused SCREEN_HEIGHT import - Add rust-std-src to .gitignore (local symlink for cargo-psp sysroot) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + crates/oasis-backend-psp/Cargo.lock | 2 +- crates/oasis-plugin-psp/Cargo.lock | 114 ++++++++++++++++++ crates/oasis-plugin-psp/src/audio.rs | 27 ++--- crates/oasis-plugin-psp/src/config.rs | 4 +- crates/oasis-plugin-psp/src/hook.rs | 2 +- .../oasis-plugin-psp/src/{lib.rs => main.rs} | 2 +- crates/oasis-plugin-psp/src/overlay.rs | 10 +- crates/oasis-plugin-psp/src/render.rs | 3 +- 9 files changed, 140 insertions(+), 25 deletions(-) create mode 100644 crates/oasis-plugin-psp/Cargo.lock rename crates/oasis-plugin-psp/src/{lib.rs => main.rs} (98%) diff --git a/.gitignore b/.gitignore index 062aa73..44f3170 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ Thumbs.db *.SFO *.PRX psp_output_file.log +rust-std-src # Secrets *.key diff --git a/crates/oasis-backend-psp/Cargo.lock b/crates/oasis-backend-psp/Cargo.lock index 5150736..1ecac0b 100644 --- a/crates/oasis-backend-psp/Cargo.lock +++ b/crates/oasis-backend-psp/Cargo.lock @@ -748,7 +748,7 @@ dependencies = [ [[package]] name = "psp" version = "0.4.0" -source = "git+https://github.com/AndrewAltimit/rust-psp?branch=main#5f6047a25f4840e39c5f9af61b3922b01f2ad67a" +source = "git+https://github.com/AndrewAltimit/rust-psp?branch=fix%2Fosk-dialog-gu-frame#17e948323f77abe57c7b1a76430fde2131171fdd" dependencies = [ "bitflags", "libm", diff --git a/crates/oasis-plugin-psp/Cargo.lock b/crates/oasis-plugin-psp/Cargo.lock new file mode 100644 index 0000000..605be19 --- /dev/null +++ b/crates/oasis-plugin-psp/Cargo.lock @@ -0,0 +1,114 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oasis-plugin-psp" +version = "0.1.0" +dependencies = [ + "psp", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psp" +version = "0.4.0" +source = "git+https://github.com/AndrewAltimit/rust-psp?branch=fix%2Fosk-dialog-gu-frame#17e948323f77abe57c7b1a76430fde2131171fdd" +dependencies = [ + "bitflags", + "libm", + "num_enum", + "num_enum_derive", + "paste", + "unstringify", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unstringify" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9612d66420ead229348915b911ad9689e79dfc347fe7a876a82551c8eab36b5e" + +[[patch.unused]] +name = "unicode-width" +version = "0.2.0" +source = "git+https://git.sr.ht/~sajattack/unicode-width#114ac4742ac29a7b69be8e0e7b1e45af43ed6d83" diff --git a/crates/oasis-plugin-psp/src/audio.rs b/crates/oasis-plugin-psp/src/audio.rs index 683e919..13ac0a8 100644 --- a/crates/oasis-plugin-psp/src/audio.rs +++ b/crates/oasis-plugin-psp/src/audio.rs @@ -136,7 +136,7 @@ pub fn start_audio_thread() { let cfg = crate::config::get_config(); // SAFETY: Single-threaded init. unsafe { - let len = cfg.music_dir_len.min(MUSIC_DIR.len() - 1); + let len = cfg.music_dir_len.min(63); let mut i = 0; while i < len { MUSIC_DIR[i] = cfg.music_dir[i]; @@ -166,7 +166,7 @@ pub fn start_audio_thread() { psp::sys::ThreadAttributes::empty(), core::ptr::null_mut(), ); - if tid >= 0 { + if tid >= psp::sys::SceUid(0) { RUNNING.store(true, Ordering::Release); psp::sys::sceKernelStartThread(tid, 0, core::ptr::null_mut()); } @@ -181,7 +181,7 @@ fn scan_playlist() { // SAFETY: sceIoDopen with null-terminated path. let dfd = unsafe { psp::sys::sceIoDopen(dir_path.as_ptr()) }; - if dfd < 0 { + if dfd < psp::sys::SceUid(0) { return; } @@ -318,7 +318,7 @@ unsafe extern "C" fn audio_thread_entry(_args: usize, _argp: *mut c_void) -> i32 0, ) }; - if fd < 0 { + if fd < psp::sys::SceUid(0) { // Skip to next track on error advance_track(); continue; @@ -350,9 +350,9 @@ unsafe extern "C" fn audio_thread_entry(_args: usize, _argp: *mut c_void) -> i32 unk1: 0, mp3_stream_end: file_size, unk2: 0, - mp3_buf: unsafe { MP3_BUF.as_mut_ptr() as *mut c_void }, + mp3_buf: (&raw mut MP3_BUF) as *mut c_void, mp3_buf_size: MP3_BUF_SIZE as i32, - pcm_buf: unsafe { PCM_BUF.as_mut_ptr() as *mut c_void }, + pcm_buf: (&raw mut PCM_BUF) as *mut c_void, pcm_buf_size: (PCM_BUF_SIZE * 2) as i32, }; @@ -369,7 +369,7 @@ unsafe extern "C" fn audio_thread_entry(_args: usize, _argp: *mut c_void) -> i32 // Feed initial data from file // SAFETY: Static buffers, fd is valid. - let feed_ok = unsafe { feed_mp3_from_file(handle, fd, &mut FILE_BUF) }; + let feed_ok = unsafe { feed_mp3_from_file(handle, fd, &raw mut FILE_BUF) }; if !feed_ok { unsafe { psp::sys::sceMp3ReleaseMp3Handle(handle); @@ -413,7 +413,7 @@ unsafe extern "C" fn audio_thread_entry(_args: usize, _argp: *mut c_void) -> i32 // SAFETY: handle and fd are valid. unsafe { if psp::sys::sceMp3CheckStreamDataNeeded(handle) > 0 { - if !feed_mp3_from_file(handle, fd, &mut FILE_BUF) { + if !feed_mp3_from_file(handle, fd, &raw mut FILE_BUF) { break; // EOF or error } } @@ -459,11 +459,11 @@ unsafe extern "C" fn audio_thread_entry(_args: usize, _argp: *mut c_void) -> i32 /// /// # Safety /// `handle` must be a valid MP3 handle, `fd` a valid file descriptor, -/// `file_buf` must be a valid mutable buffer. +/// `file_buf` must be a valid pointer to a `[u8; FILE_BUF_SIZE]` buffer. unsafe fn feed_mp3_from_file( handle: psp::sys::Mp3Handle, fd: psp::sys::SceUid, - file_buf: &mut [u8; FILE_BUF_SIZE], + file_buf: *mut [u8; FILE_BUF_SIZE], ) -> bool { let mut dst_ptr: *mut u8 = core::ptr::null_mut(); let mut to_write: i32 = 0; @@ -477,7 +477,6 @@ unsafe fn feed_mp3_from_file( return false; } - // Seek to the position the decoder wants // SAFETY: fd is valid. unsafe { psp::sys::sceIoLseek(fd, src_pos as i64, psp::sys::IoWhence::Set); @@ -489,18 +488,18 @@ unsafe fn feed_mp3_from_file( let chunk = ((to_write - total_read) as usize).min(FILE_BUF_SIZE); // SAFETY: file_buf is valid, fd is valid. let bytes_read = unsafe { - psp::sys::sceIoRead(fd, file_buf.as_mut_ptr() as *mut _, chunk as u32) + psp::sys::sceIoRead(fd, file_buf as *mut _, chunk as u32) }; if bytes_read <= 0 { break; } // Copy to decoder buffer - // SAFETY: dst_ptr is valid memory from sceMp3GetInfoToAddStreamData. + // SAFETY: dst_ptr is valid, file_buf is valid, bounds checked. let mut i = 0; while i < bytes_read as usize { unsafe { - *dst_ptr.add(total_read as usize + i) = file_buf[i]; + *dst_ptr.add(total_read as usize + i) = (*file_buf)[i]; } i += 1; } diff --git a/crates/oasis-plugin-psp/src/config.rs b/crates/oasis-plugin-psp/src/config.rs index 909955b..b9321eb 100644 --- a/crates/oasis-plugin-psp/src/config.rs +++ b/crates/oasis-plugin-psp/src/config.rs @@ -114,7 +114,7 @@ pub fn load_config() { 0, ) }; - if fd < 0 { + if fd < psp::sys::SceUid(0) { return; // File doesn't exist, use defaults. } @@ -133,7 +133,7 @@ pub fn load_config() { // SAFETY: Single-threaded init, CONFIG not yet shared. unsafe { - parse_config(data, &mut CONFIG); + parse_config(data, &mut *(&raw mut CONFIG)); OPACITY.store(CONFIG.opacity, Ordering::Relaxed); } } diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 4ed2cc9..2efe1d0 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -84,7 +84,7 @@ pub fn install_display_hook() -> bool { ORIGINAL_SET_FRAME_BUF = Some(core::mem::transmute(h.original_ptr())); // Flush caches to ensure the patched syscall is visible - psp::sys::sceKernelIcacheClearAll(); + psp::sys::sceKernelIcacheInvalidateAll(); psp::sys::sceKernelDcacheWritebackAll(); HOOK_INSTALLED.store(true, Ordering::Release); diff --git a/crates/oasis-plugin-psp/src/lib.rs b/crates/oasis-plugin-psp/src/main.rs similarity index 98% rename from crates/oasis-plugin-psp/src/lib.rs rename to crates/oasis-plugin-psp/src/main.rs index 466082a..192929c 100644 --- a/crates/oasis-plugin-psp/src/lib.rs +++ b/crates/oasis-plugin-psp/src/main.rs @@ -19,7 +19,7 @@ //! Target: <64KB total (code + data). No heap allocator -- stack + static //! buffers only. -#![no_std] +#![feature(restricted_std)] #![no_main] #![feature(asm_experimental_arch)] diff --git a/crates/oasis-plugin-psp/src/overlay.rs b/crates/oasis-plugin-psp/src/overlay.rs index 7735f1c..9038b31 100644 --- a/crates/oasis-plugin-psp/src/overlay.rs +++ b/crates/oasis-plugin-psp/src/overlay.rs @@ -7,7 +7,7 @@ use crate::audio; use crate::config; -use crate::render::{self, colors, SCREEN_HEIGHT, SCREEN_WIDTH}; +use crate::render::{self, colors, SCREEN_WIDTH}; use core::sync::atomic::{AtomicU8, Ordering}; @@ -77,8 +77,8 @@ const BTN_CROSS: u32 = 0x4000; /// `stride * 272` pixels. Called from the display thread context. pub unsafe fn on_frame(fb: *mut u32, stride: u32) { // Poll controller (non-blocking) - let mut pad = core::mem::zeroed::(); // SAFETY: SceCtrlData is repr(C), zeroed is valid. + let mut pad = unsafe { core::mem::zeroed::() }; unsafe { psp::sys::sceCtrlPeekBufferPositive(&mut pad, 1); } @@ -157,7 +157,7 @@ impl OverlayState { pub fn show_osd(msg: &[u8]) { // SAFETY: Called from single-threaded context (audio thread or menu action). unsafe { - let len = msg.len().min(OSD_MSG.len() - 1); + let len = msg.len().min(47); let mut i = 0; while i < len { OSD_MSG[i] = msg[i]; @@ -317,8 +317,8 @@ unsafe fn draw_status_line(fb: *mut u32, stride: u32) { pos = write_str(&mut buf, pos, b"MHz "); // Time - let mut dt = core::mem::zeroed::(); - // SAFETY: dt is a valid zeroed struct. + // SAFETY: ScePspDateTime is repr(C), zeroed is valid. + let mut dt = unsafe { core::mem::zeroed::() }; if unsafe { psp::sys::sceRtcGetCurrentClockLocalTime(&mut dt) } >= 0 { pos = write_u32_pad2(&mut buf, pos, dt.hour as u32); pos = write_str(&mut buf, pos, b":"); diff --git a/crates/oasis-plugin-psp/src/render.rs b/crates/oasis-plugin-psp/src/render.rs index 19e5f6e..3bae54e 100644 --- a/crates/oasis-plugin-psp/src/render.rs +++ b/crates/oasis-plugin-psp/src/render.rs @@ -155,7 +155,8 @@ pub unsafe fn fill_rect(fb: *mut u32, stride: u32, x: u32, y: u32, w: u32, h: u3 /// # Safety /// `fb` must point to a valid memory region. pub unsafe fn flush_framebuffer(fb: *mut u32, stride: u32, y: u32, h: u32) { - let start = fb.add((y * stride) as usize) as *const u8; + // SAFETY: Pointer arithmetic within valid framebuffer region. + let start = unsafe { fb.add((y * stride) as usize) } as *const u8; let size = (h * stride * 4) as u32; // SAFETY: Valid memory range within framebuffer. unsafe { From ed26dc51ef395c103615d3fe17657455a6f03a63 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 13:11:59 -0600 Subject: [PATCH 04/26] debug: add hook beacon and L+R+START fallback trigger - Draw a 2x2 green dot at (1,1) every frame to confirm the display hook is running (remove once overlay is confirmed working) - Add L+R+START as a fallback trigger combo -- CFW (ARK-4/PRO) often intercepts the NOTE button for its own VSH menu before the plugin sees it Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 19 ++++++++++++++++--- crates/oasis-plugin-psp/src/overlay.rs | 15 ++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 2efe1d0..9f50ee3 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -48,11 +48,24 @@ unsafe extern "C" fn hooked_set_frame_buf( // Only draw overlay on 32-bit ABGR framebuffers (pixel_format == 3) // and valid framebuffer pointers if !top_addr.is_null() && pixel_format == 3 { - // SAFETY: top_addr is a valid framebuffer pointer provided by the OS. - // buffer_width is the stride in pixels. We only write within + let fb = top_addr as *mut u32; + let stride = buffer_width as u32; + + // Debug beacon: 2x2 green dot at (1,1) confirms the hook is running. + // Remove once overlay is confirmed working. + // SAFETY: Writing within screen bounds to valid framebuffer. + unsafe { + *fb.add((1 * stride + 1) as usize) = 0xFF00FF00; + *fb.add((1 * stride + 2) as usize) = 0xFF00FF00; + *fb.add((2 * stride + 1) as usize) = 0xFF00FF00; + *fb.add((2 * stride + 2) as usize) = 0xFF00FF00; + } + + // SAFETY: fb is a valid framebuffer pointer provided by the OS. + // stride is the buffer width in pixels. We only write within // screen bounds (480x272). unsafe { - overlay::on_frame(top_addr as *mut u32, buffer_width as u32); + overlay::on_frame(fb, stride); } } diff --git a/crates/oasis-plugin-psp/src/overlay.rs b/crates/oasis-plugin-psp/src/overlay.rs index 9038b31..2b08cdc 100644 --- a/crates/oasis-plugin-psp/src/overlay.rs +++ b/crates/oasis-plugin-psp/src/overlay.rs @@ -66,6 +66,9 @@ const MENU_START_Y: u32 = OVERLAY_Y + 48; const BTN_UP: u32 = 0x10; const BTN_DOWN: u32 = 0x40; const BTN_CROSS: u32 = 0x4000; +const BTN_L_TRIGGER: u32 = 0x100; +const BTN_R_TRIGGER: u32 = 0x200; +const BTN_START: u32 = 0x8; /// Called every frame from the display hook. /// @@ -94,9 +97,15 @@ pub unsafe fn on_frame(fb: *mut u32, stride: u32) { let trigger = config::get_config().trigger_mask(); let state = OverlayState::from_u8(STATE.load(Ordering::Relaxed)); + // Accept either the config trigger button (NOTE/SCREEN) or L+R+START combo. + // CFW often intercepts NOTE for its own menu, so the combo is a fallback. + let combo = BTN_L_TRIGGER | BTN_R_TRIGGER | BTN_START; + let combo_triggered = (buttons & combo) == combo && (prev & combo) != combo; + let triggered = (pressed & trigger != 0) || combo_triggered; + match state { OverlayState::Hidden => { - if pressed & trigger != 0 { + if triggered { STATE.store(OverlayState::Menu as u8, Ordering::Relaxed); unsafe { CURSOR = 0; @@ -114,7 +123,7 @@ pub unsafe fn on_frame(fb: *mut u32, stride: u32) { STATE.store(OverlayState::Hidden as u8, Ordering::Relaxed); } } - if pressed & trigger != 0 { + if triggered { STATE.store(OverlayState::Menu as u8, Ordering::Relaxed); unsafe { CURSOR = 0; @@ -122,7 +131,7 @@ pub unsafe fn on_frame(fb: *mut u32, stride: u32) { } } OverlayState::Menu => { - if pressed & trigger != 0 { + if triggered { STATE.store(OverlayState::Hidden as u8, Ordering::Relaxed); } else { // SAFETY: CURSOR only modified in display hook. From 47394498737608f72d2082f128ae0b440d6fcc0f Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 13:29:32 -0600 Subject: [PATCH 05/26] debug: add file-based diagnostic logging and multi-name hook discovery Writes initialization trace to ms0:/seplugins/oasis_debug.txt to diagnose why the display hook fails on 6.20 PRO-C2. Tries four module/library name combinations for sceDisplaySetFrameBuf since different CFW versions expose it under different names. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 137 +++++++++++++++++++++++----- crates/oasis-plugin-psp/src/main.rs | 27 ++++++ 2 files changed, 143 insertions(+), 21 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 9f50ee3..72af395 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -72,6 +72,17 @@ unsafe extern "C" fn hooked_set_frame_buf( result } +/// Module/library name pairs to try for finding sceDisplaySetFrameBuf. +/// +/// Different CFW versions and firmware versions expose the display driver +/// under different module names. We try them in order until one works. +const DISPLAY_MODULE_NAMES: &[(&[u8], &[u8])] = &[ + (b"sceDisplay_Service\0", b"sceDisplay\0"), + (b"sceDisplay\0", b"sceDisplay\0"), + (b"sceDisplay_Service\0", b"sceDisplay_driver\0"), + (b"sceDisplay\0", b"sceDisplay_driver\0"), +]; + /// Install the `sceDisplaySetFrameBuf` hook. /// /// Returns `true` on success. Must be called from kernel mode during plugin @@ -81,29 +92,113 @@ pub fn install_display_hook() -> bool { return true; } - // SAFETY: We are in kernel mode (module_kernel!). The hook module/library - // names and NID are well-known constants for the PSP display driver. + // SAFETY: We are in kernel mode (module_kernel!). Try multiple + // module/library name combinations for compatibility across CFW versions. + unsafe { + for &(module, library) in DISPLAY_MODULE_NAMES { + let hook = psp::hook::SyscallHook::install( + module.as_ptr(), + library.as_ptr(), + NID_SCE_DISPLAY_SET_FRAME_BUF, + hooked_set_frame_buf as *mut u8, + ); + + match hook { + Some(h) => { + // Store the original function pointer for the trampoline + ORIGINAL_SET_FRAME_BUF = + Some(core::mem::transmute(h.original_ptr())); + + // Flush caches to ensure the patched syscall is visible + psp::sys::sceKernelIcacheInvalidateAll(); + psp::sys::sceKernelDcacheWritebackAll(); + + HOOK_INSTALLED.store(true, Ordering::Release); + return true; + } + None => continue, + } + } + } + + false +} + +/// Log diagnostic info about sctrlHENFindFunction results. +/// +/// Tries all known module/library name combinations and logs which ones +/// return a valid pointer vs null. Writes to the debug log file. +pub fn log_find_function_result() { + // SAFETY: sctrlHENFindFunction is safe to call from kernel mode. + // It just looks up function pointers without side effects. unsafe { - let hook = psp::hook::SyscallHook::install( - b"sceDisplay_Service\0".as_ptr(), - b"sceDisplay\0".as_ptr(), - NID_SCE_DISPLAY_SET_FRAME_BUF, - hooked_set_frame_buf as *mut u8, - ); - - match hook { - Some(h) => { - // Store the original function pointer for the trampoline - ORIGINAL_SET_FRAME_BUF = Some(core::mem::transmute(h.original_ptr())); - - // Flush caches to ensure the patched syscall is visible - psp::sys::sceKernelIcacheInvalidateAll(); - psp::sys::sceKernelDcacheWritebackAll(); - - HOOK_INSTALLED.store(true, Ordering::Release); - true + for &(module, library) in DISPLAY_MODULE_NAMES { + let ptr = psp::sys::sctrlHENFindFunction( + module.as_ptr(), + library.as_ptr(), + NID_SCE_DISPLAY_SET_FRAME_BUF, + ); + + // Build log message: "[OASIS] FindFunc mod=X lib=Y -> 0xADDR" + let mut buf = [0u8; 96]; + let mut pos = 0usize; + pos = write_log_bytes(&mut buf, pos, b"[OASIS] FindFunc mod="); + // Copy module name (without null terminator) + pos = write_log_cstr(&mut buf, pos, module); + pos = write_log_bytes(&mut buf, pos, b" lib="); + pos = write_log_cstr(&mut buf, pos, library); + pos = write_log_bytes(&mut buf, pos, b" -> "); + if ptr.is_null() { + pos = write_log_bytes(&mut buf, pos, b"NULL"); + } else { + pos = write_log_bytes(&mut buf, pos, b"0x"); + pos = write_log_hex(&mut buf, pos, ptr as u32); } - None => false, + crate::debug_log(&buf[..pos]); + } + } +} + +/// Write bytes into a log buffer. Returns new position. +fn write_log_bytes(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { + let mut p = pos; + for &b in s { + if p >= buf.len() { + break; + } + buf[p] = b; + p += 1; + } + p +} + +/// Write a null-terminated C string (without the null) into a log buffer. +fn write_log_cstr(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { + let mut p = pos; + for &b in s { + if b == 0 || p >= buf.len() { + break; + } + buf[p] = b; + p += 1; + } + p +} + +/// Write a u32 as hexadecimal into a log buffer. +fn write_log_hex(buf: &mut [u8], pos: usize, val: u32) -> usize { + let mut p = pos; + let hex = b"0123456789ABCDEF"; + // Write 8 hex digits + let mut i = 0; + while i < 8 { + if p >= buf.len() { + break; } + let nibble = (val >> (28 - i * 4)) & 0xF; + buf[p] = hex[nibble as usize]; + p += 1; + i += 1; } + p } diff --git a/crates/oasis-plugin-psp/src/main.rs b/crates/oasis-plugin-psp/src/main.rs index 192929c..c7331d4 100644 --- a/crates/oasis-plugin-psp/src/main.rs +++ b/crates/oasis-plugin-psp/src/main.rs @@ -37,20 +37,47 @@ use core::sync::atomic::{AtomicBool, Ordering}; /// Global flag: plugin is active and hooks are installed. static PLUGIN_ACTIVE: AtomicBool = AtomicBool::new(false); +/// Write a debug message to ms0:/seplugins/oasis_debug.txt (append mode). +fn debug_log(msg: &[u8]) { + // SAFETY: sceIo calls with valid null-terminated path and buffer. + unsafe { + let fd = psp::sys::sceIoOpen( + b"ms0:/seplugins/oasis_debug.txt\0".as_ptr(), + psp::sys::IoOpenFlags::APPEND | psp::sys::IoOpenFlags::CREAT | psp::sys::IoOpenFlags::WR_ONLY, + 0o777, + ); + if fd >= psp::sys::SceUid(0) { + psp::sys::sceIoWrite(fd, msg.as_ptr() as *const _, msg.len()); + psp::sys::sceIoWrite(fd, b"\n".as_ptr() as *const _, 1); + psp::sys::sceIoClose(fd); + } + } +} + fn psp_main() { + debug_log(b"[OASIS] psp_main entered"); + // Load configuration from ms0:/seplugins/oasis.ini config::load_config(); + debug_log(b"[OASIS] config loaded"); // Install the display framebuffer hook + debug_log(b"[OASIS] installing display hook..."); if hook::install_display_hook() { PLUGIN_ACTIVE.store(true, Ordering::Release); + debug_log(b"[OASIS] hook installed OK"); // Start background audio thread if autoplay is enabled if config::get_config().autoplay { audio::start_audio_thread(); } + } else { + debug_log(b"[OASIS] hook install FAILED"); } + // Also log what sctrlHENFindFunction returns for diagnostics + hook::log_find_function_result(); + // Keep the plugin thread alive (it does nothing after setup -- // all work happens in the display hook and audio thread). loop { From 9985bc82aad8375eb18061b2453a33719632be02 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 14:03:30 -0600 Subject: [PATCH 06/26] fix: switch PRX plugin from restricted_std to no_std to prevent kernel crash The std runtime initialization (TLS, allocator, panic hooks) was crashing in kernel mode on 6.20 PRO-C2, causing the system to lock on game startup. module_kernel! has a no_std code path using core::intrinsics::catch_unwind that works without std. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/Cargo.toml | 2 +- crates/oasis-plugin-psp/src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/oasis-plugin-psp/Cargo.toml b/crates/oasis-plugin-psp/Cargo.toml index ff9f997..7223bc8 100644 --- a/crates/oasis-plugin-psp/Cargo.toml +++ b/crates/oasis-plugin-psp/Cargo.toml @@ -18,7 +18,7 @@ repository = "https://github.com/AndrewAltimit/oasis-os" authors = ["AndrewAltimit"] [dependencies] -psp = { git = "https://github.com/AndrewAltimit/rust-psp", branch = "fix/osk-dialog-gu-frame", features = ["kernel", "std"] } +psp = { git = "https://github.com/AndrewAltimit/rust-psp", branch = "fix/osk-dialog-gu-frame", features = ["kernel"] } # unicode-width workaround for mips target panic. [patch.crates-io] diff --git a/crates/oasis-plugin-psp/src/main.rs b/crates/oasis-plugin-psp/src/main.rs index c7331d4..6c2e8fa 100644 --- a/crates/oasis-plugin-psp/src/main.rs +++ b/crates/oasis-plugin-psp/src/main.rs @@ -19,7 +19,7 @@ //! Target: <64KB total (code + data). No heap allocator -- stack + static //! buffers only. -#![feature(restricted_std)] +#![no_std] #![no_main] #![feature(asm_experimental_arch)] From c70720cfe675407b3d51655cb672e777db12ca6d Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 14:10:27 -0600 Subject: [PATCH 07/26] fix: remove sceMp3/sceAudio/scePower/sceRtc imports from PRX plugin Unresolvable import stubs crash the PRX at load time because these modules (MP3 decoder, audio channel, power, RTC) aren't loaded in the game's kernel context. Stub out audio module and simplify status line to only use kernel-safe imports (sceIo, sceCtrl, sceKernel, sctrlHEN). Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/audio.rs | 530 +------------------------ crates/oasis-plugin-psp/src/overlay.rs | 85 +--- 2 files changed, 31 insertions(+), 584 deletions(-) diff --git a/crates/oasis-plugin-psp/src/audio.rs b/crates/oasis-plugin-psp/src/audio.rs index 13ac0a8..60fdeed 100644 --- a/crates/oasis-plugin-psp/src/audio.rs +++ b/crates/oasis-plugin-psp/src/audio.rs @@ -1,536 +1,42 @@ -//! Background MP3 playback via the PSP's hardware MP3 decoder. +//! Background MP3 playback -- STUBBED OUT. //! -//! Runs in a dedicated kernel thread, streaming MP3 files from -//! `ms0:/MUSIC/` through `sceMp3*` APIs to a reserved audio channel. -//! -//! The playlist is built by scanning the music directory at startup. -//! No heap allocator is needed for the core decode loop -- static buffers -//! are used for MP3 stream data and PCM output. +//! Audio imports (sceMp3*, sceAudio*) cause PRX load failure because +//! those modules aren't loaded in the game's kernel context. All public +//! functions are no-ops until we implement dynamic module loading. use crate::overlay; -use core::ffi::c_void; -use core::sync::atomic::{AtomicBool, AtomicI32, AtomicU8, Ordering}; - -/// Maximum number of tracks in the playlist. -const MAX_TRACKS: usize = 32; -/// Maximum filename length (null-terminated). -const MAX_FILENAME: usize = 64; -/// MP3 stream buffer size (fed to the hardware decoder). -const MP3_BUF_SIZE: usize = 8 * 1024; -/// PCM output buffer (stereo i16, 1152 samples * 2 channels). -const PCM_BUF_SIZE: usize = 4608; -/// Audio output volume (0-0x8000). -const DEFAULT_VOLUME: i32 = 0x6000; -/// Volume step for up/down. -const VOLUME_STEP: i32 = 0x800; -/// Audio thread stack size. -const AUDIO_STACK_SIZE: i32 = 8192; -/// Audio thread priority (lower = higher priority, 30 is moderate). -const AUDIO_PRIORITY: i32 = 30; -/// File read buffer for streaming. -const FILE_BUF_SIZE: usize = 16 * 1024; - -/// Playlist: array of null-terminated filenames. -static mut PLAYLIST: [[u8; MAX_FILENAME]; MAX_TRACKS] = [[0u8; MAX_FILENAME]; MAX_TRACKS]; -/// Number of tracks in the playlist. -static mut TRACK_COUNT: usize = 0; -/// Current track index. -static CURRENT_TRACK: AtomicU8 = AtomicU8::new(0); -/// Current volume. -static VOLUME: AtomicI32 = AtomicI32::new(DEFAULT_VOLUME); -/// Playback paused flag. -static PAUSED: AtomicBool = AtomicBool::new(false); -/// Audio thread running flag. -static RUNNING: AtomicBool = AtomicBool::new(false); -/// Track changed flag (signals audio thread to restart decode). -static TRACK_CHANGED: AtomicBool = AtomicBool::new(false); - -/// Music directory prefix (from config). -static mut MUSIC_DIR: [u8; 64] = [0u8; 64]; -static mut MUSIC_DIR_LEN: usize = 0; - -/// Get the current track's display name (for the overlay). +/// Get the current track's display name (stub). pub fn current_track_name() -> &'static [u8] { - // SAFETY: PLAYLIST is read-only after scan_playlist(). - unsafe { - let idx = CURRENT_TRACK.load(Ordering::Relaxed) as usize; - if idx < TRACK_COUNT { - &PLAYLIST[idx] - } else { - b"\0" - } - } + b"\0" } -/// Toggle play/pause. +/// Toggle play/pause (stub). pub fn toggle_playback() { - let was_paused = PAUSED.load(Ordering::Relaxed); - PAUSED.store(!was_paused, Ordering::Relaxed); - if was_paused { - overlay::show_osd(b"Music: Playing"); - } else { - overlay::show_osd(b"Music: Paused"); - } + overlay::show_osd(b"Audio: not available"); } -/// Skip to next track. +/// Skip to next track (stub). pub fn next_track() { - // SAFETY: TRACK_COUNT is read-only after init. - let count = unsafe { TRACK_COUNT }; - if count == 0 { - return; - } - let cur = CURRENT_TRACK.load(Ordering::Relaxed); - let next = if (cur as usize + 1) >= count { - 0 - } else { - cur + 1 - }; - CURRENT_TRACK.store(next, Ordering::Relaxed); - TRACK_CHANGED.store(true, Ordering::Release); - overlay::show_osd(b"Next track"); + overlay::show_osd(b"Audio: not available"); } -/// Skip to previous track. +/// Skip to previous track (stub). pub fn prev_track() { - // SAFETY: TRACK_COUNT is read-only after init. - let count = unsafe { TRACK_COUNT }; - if count == 0 { - return; - } - let cur = CURRENT_TRACK.load(Ordering::Relaxed); - let prev = if cur == 0 { - (count - 1) as u8 - } else { - cur - 1 - }; - CURRENT_TRACK.store(prev, Ordering::Relaxed); - TRACK_CHANGED.store(true, Ordering::Release); - overlay::show_osd(b"Prev track"); + overlay::show_osd(b"Audio: not available"); } -/// Increase volume. +/// Increase volume (stub). pub fn volume_up() { - let vol = VOLUME.load(Ordering::Relaxed); - let new_vol = (vol + VOLUME_STEP).min(0x8000); - VOLUME.store(new_vol, Ordering::Relaxed); - overlay::show_osd(b"Volume Up"); + overlay::show_osd(b"Audio: not available"); } -/// Decrease volume. +/// Decrease volume (stub). pub fn volume_down() { - let vol = VOLUME.load(Ordering::Relaxed); - let new_vol = (vol - VOLUME_STEP).max(0); - VOLUME.store(new_vol, Ordering::Relaxed); - overlay::show_osd(b"Volume Down"); + overlay::show_osd(b"Audio: not available"); } -/// Start the background audio thread. +/// Start the background audio thread (stub). pub fn start_audio_thread() { - if RUNNING.load(Ordering::Relaxed) { - return; - } - - // Copy music dir from config - let cfg = crate::config::get_config(); - // SAFETY: Single-threaded init. - unsafe { - let len = cfg.music_dir_len.min(63); - let mut i = 0; - while i < len { - MUSIC_DIR[i] = cfg.music_dir[i]; - i += 1; - } - MUSIC_DIR[len] = 0; - MUSIC_DIR_LEN = len; - } - - // Scan playlist - scan_playlist(); - - // SAFETY: TRACK_COUNT is set by scan_playlist. - if unsafe { TRACK_COUNT } == 0 { - overlay::show_osd(b"No MP3 files found"); - return; - } - - // Create kernel thread for audio playback - // SAFETY: Creating a kernel thread with valid parameters. - unsafe { - let tid = psp::sys::sceKernelCreateThread( - b"OasisAudio\0".as_ptr(), - audio_thread_entry, - AUDIO_PRIORITY, - AUDIO_STACK_SIZE, - psp::sys::ThreadAttributes::empty(), - core::ptr::null_mut(), - ); - if tid >= psp::sys::SceUid(0) { - RUNNING.store(true, Ordering::Release); - psp::sys::sceKernelStartThread(tid, 0, core::ptr::null_mut()); - } - } -} - -/// Scan the music directory for MP3 files. -fn scan_playlist() { - // Build null-terminated path - // SAFETY: MUSIC_DIR is valid after init. - let dir_path = unsafe { &MUSIC_DIR[..MUSIC_DIR_LEN + 1] }; - - // SAFETY: sceIoDopen with null-terminated path. - let dfd = unsafe { psp::sys::sceIoDopen(dir_path.as_ptr()) }; - if dfd < psp::sys::SceUid(0) { - return; - } - - let mut dirent = unsafe { core::mem::zeroed::() }; - - // SAFETY: Iterating directory entries. - unsafe { - while TRACK_COUNT < MAX_TRACKS { - let ret = psp::sys::sceIoDread(dfd, &mut dirent); - if ret <= 0 { - break; - } - - // Check if it's a regular file ending in .mp3 or .MP3 - let name_ptr = dirent.d_name.as_ptr() as *const u8; - let mut name_len = 0; - while name_len < 256 && *name_ptr.add(name_len) != 0 { - name_len += 1; - } - - if name_len < 5 { - continue; - } - - // Check .mp3 extension (case-insensitive) - let ext_start = name_len - 4; - let b1 = (*name_ptr.add(ext_start)).to_ascii_lowercase(); - let b2 = (*name_ptr.add(ext_start + 1)).to_ascii_lowercase(); - let b3 = (*name_ptr.add(ext_start + 2)).to_ascii_lowercase(); - let b4 = (*name_ptr.add(ext_start + 3)).to_ascii_lowercase(); - - if b1 != b'.' || b2 != b'm' || b3 != b'p' || b4 != b'3' { - continue; - } - - // Store filename (just the name, not full path) - let store_len = name_len.min(MAX_FILENAME - 1); - let mut i = 0; - while i < store_len { - PLAYLIST[TRACK_COUNT][i] = *name_ptr.add(i); - i += 1; - } - PLAYLIST[TRACK_COUNT][store_len] = 0; - TRACK_COUNT += 1; - } - - psp::sys::sceIoDclose(dfd); - } -} - -/// Build a full path for a track: music_dir + filename. -/// -/// Returns the length of the path (excluding null terminator). -fn build_track_path(buf: &mut [u8; 128], track_idx: usize) -> usize { - // SAFETY: MUSIC_DIR and PLAYLIST are valid after init. - unsafe { - let mut pos = 0; - - // Copy music dir - let mut i = 0; - while i < MUSIC_DIR_LEN && pos < 127 { - buf[pos] = MUSIC_DIR[i]; - pos += 1; - i += 1; - } - - // Ensure trailing slash - if pos > 0 && buf[pos - 1] != b'/' { - buf[pos] = b'/'; - pos += 1; - } - - // Copy filename - i = 0; - while i < MAX_FILENAME && PLAYLIST[track_idx][i] != 0 && pos < 127 { - buf[pos] = PLAYLIST[track_idx][i]; - pos += 1; - i += 1; - } - - buf[pos] = 0; - pos - } -} - -/// Audio thread entry point. -/// -/// Loops: open MP3 file -> init decoder -> decode+output loop -> next track. -/// -/// # Safety -/// Called as a PSP kernel thread entry point. -unsafe extern "C" fn audio_thread_entry(_args: usize, _argp: *mut c_void) -> i32 { - // Static buffers for MP3 decode (no heap) - static mut MP3_BUF: [u8; MP3_BUF_SIZE] = [0u8; MP3_BUF_SIZE]; - static mut PCM_BUF: [i16; PCM_BUF_SIZE] = [0i16; PCM_BUF_SIZE]; - static mut FILE_BUF: [u8; FILE_BUF_SIZE] = [0u8; FILE_BUF_SIZE]; - - // Reserve an audio channel - let channel = unsafe { - psp::sys::sceAudioChReserve( - psp::sys::AUDIO_NEXT_CHANNEL, - psp::sys::audio_sample_align(1152), - psp::sys::AudioFormat::Stereo, - ) - }; - if channel < 0 { - overlay::show_osd(b"Audio: no channel"); - RUNNING.store(false, Ordering::Release); - return -1; - } - - while RUNNING.load(Ordering::Relaxed) { - let track_idx = CURRENT_TRACK.load(Ordering::Relaxed) as usize; - // SAFETY: TRACK_COUNT is read-only after init. - let track_count = unsafe { TRACK_COUNT }; - if track_idx >= track_count { - // SAFETY: sleep to avoid busy loop. - unsafe { - psp::sys::sceKernelDelayThread(100_000); - } - continue; - } - - // Build full path - let mut path_buf = [0u8; 128]; - build_track_path(&mut path_buf, track_idx); - - // Open MP3 file - // SAFETY: path_buf is null-terminated. - let fd = unsafe { - psp::sys::sceIoOpen( - path_buf.as_ptr(), - psp::sys::IoOpenFlags::RD_ONLY, - 0, - ) - }; - if fd < psp::sys::SceUid(0) { - // Skip to next track on error - advance_track(); - continue; - } - - // Get file size - // SAFETY: fd is valid. - let file_size = unsafe { psp::sys::sceIoLseek(fd, 0, psp::sys::IoWhence::End) } as u32; - unsafe { - psp::sys::sceIoLseek(fd, 0, psp::sys::IoWhence::Set); - } - - // Init MP3 resource - // SAFETY: sceMp3 syscalls. - let mp3_ret = unsafe { psp::sys::sceMp3InitResource() }; - if mp3_ret < 0 { - unsafe { - psp::sys::sceIoClose(fd); - } - overlay::show_osd(b"MP3 init failed"); - advance_track(); - continue; - } - - // Reserve MP3 handle with static buffers - // SAFETY: Static buffers are valid, single audio thread. - let mut init_arg = psp::sys::SceMp3InitArg { - mp3_stream_start: 0, - unk1: 0, - mp3_stream_end: file_size, - unk2: 0, - mp3_buf: (&raw mut MP3_BUF) as *mut c_void, - mp3_buf_size: MP3_BUF_SIZE as i32, - pcm_buf: (&raw mut PCM_BUF) as *mut c_void, - pcm_buf_size: (PCM_BUF_SIZE * 2) as i32, - }; - - let handle_id = unsafe { psp::sys::sceMp3ReserveMp3Handle(&mut init_arg) }; - if handle_id < 0 { - unsafe { - psp::sys::sceMp3TermResource(); - psp::sys::sceIoClose(fd); - } - advance_track(); - continue; - } - let handle = psp::sys::Mp3Handle(handle_id); - - // Feed initial data from file - // SAFETY: Static buffers, fd is valid. - let feed_ok = unsafe { feed_mp3_from_file(handle, fd, &raw mut FILE_BUF) }; - if !feed_ok { - unsafe { - psp::sys::sceMp3ReleaseMp3Handle(handle); - psp::sys::sceMp3TermResource(); - psp::sys::sceIoClose(fd); - } - advance_track(); - continue; - } - - // Initialize decoder - let init_ret = unsafe { psp::sys::sceMp3Init(handle) }; - if init_ret < 0 { - unsafe { - psp::sys::sceMp3ReleaseMp3Handle(handle); - psp::sys::sceMp3TermResource(); - psp::sys::sceIoClose(fd); - } - advance_track(); - continue; - } - - TRACK_CHANGED.store(false, Ordering::Release); - - // Decode + output loop - loop { - if !RUNNING.load(Ordering::Relaxed) || TRACK_CHANGED.load(Ordering::Relaxed) { - break; - } - - // Handle pause - if PAUSED.load(Ordering::Relaxed) { - // SAFETY: Sleep while paused. - unsafe { - psp::sys::sceKernelDelayThread(50_000); - } - continue; - } - - // Feed more data if needed - // SAFETY: handle and fd are valid. - unsafe { - if psp::sys::sceMp3CheckStreamDataNeeded(handle) > 0 { - if !feed_mp3_from_file(handle, fd, &raw mut FILE_BUF) { - break; // EOF or error - } - } - } - - // Decode a frame - let mut out_ptr: *mut i16 = core::ptr::null_mut(); - let decoded = unsafe { psp::sys::sceMp3Decode(handle, &mut out_ptr) }; - if decoded <= 0 || out_ptr.is_null() { - break; // End of track - } - - // Output decoded PCM to audio channel - let vol = VOLUME.load(Ordering::Relaxed); - // SAFETY: out_ptr is valid PCM data from the decoder. - unsafe { - psp::sys::sceAudioOutputBlocking(channel, vol, out_ptr as *mut c_void); - } - } - - // Cleanup - unsafe { - psp::sys::sceMp3ReleaseMp3Handle(handle); - psp::sys::sceMp3TermResource(); - psp::sys::sceIoClose(fd); - } - - // If track wasn't manually changed, advance to next - if !TRACK_CHANGED.load(Ordering::Relaxed) { - advance_track(); - } - } - - // Release audio channel - unsafe { - psp::sys::sceAudioChRelease(channel); - } - RUNNING.store(false, Ordering::Release); - 0 -} - -/// Feed MP3 data from a file to the decoder's stream buffer. -/// -/// # Safety -/// `handle` must be a valid MP3 handle, `fd` a valid file descriptor, -/// `file_buf` must be a valid pointer to a `[u8; FILE_BUF_SIZE]` buffer. -unsafe fn feed_mp3_from_file( - handle: psp::sys::Mp3Handle, - fd: psp::sys::SceUid, - file_buf: *mut [u8; FILE_BUF_SIZE], -) -> bool { - let mut dst_ptr: *mut u8 = core::ptr::null_mut(); - let mut to_write: i32 = 0; - let mut src_pos: i32 = 0; - - // SAFETY: sceMp3 syscalls with valid handle. - let ret = unsafe { - psp::sys::sceMp3GetInfoToAddStreamData(handle, &mut dst_ptr, &mut to_write, &mut src_pos) - }; - if ret < 0 || to_write <= 0 || dst_ptr.is_null() { - return false; - } - - // SAFETY: fd is valid. - unsafe { - psp::sys::sceIoLseek(fd, src_pos as i64, psp::sys::IoWhence::Set); - } - - // Read from file in chunks - let mut total_read = 0i32; - while total_read < to_write { - let chunk = ((to_write - total_read) as usize).min(FILE_BUF_SIZE); - // SAFETY: file_buf is valid, fd is valid. - let bytes_read = unsafe { - psp::sys::sceIoRead(fd, file_buf as *mut _, chunk as u32) - }; - if bytes_read <= 0 { - break; - } - - // Copy to decoder buffer - // SAFETY: dst_ptr is valid, file_buf is valid, bounds checked. - let mut i = 0; - while i < bytes_read as usize { - unsafe { - *dst_ptr.add(total_read as usize + i) = (*file_buf)[i]; - } - i += 1; - } - total_read += bytes_read; - } - - if total_read <= 0 { - // SAFETY: Notify decoder with 0 bytes (EOF). - unsafe { - psp::sys::sceMp3NotifyAddStreamData(handle, 0); - } - return false; - } - - // SAFETY: Notify decoder of added data. - let ret = unsafe { psp::sys::sceMp3NotifyAddStreamData(handle, total_read) }; - ret >= 0 -} - -/// Advance to the next track (wrap around). -fn advance_track() { - // SAFETY: TRACK_COUNT is read-only. - let count = unsafe { TRACK_COUNT }; - if count == 0 { - return; - } - let cur = CURRENT_TRACK.load(Ordering::Relaxed); - let next = if (cur as usize + 1) >= count { - 0 - } else { - cur + 1 - }; - CURRENT_TRACK.store(next, Ordering::Relaxed); + // No-op: sceMp3/sceAudio imports removed to prevent PRX load failure. } diff --git a/crates/oasis-plugin-psp/src/overlay.rs b/crates/oasis-plugin-psp/src/overlay.rs index 2b08cdc..4241e89 100644 --- a/crates/oasis-plugin-psp/src/overlay.rs +++ b/crates/oasis-plugin-psp/src/overlay.rs @@ -230,24 +230,9 @@ unsafe fn execute_menu_action(item: u8) { } } -/// Cycle CPU clock between 333/266/222 MHz. +/// Cycle CPU clock (stub -- scePower imports removed). fn cycle_cpu_clock() { - // SAFETY: Power syscalls. - let current = unsafe { psp::sys::scePowerGetCpuClockFrequency() }; - let (cpu, bus) = match current { - 333 => (266, 133), - 266 => (222, 111), - _ => (333, 166), - }; - // SAFETY: Setting CPU/bus frequency. - unsafe { - psp::sys::scePowerSetClockFrequency(cpu, cpu, bus); - } - show_osd(match cpu { - 333 => b"CPU: 333 MHz (max)", - 266 => b"CPU: 266 MHz (balanced)", - _ => b"CPU: 222 MHz (power save)", - }); + show_osd(b"CPU clock: not available"); } /// Draw the full menu overlay. @@ -300,72 +285,28 @@ unsafe fn draw_menu(fb: *mut u32, stride: u32) { } } -/// Draw the status line (battery, CPU, time). +/// Draw the status line (static text -- power/RTC imports removed). /// /// # Safety /// `fb` must be valid. unsafe fn draw_status_line(fb: *mut u32, stride: u32) { - let mut buf = [0u8; 64]; - let mut pos = 0usize; - - // Battery - // SAFETY: Power syscalls, no side effects. - let bat = unsafe { psp::sys::scePowerGetBatteryLifePercent() }; - let charging = unsafe { psp::sys::scePowerIsBatteryCharging() } != 0; - pos = write_str(&mut buf, pos, b"Bat:"); - pos = write_u32(&mut buf, pos, bat as u32); - pos = write_str(&mut buf, pos, b"%"); - if charging { - pos = write_str(&mut buf, pos, b"+"); - } - pos = write_str(&mut buf, pos, b" CPU:"); - - // CPU clock - let cpu = unsafe { psp::sys::scePowerGetCpuClockFrequency() }; - pos = write_u32(&mut buf, pos, cpu as u32); - pos = write_str(&mut buf, pos, b"MHz "); - - // Time - // SAFETY: ScePspDateTime is repr(C), zeroed is valid. - let mut dt = unsafe { core::mem::zeroed::() }; - if unsafe { psp::sys::sceRtcGetCurrentClockLocalTime(&mut dt) } >= 0 { - pos = write_u32_pad2(&mut buf, pos, dt.hour as u32); - pos = write_str(&mut buf, pos, b":"); - pos = write_u32_pad2(&mut buf, pos, dt.minutes as u32); - } - - // SAFETY: buf is valid, render functions check bounds. + // SAFETY: render functions check bounds. unsafe { - render::draw_string(fb, stride, OVERLAY_X + 8, STATUS_Y, &buf[..pos], colors::GREEN); + render::draw_string( + fb, stride, + OVERLAY_X + 8, STATUS_Y, + b"OASIS Plugin v0.1", + colors::GREEN, + ); } } -/// Draw the now-playing track name. +/// Draw the now-playing track name (stub). /// /// # Safety /// `fb` must be valid. -unsafe fn draw_now_playing(fb: *mut u32, stride: u32) { - let track = audio::current_track_name(); - if track[0] != 0 { - let mut buf = [0u8; 56]; - let mut pos = write_str(&mut buf, 0, b"Now: "); - // Copy track name (truncated) - let mut i = 0; - while i < track.len() && track[i] != 0 && pos < buf.len() - 1 { - buf[pos] = track[i]; - pos += 1; - i += 1; - } - // SAFETY: render functions check bounds. - unsafe { - render::draw_string( - fb, stride, - OVERLAY_X + 8, STATUS_Y + 16, - &buf[..pos], - colors::YELLOW, - ); - } - } +unsafe fn draw_now_playing(_fb: *mut u32, _stride: u32) { + // No-op: audio module is stubbed out. } /// Write a byte string into a buffer. Returns new position. From 5b4397ab9e410c040e7cbd5d88b6a7931c4b724d Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 14:23:59 -0600 Subject: [PATCH 08/26] fix: use panic=unwind for PRX plugin to match rust-psp SDK expectations The SDK's no_std path uses extern crate panic_unwind and core::intrinsics::catch_unwind which require unwind support. Also picks up rust-psp kernel thread fix (no USER flag) and kernel partition allocator. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/Cargo.lock | 2 +- crates/oasis-plugin-psp/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/oasis-plugin-psp/Cargo.lock b/crates/oasis-plugin-psp/Cargo.lock index 605be19..1b7ea9c 100644 --- a/crates/oasis-plugin-psp/Cargo.lock +++ b/crates/oasis-plugin-psp/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "psp" version = "0.4.0" -source = "git+https://github.com/AndrewAltimit/rust-psp?branch=fix%2Fosk-dialog-gu-frame#17e948323f77abe57c7b1a76430fde2131171fdd" +source = "git+https://github.com/AndrewAltimit/rust-psp?branch=fix%2Fosk-dialog-gu-frame#c44425192553c84eaebe85c983f72c828c7515f7" dependencies = [ "bitflags", "libm", diff --git a/crates/oasis-plugin-psp/Cargo.toml b/crates/oasis-plugin-psp/Cargo.toml index 7223bc8..45199c3 100644 --- a/crates/oasis-plugin-psp/Cargo.toml +++ b/crates/oasis-plugin-psp/Cargo.toml @@ -28,5 +28,5 @@ unicode-width = { git = "https://git.sr.ht/~sajattack/unicode-width" } opt-level = "z" # minimize size (<64KB target) lto = true codegen-units = 1 -panic = "abort" +panic = "unwind" # Do NOT set strip = true -- it conflicts with --emit-relocs needed by prxgen. From 71d77c0ee9974b89a902fdced1941007a5c41631 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 14:34:31 -0600 Subject: [PATCH 09/26] debug: granular logging inside hook installation to find crash point Separates sctrlHENFindFunction and sctrlHENPatchSyscall calls with debug_log messages to identify which CFW API call crashes on 6.20 PRO-C2. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 48 +++++++++++++++++------------ 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 72af395..5a649bf 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -92,31 +92,41 @@ pub fn install_display_hook() -> bool { return true; } - // SAFETY: We are in kernel mode (module_kernel!). Try multiple - // module/library name combinations for compatibility across CFW versions. + crate::debug_log(b"[OASIS] hook: calling sctrlHENFindFunction..."); + + // SAFETY: We are in kernel mode (module_kernel!). Try each name combo. unsafe { - for &(module, library) in DISPLAY_MODULE_NAMES { - let hook = psp::hook::SyscallHook::install( - module.as_ptr(), - library.as_ptr(), - NID_SCE_DISPLAY_SET_FRAME_BUF, + // First, just test if sctrlHENFindFunction works at all + let test_ptr = psp::sys::sctrlHENFindFunction( + b"sceDisplay_Service\0".as_ptr(), + b"sceDisplay\0".as_ptr(), + NID_SCE_DISPLAY_SET_FRAME_BUF, + ); + if test_ptr.is_null() { + crate::debug_log(b"[OASIS] hook: FindFunction returned NULL"); + } else { + crate::debug_log(b"[OASIS] hook: FindFunction returned non-NULL"); + } + + crate::debug_log(b"[OASIS] hook: calling PatchSyscall..."); + if !test_ptr.is_null() { + let ret = psp::sys::sctrlHENPatchSyscall( + test_ptr, hooked_set_frame_buf as *mut u8, ); + if ret < 0 { + crate::debug_log(b"[OASIS] hook: PatchSyscall FAILED"); + } else { + crate::debug_log(b"[OASIS] hook: PatchSyscall OK"); - match hook { - Some(h) => { - // Store the original function pointer for the trampoline - ORIGINAL_SET_FRAME_BUF = - Some(core::mem::transmute(h.original_ptr())); + ORIGINAL_SET_FRAME_BUF = + Some(core::mem::transmute(test_ptr)); - // Flush caches to ensure the patched syscall is visible - psp::sys::sceKernelIcacheInvalidateAll(); - psp::sys::sceKernelDcacheWritebackAll(); + psp::sys::sceKernelIcacheInvalidateAll(); + psp::sys::sceKernelDcacheWritebackAll(); - HOOK_INSTALLED.store(true, Ordering::Release); - return true; - } - None => continue, + HOOK_INSTALLED.store(true, Ordering::Release); + return true; } } } From 9eb49fd543adae23b6dd343cdf5cd64545e0c699 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 14:43:23 -0600 Subject: [PATCH 10/26] debug: log sctrlHENFindFunction stub address before calling it Check if the import stub was actually resolved by CFW before calling the function. Logs the raw function pointer address. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 5a649bf..58dec53 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -92,6 +92,21 @@ pub fn install_display_hook() -> bool { return true; } + // Check if sctrlHENFindFunction import was resolved properly. + // If the stub wasn't patched by CFW, the pointer will be 0 or garbage. + let fn_addr = psp::sys::sctrlHENFindFunction as usize; + { + let mut buf = [0u8; 48]; + let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] FindFunc addr=0x"); + pos = write_log_hex(&mut buf, pos, fn_addr as u32); + crate::debug_log(&buf[..pos]); + } + + if fn_addr == 0 || fn_addr < 0x08000000 { + crate::debug_log(b"[OASIS] hook: sctrlHEN import NOT resolved!"); + return false; + } + crate::debug_log(b"[OASIS] hook: calling sctrlHENFindFunction..."); // SAFETY: We are in kernel mode (module_kernel!). Try each name combo. From 15db5c6c48ba97c04eaf93faf3c94dd8e41d649b Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 14:50:29 -0600 Subject: [PATCH 11/26] fix: flush dcache and use stack strings before sctrlHENFindFunction MIPS dcache coherency: freshly loaded PRX .rodata may not be visible to kernel functions. Flush dcache before calling sctrlHENFindFunction and copy string args to stack to avoid stale cache reads. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 58dec53..19f16f6 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -107,14 +107,26 @@ pub fn install_display_hook() -> bool { return false; } - crate::debug_log(b"[OASIS] hook: calling sctrlHENFindFunction..."); + // Flush dcache to ensure our string data is visible to kernel functions. + // Freshly loaded PRX data may still be in dcache on MIPS. + unsafe { + psp::sys::sceKernelDcacheWritebackAll(); + } + + crate::debug_log(b"[OASIS] hook: dcache flushed, calling FindFunc..."); // SAFETY: We are in kernel mode (module_kernel!). Try each name combo. unsafe { - // First, just test if sctrlHENFindFunction works at all + // Build module/library strings on the stack (avoids .rodata cache issues) + let mod_name: [u8; 19] = *b"sceDisplay_Service\0"; + let lib_name: [u8; 11] = *b"sceDisplay\0"; + + // Flush these stack variables too + psp::sys::sceKernelDcacheWritebackAll(); + let test_ptr = psp::sys::sctrlHENFindFunction( - b"sceDisplay_Service\0".as_ptr(), - b"sceDisplay\0".as_ptr(), + mod_name.as_ptr(), + lib_name.as_ptr(), NID_SCE_DISPLAY_SET_FRAME_BUF, ); if test_ptr.is_null() { From d61ef18e4f04cd3c137102ed3665dbae3194dff4 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 14:54:53 -0600 Subject: [PATCH 12/26] debug: test sctrlHENFindFunction with known ThreadMan module first If ThreadMan lookup crashes too, the function itself is broken on PRO-C2. If it works, the issue is display-specific arguments. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 30 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 19f16f6..35420fc 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -113,20 +113,30 @@ pub fn install_display_hook() -> bool { psp::sys::sceKernelDcacheWritebackAll(); } - crate::debug_log(b"[OASIS] hook: dcache flushed, calling FindFunc..."); + crate::debug_log(b"[OASIS] hook: dcache flushed"); - // SAFETY: We are in kernel mode (module_kernel!). Try each name combo. + // SAFETY: We are in kernel mode (module_kernel!). unsafe { - // Build module/library strings on the stack (avoids .rodata cache issues) - let mod_name: [u8; 19] = *b"sceDisplay_Service\0"; - let lib_name: [u8; 11] = *b"sceDisplay\0"; - - // Flush these stack variables too - psp::sys::sceKernelDcacheWritebackAll(); + // Test 1: call with a known kernel module to see if the function + // works at all. sceKernelDelayThread NID=0xCEAB00D2 from + // sceThreadManager / ThreadManForKernel. + crate::debug_log(b"[OASIS] hook: test FindFunc(ThreadMan)..."); + let test1 = psp::sys::sctrlHENFindFunction( + b"sceThreadManager\0".as_ptr(), + b"ThreadManForKernel\0".as_ptr(), + 0xCEAB00D2_u32, // sceKernelDelayThread + ); + if test1.is_null() { + crate::debug_log(b"[OASIS] hook: ThreadMan -> NULL"); + } else { + crate::debug_log(b"[OASIS] hook: ThreadMan -> found!"); + } + // Test 2: try the display module + crate::debug_log(b"[OASIS] hook: test FindFunc(Display)..."); let test_ptr = psp::sys::sctrlHENFindFunction( - mod_name.as_ptr(), - lib_name.as_ptr(), + b"sceDisplay_Service\0".as_ptr(), + b"sceDisplay\0".as_ptr(), NID_SCE_DISPLAY_SET_FRAME_BUF, ); if test_ptr.is_null() { From e2ec44885a66462b9c6b379a7d9b38533589e68d Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 14:58:31 -0600 Subject: [PATCH 13/26] debug: dump import stub machine code to diagnose call crash Read the MIPS instructions at the sctrlHENFindFunction and sctrlHENPatchSyscall stubs to see what the firmware patched them to (j addr, syscall, jr $ra, etc). Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 48 ++++++++++++++++------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 35420fc..5411a89 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -107,33 +107,39 @@ pub fn install_display_hook() -> bool { return false; } - // Flush dcache to ensure our string data is visible to kernel functions. - // Freshly loaded PRX data may still be in dcache on MIPS. unsafe { psp::sys::sceKernelDcacheWritebackAll(); } - crate::debug_log(b"[OASIS] hook: dcache flushed"); - - // SAFETY: We are in kernel mode (module_kernel!). + // Read the actual MIPS instructions at the import stub to see what + // the firmware patched it to (j addr, syscall, jr $ra, etc.) unsafe { - // Test 1: call with a known kernel module to see if the function - // works at all. sceKernelDelayThread NID=0xCEAB00D2 from - // sceThreadManager / ThreadManForKernel. - crate::debug_log(b"[OASIS] hook: test FindFunc(ThreadMan)..."); - let test1 = psp::sys::sctrlHENFindFunction( - b"sceThreadManager\0".as_ptr(), - b"ThreadManForKernel\0".as_ptr(), - 0xCEAB00D2_u32, // sceKernelDelayThread - ); - if test1.is_null() { - crate::debug_log(b"[OASIS] hook: ThreadMan -> NULL"); - } else { - crate::debug_log(b"[OASIS] hook: ThreadMan -> found!"); - } + let stub = fn_addr as *const u32; + let word0 = *stub; // First instruction + let word1 = *stub.add(1); // Second instruction + let mut buf = [0u8; 64]; + let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] stub: "); + pos = write_log_hex(&mut buf, pos, word0); + pos = write_log_bytes(&mut buf, pos, b" "); + pos = write_log_hex(&mut buf, pos, word1); + crate::debug_log(&buf[..pos]); + + // Also read PatchSyscall stub + let fn2_addr = psp::sys::sctrlHENPatchSyscall as usize; + let stub2 = fn2_addr as *const u32; + let w0 = *stub2; + let w1 = *stub2.add(1); + let mut buf2 = [0u8; 64]; + let mut pos2 = write_log_bytes(&mut buf2, 0, b"[OASIS] stub2: "); + pos2 = write_log_hex(&mut buf2, pos2, w0); + pos2 = write_log_bytes(&mut buf2, pos2, b" "); + pos2 = write_log_hex(&mut buf2, pos2, w1); + crate::debug_log(&buf2[..pos2]); + } - // Test 2: try the display module - crate::debug_log(b"[OASIS] hook: test FindFunc(Display)..."); + // Now try calling FindFunction + crate::debug_log(b"[OASIS] hook: calling FindFunc..."); + unsafe { let test_ptr = psp::sys::sctrlHENFindFunction( b"sceDisplay_Service\0".as_ptr(), b"sceDisplay\0".as_ptr(), From d5cb7c94d622615fa5a31364e1a45544f29ff8d3 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 15:03:04 -0600 Subject: [PATCH 14/26] fix: add 3s startup delay before calling sctrlHENFindFunction CFW APIs crash if called before SystemControl is fully initialized. Common PSP plugin pattern: wait for the system to stabilize before hooking display functions. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 35 ++++++----------------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 5411a89..302cb2a 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -107,39 +107,18 @@ pub fn install_display_hook() -> bool { return false; } + // Wait for CFW and game to fully initialize before calling CFW APIs. + // sctrlHENFindFunction crashes if called too early (before + // SystemControl's internal module list is populated). + crate::debug_log(b"[OASIS] hook: waiting for system init..."); unsafe { - psp::sys::sceKernelDcacheWritebackAll(); + psp::sys::sceKernelDelayThread(3_000_000); // 3 seconds } + crate::debug_log(b"[OASIS] hook: delay done, calling FindFunc..."); - // Read the actual MIPS instructions at the import stub to see what - // the firmware patched it to (j addr, syscall, jr $ra, etc.) unsafe { - let stub = fn_addr as *const u32; - let word0 = *stub; // First instruction - let word1 = *stub.add(1); // Second instruction - let mut buf = [0u8; 64]; - let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] stub: "); - pos = write_log_hex(&mut buf, pos, word0); - pos = write_log_bytes(&mut buf, pos, b" "); - pos = write_log_hex(&mut buf, pos, word1); - crate::debug_log(&buf[..pos]); - - // Also read PatchSyscall stub - let fn2_addr = psp::sys::sctrlHENPatchSyscall as usize; - let stub2 = fn2_addr as *const u32; - let w0 = *stub2; - let w1 = *stub2.add(1); - let mut buf2 = [0u8; 64]; - let mut pos2 = write_log_bytes(&mut buf2, 0, b"[OASIS] stub2: "); - pos2 = write_log_hex(&mut buf2, pos2, w0); - pos2 = write_log_bytes(&mut buf2, pos2, b" "); - pos2 = write_log_hex(&mut buf2, pos2, w1); - crate::debug_log(&buf2[..pos2]); - } + psp::sys::sceKernelDcacheWritebackAll(); - // Now try calling FindFunction - crate::debug_log(b"[OASIS] hook: calling FindFunc..."); - unsafe { let test_ptr = psp::sys::sctrlHENFindFunction( b"sceDisplay_Service\0".as_ptr(), b"sceDisplay\0".as_ptr(), From 13abc2bfb639c52065d942c5bf5405f882012348 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 15:21:16 -0600 Subject: [PATCH 15/26] fix: validate sctrl import stubs before calling CFW functions Add runtime check that SystemCtrlForKernel import stubs were resolved by the firmware loader before calling them. Reads the raw stub bytes - a resolved stub starts with `jr $ra` (0x03E00008), while an unresolved stub contains Stub struct pointer data. Also updated rust-psp dependency which fixes the import flags from 0x4001 to 0x4009 (adds kernel library bit so the loader searches kernel space for SystemCtrlForKernel). Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/Cargo.lock | 2 +- crates/oasis-plugin-psp/src/hook.rs | 126 +++++++++++++++++++--------- 2 files changed, 89 insertions(+), 39 deletions(-) diff --git a/crates/oasis-plugin-psp/Cargo.lock b/crates/oasis-plugin-psp/Cargo.lock index 1b7ea9c..fbb9719 100644 --- a/crates/oasis-plugin-psp/Cargo.lock +++ b/crates/oasis-plugin-psp/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "psp" version = "0.4.0" -source = "git+https://github.com/AndrewAltimit/rust-psp?branch=fix%2Fosk-dialog-gu-frame#c44425192553c84eaebe85c983f72c828c7515f7" +source = "git+https://github.com/AndrewAltimit/rust-psp?branch=fix%2Fosk-dialog-gu-frame#7030322b95a269a017908a73e5c1e2ee15ead4cc" dependencies = [ "bitflags", "libm", diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 302cb2a..78f1b47 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -19,6 +19,28 @@ static mut ORIGINAL_SET_FRAME_BUF: Option< /// NID for sceDisplaySetFrameBuf. const NID_SCE_DISPLAY_SET_FRAME_BUF: u32 = 0x289D82FE; +// Reference the raw stub symbol generated by psp_extern! for +// sctrlHENFindFunction. It is #[unsafe(no_mangle)] so we can access it +// from here. The firmware replaces this 8-byte region with `jr $ra; +// syscall N` at module load time if the import was resolved. +unsafe extern "C" { + #[link_name = "__sctrlHENFindFunction_stub"] + static FIND_FUNC_STUB: [u32; 2]; +} + +/// Check if the SystemCtrlForKernel import stubs were resolved by firmware. +/// +/// A resolved stub starts with `jr $ra` (0x03E00008). An unresolved stub +/// contains the raw `Stub` struct data (two pointers to lib entry and NID). +fn is_sctrl_resolved() -> bool { + // SAFETY: Reading the stub bytes to check if they were patched. + // The symbol is valid as long as the psp crate is linked. + unsafe { + let first_word = core::ptr::read_volatile(&raw const FIND_FUNC_STUB as *const u32); + first_word == 0x03E00008 // jr $ra + } +} + /// Our hook function that replaces `sceDisplaySetFrameBuf`. /// /// Called in the game's display thread context every vsync. Must be fast: @@ -92,68 +114,91 @@ pub fn install_display_hook() -> bool { return true; } - // Check if sctrlHENFindFunction import was resolved properly. - // If the stub wasn't patched by CFW, the pointer will be 0 or garbage. - let fn_addr = psp::sys::sctrlHENFindFunction as usize; + // Check if SystemCtrlForKernel import stubs were resolved by firmware. + // The stub should contain `jr $ra; syscall N` if resolved. { - let mut buf = [0u8; 48]; - let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] FindFunc addr=0x"); - pos = write_log_hex(&mut buf, pos, fn_addr as u32); + // SAFETY: Reading stub bytes for diagnostics. + let (w0, w1) = unsafe { + let p = &raw const FIND_FUNC_STUB as *const u32; + ( + core::ptr::read_volatile(p), + core::ptr::read_volatile(p.add(1)), + ) + }; + let mut buf = [0u8; 64]; + let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] stub: "); + pos = write_log_hex(&mut buf, pos, w0); + pos = write_log_bytes(&mut buf, pos, b" "); + pos = write_log_hex(&mut buf, pos, w1); crate::debug_log(&buf[..pos]); } - if fn_addr == 0 || fn_addr < 0x08000000 { - crate::debug_log(b"[OASIS] hook: sctrlHEN import NOT resolved!"); + if !is_sctrl_resolved() { + crate::debug_log(b"[OASIS] hook: sctrl stubs NOT resolved by FW!"); + crate::debug_log(b"[OASIS] hook: SystemCtrlForKernel unavailable"); return false; } + crate::debug_log(b"[OASIS] hook: sctrl stubs resolved OK"); // Wait for CFW and game to fully initialize before calling CFW APIs. - // sctrlHENFindFunction crashes if called too early (before + // sctrlHENFindFunction may crash if called too early (before // SystemControl's internal module list is populated). crate::debug_log(b"[OASIS] hook: waiting for system init..."); unsafe { - psp::sys::sceKernelDelayThread(3_000_000); // 3 seconds + psp::sys::sceKernelDelayThread(2_000_000); // 2 seconds } - crate::debug_log(b"[OASIS] hook: delay done, calling FindFunc..."); + crate::debug_log(b"[OASIS] hook: calling FindFunc..."); unsafe { psp::sys::sceKernelDcacheWritebackAll(); - let test_ptr = psp::sys::sctrlHENFindFunction( - b"sceDisplay_Service\0".as_ptr(), - b"sceDisplay\0".as_ptr(), - NID_SCE_DISPLAY_SET_FRAME_BUF, - ); - if test_ptr.is_null() { - crate::debug_log(b"[OASIS] hook: FindFunction returned NULL"); - } else { - crate::debug_log(b"[OASIS] hook: FindFunction returned non-NULL"); + // Try each module/library name combination until one returns a + // valid function pointer for sceDisplaySetFrameBuf. + let mut display_ptr: *mut u8 = core::ptr::null_mut(); + for &(module, library) in DISPLAY_MODULE_NAMES { + let ptr = psp::sys::sctrlHENFindFunction( + module.as_ptr(), + library.as_ptr(), + NID_SCE_DISPLAY_SET_FRAME_BUF, + ); + if !ptr.is_null() { + display_ptr = ptr; + + let mut buf = [0u8; 80]; + let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] found at mod="); + pos = write_log_cstr(&mut buf, pos, module); + pos = write_log_bytes(&mut buf, pos, b" -> 0x"); + pos = write_log_hex(&mut buf, pos, ptr as u32); + crate::debug_log(&buf[..pos]); + break; + } + } + + if display_ptr.is_null() { + crate::debug_log(b"[OASIS] hook: FindFunc returned NULL for all"); + return false; } crate::debug_log(b"[OASIS] hook: calling PatchSyscall..."); - if !test_ptr.is_null() { - let ret = psp::sys::sctrlHENPatchSyscall( - test_ptr, - hooked_set_frame_buf as *mut u8, - ); - if ret < 0 { - crate::debug_log(b"[OASIS] hook: PatchSyscall FAILED"); - } else { - crate::debug_log(b"[OASIS] hook: PatchSyscall OK"); + let ret = psp::sys::sctrlHENPatchSyscall( + display_ptr, + hooked_set_frame_buf as *mut u8, + ); + if ret < 0 { + crate::debug_log(b"[OASIS] hook: PatchSyscall FAILED"); + return false; + } - ORIGINAL_SET_FRAME_BUF = - Some(core::mem::transmute(test_ptr)); + crate::debug_log(b"[OASIS] hook: PatchSyscall OK"); + ORIGINAL_SET_FRAME_BUF = Some(core::mem::transmute(display_ptr)); - psp::sys::sceKernelIcacheInvalidateAll(); - psp::sys::sceKernelDcacheWritebackAll(); + psp::sys::sceKernelIcacheInvalidateAll(); + psp::sys::sceKernelDcacheWritebackAll(); - HOOK_INSTALLED.store(true, Ordering::Release); - return true; - } - } + HOOK_INSTALLED.store(true, Ordering::Release); } - false + true } /// Log diagnostic info about sctrlHENFindFunction results. @@ -161,6 +206,11 @@ pub fn install_display_hook() -> bool { /// Tries all known module/library name combinations and logs which ones /// return a valid pointer vs null. Writes to the debug log file. pub fn log_find_function_result() { + if !is_sctrl_resolved() { + crate::debug_log(b"[OASIS] log: skipping (stubs not resolved)"); + return; + } + // SAFETY: sctrlHENFindFunction is safe to call from kernel mode. // It just looks up function pointers without side effects. unsafe { From bfeb94c06ae4c96425e36d123fea4851e7edefa3 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 15:26:06 -0600 Subject: [PATCH 16/26] fix: recognize kernel-mode j-instruction stubs as resolved Kernel imports are patched with `j target` (direct jump, opcode=2) not `jr $ra; syscall N` (user-mode pattern). The stub check now accepts both formats. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 78f1b47..eaea19f 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -30,14 +30,17 @@ unsafe extern "C" { /// Check if the SystemCtrlForKernel import stubs were resolved by firmware. /// -/// A resolved stub starts with `jr $ra` (0x03E00008). An unresolved stub -/// contains the raw `Stub` struct data (two pointers to lib entry and NID). +/// User-mode stubs are patched to `jr $ra; syscall N` (first word 0x03E00008). +/// Kernel-mode stubs are patched to `j target; nop` (first word opcode = 2). +/// An unresolved stub contains the raw `Stub` struct data (two pointers). fn is_sctrl_resolved() -> bool { // SAFETY: Reading the stub bytes to check if they were patched. // The symbol is valid as long as the psp crate is linked. unsafe { let first_word = core::ptr::read_volatile(&raw const FIND_FUNC_STUB as *const u32); - first_word == 0x03E00008 // jr $ra + // User-mode: jr $ra (0x03E00008) + // Kernel-mode: j target (opcode field bits 31-26 == 2) + first_word == 0x03E00008 || (first_word >> 26) == 2 } } From 8c08ad95eb5262a917d2d83d0c0b7e61cc3386f2 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 15:33:01 -0600 Subject: [PATCH 17/26] fix: bypass broken kernel stub delay slots with direct function calls PSP firmware patches kernel import stubs with `j target` but leaves the delay slot containing the original Stub struct data (a pointer value). This decodes to `lwl $a0, offset($at)` which corrupts arguments and crashes. Instead of calling through the psp_extern! wrapper (which executes the broken stub), extract the jump target address from the `j` instruction and call the kernel function directly via transmuted function pointer. This bypasses the stub entirely. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 137 ++++++++++++++++++++-------- 1 file changed, 101 insertions(+), 36 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index eaea19f..c427a71 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -3,6 +3,16 @@ //! Intercepts `sceDisplaySetFrameBuf` to draw the overlay on top of the //! game's framebuffer after each frame. The hook calls the original function //! first (so the game renders normally), then draws overlay elements. +//! +//! # Kernel stub workaround +//! +//! The PSP firmware patches kernel import stubs with `j target` (direct +//! jump) but does NOT write a `nop` to the delay slot. The delay slot +//! retains the original `Stub` struct data (a pointer), which decodes +//! to a garbage MIPS instruction and crashes when executed. We work +//! around this by extracting the jump target from the `j` instruction +//! and calling the function directly via a raw pointer, bypassing the +//! broken stub entirely. use crate::overlay; @@ -19,29 +29,40 @@ static mut ORIGINAL_SET_FRAME_BUF: Option< /// NID for sceDisplaySetFrameBuf. const NID_SCE_DISPLAY_SET_FRAME_BUF: u32 = 0x289D82FE; -// Reference the raw stub symbol generated by psp_extern! for -// sctrlHENFindFunction. It is #[unsafe(no_mangle)] so we can access it -// from here. The firmware replaces this 8-byte region with `jr $ra; -// syscall N` at module load time if the import was resolved. +// Reference the raw stub symbols generated by psp_extern! for +// SystemCtrlForKernel functions. They are #[unsafe(no_mangle)] so we +// can access them from here. The firmware replaces the first 4 bytes +// with a `j target` instruction for kernel imports. unsafe extern "C" { #[link_name = "__sctrlHENFindFunction_stub"] static FIND_FUNC_STUB: [u32; 2]; + + #[link_name = "__sctrlHENPatchSyscall_stub"] + static PATCH_SYSCALL_STUB: [u32; 2]; } -/// Check if the SystemCtrlForKernel import stubs were resolved by firmware. +/// Extract the absolute target address from a MIPS `j` instruction. /// -/// User-mode stubs are patched to `jr $ra; syscall N` (first word 0x03E00008). -/// Kernel-mode stubs are patched to `j target; nop` (first word opcode = 2). -/// An unresolved stub contains the raw `Stub` struct data (two pointers). -fn is_sctrl_resolved() -> bool { - // SAFETY: Reading the stub bytes to check if they were patched. - // The symbol is valid as long as the psp crate is linked. - unsafe { - let first_word = core::ptr::read_volatile(&raw const FIND_FUNC_STUB as *const u32); - // User-mode: jr $ra (0x03E00008) - // Kernel-mode: j target (opcode field bits 31-26 == 2) - first_word == 0x03E00008 || (first_word >> 26) == 2 +/// The `j` instruction encodes a 26-bit word index. The full 32-bit +/// address is formed by combining the upper 4 bits of the PC with the +/// 28-bit target (index << 2). +/// +/// Returns `None` if the instruction is not a `j` (opcode != 2). +fn extract_j_target(instruction: u32, pc: u32) -> Option { + if (instruction >> 26) != 2 { + return None; } + let offset = (instruction & 0x03FF_FFFF) << 2; + let region = pc & 0xF000_0000; + Some(region | offset) +} + +/// Check if a stub word looks like a resolved import. +/// +/// User-mode: `jr $ra` (0x03E00008) +/// Kernel-mode: `j target` (opcode bits 31-26 == 2) +fn is_stub_resolved(first_word: u32) -> bool { + first_word == 0x03E00008 || (first_word >> 26) == 2 } /// Our hook function that replaces `sceDisplaySetFrameBuf`. @@ -108,6 +129,26 @@ const DISPLAY_MODULE_NAMES: &[(&[u8], &[u8])] = &[ (b"sceDisplay\0", b"sceDisplay_driver\0"), ]; +/// Resolve a kernel import stub to a direct function pointer. +/// +/// Reads the `j target` instruction from the stub, extracts the +/// absolute target address, and returns it. This bypasses the broken +/// delay slot in kernel stubs (firmware doesn't write nop there). +/// +/// Returns `None` if the stub wasn't resolved by the firmware. +unsafe fn resolve_kernel_stub(stub: &[u32; 2]) -> Option { + let first_word = unsafe { + core::ptr::read_volatile(&raw const *stub as *const u32) + }; + if !is_stub_resolved(first_word) { + return None; + } + // For user-mode stubs (jr $ra; syscall), calling through the + // wrapper is safe. But we only get here for kernel stubs. + let stub_addr = &raw const *stub as u32; + extract_j_target(first_word, stub_addr) +} + /// Install the `sceDisplaySetFrameBuf` hook. /// /// Returns `true` on success. Must be called from kernel mode during plugin @@ -117,8 +158,7 @@ pub fn install_display_hook() -> bool { return true; } - // Check if SystemCtrlForKernel import stubs were resolved by firmware. - // The stub should contain `jr $ra; syscall N` if resolved. + // Log raw stub bytes for diagnostics. { // SAFETY: Reading stub bytes for diagnostics. let (w0, w1) = unsafe { @@ -136,16 +176,39 @@ pub fn install_display_hook() -> bool { crate::debug_log(&buf[..pos]); } - if !is_sctrl_resolved() { - crate::debug_log(b"[OASIS] hook: sctrl stubs NOT resolved by FW!"); - crate::debug_log(b"[OASIS] hook: SystemCtrlForKernel unavailable"); - return false; - } - crate::debug_log(b"[OASIS] hook: sctrl stubs resolved OK"); + // Resolve CFW function pointers by extracting j-targets from the + // patched stubs. This bypasses the broken delay slot. + // SAFETY: Reading stub data that was written by firmware at load time. + let find_func_addr = unsafe { resolve_kernel_stub(&FIND_FUNC_STUB) }; + let patch_syscall_addr = unsafe { resolve_kernel_stub(&PATCH_SYSCALL_STUB) }; + + let (find_addr, patch_addr) = match (find_func_addr, patch_syscall_addr) { + (Some(f), Some(p)) => { + let mut buf = [0u8; 64]; + let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] FindFunc=0x"); + pos = write_log_hex(&mut buf, pos, f); + pos = write_log_bytes(&mut buf, pos, b" Patch=0x"); + pos = write_log_hex(&mut buf, pos, p); + crate::debug_log(&buf[..pos]); + (f, p) + } + _ => { + crate::debug_log(b"[OASIS] hook: sctrl stubs NOT resolved!"); + return false; + } + }; + + // Cast to function pointer types matching the CFW API signatures. + // SAFETY: Addresses extracted from firmware-patched import stubs. + let sctrl_find_function: unsafe extern "C" fn( + *const u8, *const u8, u32, + ) -> *mut u8 = unsafe { core::mem::transmute(find_addr) }; + + let sctrl_patch_syscall: unsafe extern "C" fn( + *mut u8, *mut u8, + ) -> i32 = unsafe { core::mem::transmute(patch_addr) }; // Wait for CFW and game to fully initialize before calling CFW APIs. - // sctrlHENFindFunction may crash if called too early (before - // SystemControl's internal module list is populated). crate::debug_log(b"[OASIS] hook: waiting for system init..."); unsafe { psp::sys::sceKernelDelayThread(2_000_000); // 2 seconds @@ -159,7 +222,7 @@ pub fn install_display_hook() -> bool { // valid function pointer for sceDisplaySetFrameBuf. let mut display_ptr: *mut u8 = core::ptr::null_mut(); for &(module, library) in DISPLAY_MODULE_NAMES { - let ptr = psp::sys::sctrlHENFindFunction( + let ptr = sctrl_find_function( module.as_ptr(), library.as_ptr(), NID_SCE_DISPLAY_SET_FRAME_BUF, @@ -183,7 +246,7 @@ pub fn install_display_hook() -> bool { } crate::debug_log(b"[OASIS] hook: calling PatchSyscall..."); - let ret = psp::sys::sctrlHENPatchSyscall( + let ret = sctrl_patch_syscall( display_ptr, hooked_set_frame_buf as *mut u8, ); @@ -209,26 +272,29 @@ pub fn install_display_hook() -> bool { /// Tries all known module/library name combinations and logs which ones /// return a valid pointer vs null. Writes to the debug log file. pub fn log_find_function_result() { - if !is_sctrl_resolved() { + // SAFETY: Resolving the kernel stub for direct calls. + let find_addr = unsafe { resolve_kernel_stub(&FIND_FUNC_STUB) }; + let Some(addr) = find_addr else { crate::debug_log(b"[OASIS] log: skipping (stubs not resolved)"); return; - } + }; + + let sctrl_find_function: unsafe extern "C" fn( + *const u8, *const u8, u32, + ) -> *mut u8 = unsafe { core::mem::transmute(addr) }; - // SAFETY: sctrlHENFindFunction is safe to call from kernel mode. - // It just looks up function pointers without side effects. + // SAFETY: Calling the resolved CFW function from kernel mode. unsafe { for &(module, library) in DISPLAY_MODULE_NAMES { - let ptr = psp::sys::sctrlHENFindFunction( + let ptr = sctrl_find_function( module.as_ptr(), library.as_ptr(), NID_SCE_DISPLAY_SET_FRAME_BUF, ); - // Build log message: "[OASIS] FindFunc mod=X lib=Y -> 0xADDR" let mut buf = [0u8; 96]; let mut pos = 0usize; pos = write_log_bytes(&mut buf, pos, b"[OASIS] FindFunc mod="); - // Copy module name (without null terminator) pos = write_log_cstr(&mut buf, pos, module); pos = write_log_bytes(&mut buf, pos, b" lib="); pos = write_log_cstr(&mut buf, pos, library); @@ -274,7 +340,6 @@ fn write_log_cstr(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { fn write_log_hex(buf: &mut [u8], pos: usize, val: u32) -> usize { let mut p = pos; let hex = b"0123456789ABCDEF"; - // Write 8 hex digits let mut i = 0; while i < 8 { if p >= buf.len() { From 31ab19e9fd75eeedf55ffb30b5f2d1766179f6e0 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 15:42:24 -0600 Subject: [PATCH 18/26] feat: inline hook fallback when PatchSyscall fails When sctrlHENPatchSyscall returns an error (e.g. another plugin already patched the syscall table entry), fall back to inline hooking: save the first two instructions of the target function, write `j our_hook; nop` at the entry point, and build a trampoline that executes the saved instructions then jumps to original+8. Also logs the PatchSyscall return value and the original function's first two instructions for diagnostics. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 235 +++++++++++++++++----------- 1 file changed, 145 insertions(+), 90 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index c427a71..0cca457 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -7,12 +7,16 @@ //! # Kernel stub workaround //! //! The PSP firmware patches kernel import stubs with `j target` (direct -//! jump) but does NOT write a `nop` to the delay slot. The delay slot -//! retains the original `Stub` struct data (a pointer), which decodes -//! to a garbage MIPS instruction and crashes when executed. We work -//! around this by extracting the jump target from the `j` instruction -//! and calling the function directly via a raw pointer, bypassing the -//! broken stub entirely. +//! jump) but does NOT write a `nop` to the delay slot. We extract the +//! jump target and call the function directly via raw pointer. +//! +//! # Inline hook fallback +//! +//! If `sctrlHENPatchSyscall` fails (e.g. another plugin already hooked +//! the syscall table entry), we fall back to inline hooking: overwrite the +//! first two instructions of the target function with `j our_hook; nop` +//! and build a trampoline with the saved instructions for calling the +//! original. use crate::overlay; @@ -29,10 +33,16 @@ static mut ORIGINAL_SET_FRAME_BUF: Option< /// NID for sceDisplaySetFrameBuf. const NID_SCE_DISPLAY_SET_FRAME_BUF: u32 = 0x289D82FE; +/// Trampoline for calling the original function after inline hooking. +/// Layout: [saved_instr1, saved_instr2, j_original_plus_8, nop] +/// Must be 16-byte aligned for cache coherency. +#[repr(C, align(16))] +struct Trampoline([u32; 4]); + +static mut TRAMPOLINE: Trampoline = Trampoline([0; 4]); + // Reference the raw stub symbols generated by psp_extern! for -// SystemCtrlForKernel functions. They are #[unsafe(no_mangle)] so we -// can access them from here. The firmware replaces the first 4 bytes -// with a `j target` instruction for kernel imports. +// SystemCtrlForKernel functions. unsafe extern "C" { #[link_name = "__sctrlHENFindFunction_stub"] static FIND_FUNC_STUB: [u32; 2]; @@ -41,13 +51,15 @@ unsafe extern "C" { static PATCH_SYSCALL_STUB: [u32; 2]; } -/// Extract the absolute target address from a MIPS `j` instruction. -/// -/// The `j` instruction encodes a 26-bit word index. The full 32-bit -/// address is formed by combining the upper 4 bits of the PC with the -/// 28-bit target (index << 2). +/// Encode a MIPS `j target` instruction. /// -/// Returns `None` if the instruction is not a `j` (opcode != 2). +/// The `j` instruction uses the upper 4 bits of the current PC combined +/// with a 26-bit word index: `PC[31:28] | (target[27:2])`. +fn encode_j(target: u32) -> u32 { + 0x0800_0000 | ((target >> 2) & 0x03FF_FFFF) +} + +/// Extract the absolute target address from a MIPS `j` instruction. fn extract_j_target(instruction: u32, pc: u32) -> Option { if (instruction >> 26) != 2 { return None; @@ -58,23 +70,26 @@ fn extract_j_target(instruction: u32, pc: u32) -> Option { } /// Check if a stub word looks like a resolved import. -/// -/// User-mode: `jr $ra` (0x03E00008) -/// Kernel-mode: `j target` (opcode bits 31-26 == 2) fn is_stub_resolved(first_word: u32) -> bool { first_word == 0x03E00008 || (first_word >> 26) == 2 } +/// Resolve a kernel import stub to a direct function pointer. +unsafe fn resolve_kernel_stub(stub: &[u32; 2]) -> Option { + let first_word = unsafe { + core::ptr::read_volatile(&raw const *stub as *const u32) + }; + if !is_stub_resolved(first_word) { + return None; + } + let stub_addr = &raw const *stub as u32; + extract_j_target(first_word, stub_addr) +} + /// Our hook function that replaces `sceDisplaySetFrameBuf`. /// -/// Called in the game's display thread context every vsync. Must be fast: -/// - Call original to let the game's frame through -/// - Poll controller for trigger button -/// - If overlay active, blit the pre-rendered overlay buffer -/// /// # Safety -/// Called by the PSP OS as a syscall replacement. Arguments match -/// `sceDisplaySetFrameBuf` signature. +/// Called by the PSP OS as a syscall replacement. unsafe extern "C" fn hooked_set_frame_buf( top_addr: *const u8, buffer_width: usize, @@ -92,13 +107,11 @@ unsafe extern "C" fn hooked_set_frame_buf( }; // Only draw overlay on 32-bit ABGR framebuffers (pixel_format == 3) - // and valid framebuffer pointers if !top_addr.is_null() && pixel_format == 3 { let fb = top_addr as *mut u32; let stride = buffer_width as u32; // Debug beacon: 2x2 green dot at (1,1) confirms the hook is running. - // Remove once overlay is confirmed working. // SAFETY: Writing within screen bounds to valid framebuffer. unsafe { *fb.add((1 * stride + 1) as usize) = 0xFF00FF00; @@ -108,8 +121,6 @@ unsafe extern "C" fn hooked_set_frame_buf( } // SAFETY: fb is a valid framebuffer pointer provided by the OS. - // stride is the buffer width in pixels. We only write within - // screen bounds (480x272). unsafe { overlay::on_frame(fb, stride); } @@ -119,9 +130,6 @@ unsafe extern "C" fn hooked_set_frame_buf( } /// Module/library name pairs to try for finding sceDisplaySetFrameBuf. -/// -/// Different CFW versions and firmware versions expose the display driver -/// under different module names. We try them in order until one works. const DISPLAY_MODULE_NAMES: &[(&[u8], &[u8])] = &[ (b"sceDisplay_Service\0", b"sceDisplay\0"), (b"sceDisplay\0", b"sceDisplay\0"), @@ -129,24 +137,69 @@ const DISPLAY_MODULE_NAMES: &[(&[u8], &[u8])] = &[ (b"sceDisplay\0", b"sceDisplay_driver\0"), ]; -/// Resolve a kernel import stub to a direct function pointer. +/// Install a hook on `sceDisplaySetFrameBuf` using inline patching. /// -/// Reads the `j target` instruction from the stub, extracts the -/// absolute target address, and returns it. This bypasses the broken -/// delay slot in kernel stubs (firmware doesn't write nop there). +/// Overwrites the first two instructions of the target function with a +/// `j our_hook; nop` sequence, and builds a trampoline to call the +/// original function. /// -/// Returns `None` if the stub wasn't resolved by the firmware. -unsafe fn resolve_kernel_stub(stub: &[u32; 2]) -> Option { - let first_word = unsafe { - core::ptr::read_volatile(&raw const *stub as *const u32) - }; - if !is_stub_resolved(first_word) { - return None; +/// # Safety +/// `func_addr` must be a valid kernel function address. +unsafe fn install_inline_hook(func_addr: *mut u8) -> bool { + let func_ptr = func_addr as *mut u32; + + // SAFETY: Reading the first two instructions of the kernel function. + let instr1 = unsafe { core::ptr::read_volatile(func_ptr) }; + let instr2 = unsafe { core::ptr::read_volatile(func_ptr.add(1)) }; + + // Log original instructions for diagnostics. + { + let mut buf = [0u8; 48]; + let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] orig: "); + pos = write_log_hex(&mut buf, pos, instr1); + pos = write_log_bytes(&mut buf, pos, b" "); + pos = write_log_hex(&mut buf, pos, instr2); + crate::debug_log(&buf[..pos]); } - // For user-mode stubs (jr $ra; syscall), calling through the - // wrapper is safe. But we only get here for kernel stubs. - let stub_addr = &raw const *stub as u32; - extract_j_target(first_word, stub_addr) + + // Check if instr1 or instr2 are branch/jump instructions + // (PC-relative). These can't be safely relocated to the trampoline. + // Opcodes: beq=4, bne=5, blez=6, bgtz=7, j=2, jal=3 + // Also: regimm=1 (bltz, bgez, etc.) + let op1 = instr1 >> 26; + let op2 = instr2 >> 26; + let is_branch = |op: u32| matches!(op, 1 | 2 | 3 | 4 | 5 | 6 | 7); + + if is_branch(op1) || is_branch(op2) { + crate::debug_log(b"[OASIS] inline: branch in first 8 bytes, abort"); + return false; + } + + // SAFETY: Building the trampoline with saved instructions. + unsafe { + let orig_plus_8 = func_addr as u32 + 8; + TRAMPOLINE.0[0] = instr1; + TRAMPOLINE.0[1] = instr2; + TRAMPOLINE.0[2] = encode_j(orig_plus_8); + TRAMPOLINE.0[3] = 0x0000_0000; // nop (delay slot) + + // Patch the original function entry point. + let hook_addr = hooked_set_frame_buf as *const () as u32; + core::ptr::write_volatile(func_ptr, encode_j(hook_addr)); + core::ptr::write_volatile(func_ptr.add(1), 0x0000_0000); // nop + + // Flush data cache and invalidate instruction cache. + psp::sys::sceKernelDcacheWritebackAll(); + psp::sys::sceKernelIcacheInvalidateAll(); + + // Set original to point to trampoline. + ORIGINAL_SET_FRAME_BUF = Some(core::mem::transmute( + &raw const TRAMPOLINE.0[0], + )); + } + + crate::debug_log(b"[OASIS] inline hook installed OK"); + true } /// Install the `sceDisplaySetFrameBuf` hook. @@ -160,7 +213,6 @@ pub fn install_display_hook() -> bool { // Log raw stub bytes for diagnostics. { - // SAFETY: Reading stub bytes for diagnostics. let (w0, w1) = unsafe { let p = &raw const FIND_FUNC_STUB as *const u32; ( @@ -176,50 +228,41 @@ pub fn install_display_hook() -> bool { crate::debug_log(&buf[..pos]); } - // Resolve CFW function pointers by extracting j-targets from the - // patched stubs. This bypasses the broken delay slot. - // SAFETY: Reading stub data that was written by firmware at load time. + // Resolve CFW function pointers from patched kernel stubs. + // SAFETY: Reading stub data written by firmware at load time. let find_func_addr = unsafe { resolve_kernel_stub(&FIND_FUNC_STUB) }; let patch_syscall_addr = unsafe { resolve_kernel_stub(&PATCH_SYSCALL_STUB) }; - let (find_addr, patch_addr) = match (find_func_addr, patch_syscall_addr) { - (Some(f), Some(p)) => { - let mut buf = [0u8; 64]; + let find_addr = match find_func_addr { + Some(f) => { + let mut buf = [0u8; 48]; let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] FindFunc=0x"); pos = write_log_hex(&mut buf, pos, f); - pos = write_log_bytes(&mut buf, pos, b" Patch=0x"); - pos = write_log_hex(&mut buf, pos, p); crate::debug_log(&buf[..pos]); - (f, p) + f } - _ => { - crate::debug_log(b"[OASIS] hook: sctrl stubs NOT resolved!"); + None => { + crate::debug_log(b"[OASIS] hook: FindFunc stub NOT resolved!"); return false; } }; - // Cast to function pointer types matching the CFW API signatures. - // SAFETY: Addresses extracted from firmware-patched import stubs. + // Cast FindFunction to the correct type. let sctrl_find_function: unsafe extern "C" fn( *const u8, *const u8, u32, ) -> *mut u8 = unsafe { core::mem::transmute(find_addr) }; - let sctrl_patch_syscall: unsafe extern "C" fn( - *mut u8, *mut u8, - ) -> i32 = unsafe { core::mem::transmute(patch_addr) }; - - // Wait for CFW and game to fully initialize before calling CFW APIs. + // Wait for CFW and game to fully initialize. crate::debug_log(b"[OASIS] hook: waiting for system init..."); unsafe { - psp::sys::sceKernelDelayThread(2_000_000); // 2 seconds + psp::sys::sceKernelDelayThread(2_000_000); } crate::debug_log(b"[OASIS] hook: calling FindFunc..."); unsafe { psp::sys::sceKernelDcacheWritebackAll(); - // Try each module/library name combination until one returns a - // valid function pointer for sceDisplaySetFrameBuf. + // Find sceDisplaySetFrameBuf. let mut display_ptr: *mut u8 = core::ptr::null_mut(); for &(module, library) in DISPLAY_MODULE_NAMES { let ptr = sctrl_find_function( @@ -229,7 +272,6 @@ pub fn install_display_hook() -> bool { ); if !ptr.is_null() { display_ptr = ptr; - let mut buf = [0u8; 80]; let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] found at mod="); pos = write_log_cstr(&mut buf, pos, module); @@ -245,18 +287,40 @@ pub fn install_display_hook() -> bool { return false; } - crate::debug_log(b"[OASIS] hook: calling PatchSyscall..."); - let ret = sctrl_patch_syscall( - display_ptr, - hooked_set_frame_buf as *mut u8, - ); - if ret < 0 { - crate::debug_log(b"[OASIS] hook: PatchSyscall FAILED"); - return false; + // Try sctrlHENPatchSyscall first (preferred method). + let mut hooked = false; + if let Some(patch_addr) = patch_syscall_addr { + let sctrl_patch_syscall: unsafe extern "C" fn( + *mut u8, *mut u8, + ) -> i32 = core::mem::transmute(patch_addr); + + crate::debug_log(b"[OASIS] hook: trying PatchSyscall..."); + let ret = sctrl_patch_syscall( + display_ptr, + hooked_set_frame_buf as *mut u8, + ); + + if ret >= 0 { + ORIGINAL_SET_FRAME_BUF = + Some(core::mem::transmute(display_ptr)); + hooked = true; + crate::debug_log(b"[OASIS] hook: PatchSyscall OK"); + } else { + let mut buf = [0u8; 48]; + let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] PatchSyscall ret="); + pos = write_log_hex(&mut buf, pos, ret as u32); + crate::debug_log(&buf[..pos]); + } } - crate::debug_log(b"[OASIS] hook: PatchSyscall OK"); - ORIGINAL_SET_FRAME_BUF = Some(core::mem::transmute(display_ptr)); + // Fallback: inline hook (patch the function bytes directly). + if !hooked { + crate::debug_log(b"[OASIS] hook: falling back to inline hook"); + if !install_inline_hook(display_ptr) { + crate::debug_log(b"[OASIS] hook: inline hook FAILED"); + return false; + } + } psp::sys::sceKernelIcacheInvalidateAll(); psp::sys::sceKernelDcacheWritebackAll(); @@ -268,13 +332,8 @@ pub fn install_display_hook() -> bool { } /// Log diagnostic info about sctrlHENFindFunction results. -/// -/// Tries all known module/library name combinations and logs which ones -/// return a valid pointer vs null. Writes to the debug log file. pub fn log_find_function_result() { - // SAFETY: Resolving the kernel stub for direct calls. - let find_addr = unsafe { resolve_kernel_stub(&FIND_FUNC_STUB) }; - let Some(addr) = find_addr else { + let Some(addr) = (unsafe { resolve_kernel_stub(&FIND_FUNC_STUB) }) else { crate::debug_log(b"[OASIS] log: skipping (stubs not resolved)"); return; }; @@ -283,7 +342,6 @@ pub fn log_find_function_result() { *const u8, *const u8, u32, ) -> *mut u8 = unsafe { core::mem::transmute(addr) }; - // SAFETY: Calling the resolved CFW function from kernel mode. unsafe { for &(module, library) in DISPLAY_MODULE_NAMES { let ptr = sctrl_find_function( @@ -310,7 +368,6 @@ pub fn log_find_function_result() { } } -/// Write bytes into a log buffer. Returns new position. fn write_log_bytes(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { let mut p = pos; for &b in s { @@ -323,7 +380,6 @@ fn write_log_bytes(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { p } -/// Write a null-terminated C string (without the null) into a log buffer. fn write_log_cstr(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { let mut p = pos; for &b in s { @@ -336,7 +392,6 @@ fn write_log_cstr(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { p } -/// Write a u32 as hexadecimal into a log buffer. fn write_log_hex(buf: &mut [u8], pos: usize, val: u32) -> usize { let mut p = pos; let hex = b"0123456789ABCDEF"; From 9128679299447e7ca28dd22764c0e24d6aa4e104 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 15:49:31 -0600 Subject: [PATCH 19/26] debug: button-state indicator for overlay input diagnosis 2x2 pixel at (5,5): green=idle, red=any button, white=L+R+START. Helps diagnose whether sceCtrlPeekBufferPositive reads input from the display hook context. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/overlay.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/oasis-plugin-psp/src/overlay.rs b/crates/oasis-plugin-psp/src/overlay.rs index 4241e89..3ccfaca 100644 --- a/crates/oasis-plugin-psp/src/overlay.rs +++ b/crates/oasis-plugin-psp/src/overlay.rs @@ -94,6 +94,23 @@ pub unsafe fn on_frame(fb: *mut u32, stride: u32) { PREV_BUTTONS = buttons; } + // Debug: 2x2 button-state indicator at (5,5). + // Green = idle, Red = L held, Blue = R held, White = L+R+START. + let combo = BTN_L_TRIGGER | BTN_R_TRIGGER | BTN_START; + let dbg_color = if (buttons & combo) == combo { + 0xFFFFFFFF // white -- full combo + } else if buttons != 0 { + 0xFF0000FF // red -- some button pressed + } else { + 0xFF00FF00 // green -- idle + }; + unsafe { + *fb.add((5 * stride + 5) as usize) = dbg_color; + *fb.add((5 * stride + 6) as usize) = dbg_color; + *fb.add((6 * stride + 5) as usize) = dbg_color; + *fb.add((6 * stride + 6) as usize) = dbg_color; + } + let trigger = config::get_config().trigger_mask(); let state = OverlayState::from_u8(STATE.load(Ordering::Relaxed)); From 133acd20d15d23c8b72dd34e21d47220c1adfc77 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 15:55:32 -0600 Subject: [PATCH 20/26] fix: use kernel-mode sceCtrl_driver for button polling in overlay The user-mode sceCtrlPeekBufferPositive import doesn't return data from the display hook context (kernel display driver thread). Resolve sceCtrlPeekBufferPositive from sceCtrl_driver via sctrlHENFindFunction and call it directly. This kernel-mode variant reads the shared controller buffer regardless of calling context. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 51 ++++++++++++++++++++++++++ crates/oasis-plugin-psp/src/overlay.rs | 11 ++---- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 0cca457..ba4c827 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -33,6 +33,32 @@ static mut ORIGINAL_SET_FRAME_BUF: Option< /// NID for sceDisplaySetFrameBuf. const NID_SCE_DISPLAY_SET_FRAME_BUF: u32 = 0x289D82FE; +/// NID for sceCtrlPeekBufferPositive. +const NID_SCE_CTRL_PEEK_BUF_POS: u32 = 0x3A622550; + +/// Resolved kernel-mode sceCtrlPeekBufferPositive function pointer. +/// The user-mode import doesn't work from the display hook context, +/// so we resolve the driver version via sctrlHENFindFunction. +static mut CTRL_PEEK_FN: Option i32> = None; + +/// Poll controller buttons using the kernel-mode driver function. +/// +/// Returns the raw button bitmask, or 0 if unavailable. +pub fn poll_buttons() -> u32 { + // SAFETY: CTRL_PEEK_FN is set once during init and read-only after. + // SceCtrlData layout: [timestamp: u32, buttons: u32, lx: u8, ly: u8, rsrv: [u8; 6]] + unsafe { + if let Some(peek) = CTRL_PEEK_FN { + let mut buf = [0u8; 16]; + peek(buf.as_mut_ptr(), 1); + // buttons is at offset 4, little-endian u32 + u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) + } else { + 0 + } + } +} + /// Trampoline for calling the original function after inline hooking. /// Layout: [saved_instr1, saved_instr2, j_original_plus_8, nop] /// Must be 16-byte aligned for cache coherency. @@ -325,6 +351,31 @@ pub fn install_display_hook() -> bool { psp::sys::sceKernelIcacheInvalidateAll(); psp::sys::sceKernelDcacheWritebackAll(); + // Resolve sceCtrlPeekBufferPositive from the kernel driver. + // The user-mode import doesn't work from the display hook context. + let ctrl_names: &[(&[u8], &[u8])] = &[ + (b"sceController_Service\0", b"sceCtrl_driver\0"), + (b"sceController_Service\0", b"sceCtrl\0"), + ]; + for &(module, library) in ctrl_names { + let ptr = sctrl_find_function( + module.as_ptr(), + library.as_ptr(), + NID_SCE_CTRL_PEEK_BUF_POS, + ); + if !ptr.is_null() { + CTRL_PEEK_FN = Some(core::mem::transmute(ptr)); + let mut buf = [0u8; 48]; + let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] ctrl=0x"); + pos = write_log_hex(&mut buf, pos, ptr as u32); + crate::debug_log(&buf[..pos]); + break; + } + } + if core::ptr::read_volatile(&raw const CTRL_PEEK_FN).is_none() { + crate::debug_log(b"[OASIS] ctrl driver NOT found"); + } + HOOK_INSTALLED.store(true, Ordering::Release); } diff --git a/crates/oasis-plugin-psp/src/overlay.rs b/crates/oasis-plugin-psp/src/overlay.rs index 3ccfaca..e4ae480 100644 --- a/crates/oasis-plugin-psp/src/overlay.rs +++ b/crates/oasis-plugin-psp/src/overlay.rs @@ -79,14 +79,9 @@ const BTN_START: u32 = 0x8; /// `fb` must be a valid 32-bit ABGR framebuffer pointer with at least /// `stride * 272` pixels. Called from the display thread context. pub unsafe fn on_frame(fb: *mut u32, stride: u32) { - // Poll controller (non-blocking) - // SAFETY: SceCtrlData is repr(C), zeroed is valid. - let mut pad = unsafe { core::mem::zeroed::() }; - unsafe { - psp::sys::sceCtrlPeekBufferPositive(&mut pad, 1); - } - - let buttons = pad.buttons.bits(); + // Poll controller via kernel-mode driver (user-mode API doesn't work + // from the display hook context). + let buttons = crate::hook::poll_buttons(); // SAFETY: Single-threaded access from display hook context. let prev = unsafe { PREV_BUTTONS }; let pressed = buttons & !prev; // Rising edge From 46e34b84349f3901e3babf56b0538cb3922ad621 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 16:01:40 -0600 Subject: [PATCH 21/26] fix: init kernel controller sampling + volatile reads for button poll Initialize sceCtrlSetSamplingCycle(0) and sceCtrlSetSamplingMode(1) via the kernel driver after resolving sceCtrlPeekBufferPositive. The game's user-mode controller init may not apply to kernel-mode reads. Also use read_volatile on the buffer to prevent the compiler from optimizing away the read. Added one-shot diagnostic log showing the first poll result (return value, timestamp, button state). Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 56 +++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index ba4c827..6a82882 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -41,18 +41,37 @@ const NID_SCE_CTRL_PEEK_BUF_POS: u32 = 0x3A622550; /// so we resolve the driver version via sctrlHENFindFunction. static mut CTRL_PEEK_FN: Option i32> = None; +/// One-shot flag: log the first non-zero controller read for diagnostics. +static mut CTRL_LOGGED: bool = false; + /// Poll controller buttons using the kernel-mode driver function. /// /// Returns the raw button bitmask, or 0 if unavailable. pub fn poll_buttons() -> u32 { // SAFETY: CTRL_PEEK_FN is set once during init and read-only after. - // SceCtrlData layout: [timestamp: u32, buttons: u32, lx: u8, ly: u8, rsrv: [u8; 6]] + // SceCtrlData layout: [u32 timestamp, u32 buttons, u8 lx, u8 ly, u8[6] rsrv] unsafe { - if let Some(peek) = CTRL_PEEK_FN { - let mut buf = [0u8; 16]; - peek(buf.as_mut_ptr(), 1); - // buttons is at offset 4, little-endian u32 - u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) + let peek = core::ptr::read_volatile(&raw const CTRL_PEEK_FN); + if let Some(peek) = peek { + let mut data = [0u32; 4]; // 16 bytes as u32 array + let ret = peek(data.as_mut_ptr() as *mut u8, 1); + let timestamp = core::ptr::read_volatile(&raw const data[0]); + let buttons = core::ptr::read_volatile(&raw const data[1]); + + // One-time diagnostic: log first poll result. + if !core::ptr::read_volatile(&raw const CTRL_LOGGED) { + core::ptr::write_volatile(&raw mut CTRL_LOGGED, true); + let mut buf = [0u8; 64]; + let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] ctrl ret="); + pos = write_log_hex(&mut buf, pos, ret as u32); + pos = write_log_bytes(&mut buf, pos, b" ts="); + pos = write_log_hex(&mut buf, pos, timestamp); + pos = write_log_bytes(&mut buf, pos, b" btn="); + pos = write_log_hex(&mut buf, pos, buttons); + crate::debug_log(&buf[..pos]); + } + + buttons } else { 0 } @@ -374,6 +393,31 @@ pub fn install_display_hook() -> bool { } if core::ptr::read_volatile(&raw const CTRL_PEEK_FN).is_none() { crate::debug_log(b"[OASIS] ctrl driver NOT found"); + } else { + // Initialize controller sampling via kernel driver. + // The game's user-mode init may not apply to kernel-mode reads. + let set_cycle_ptr = sctrl_find_function( + b"sceController_Service\0".as_ptr(), + b"sceCtrl_driver\0".as_ptr(), + 0x6A2774F3, // sceCtrlSetSamplingCycle + ); + if !set_cycle_ptr.is_null() { + let set_cycle: unsafe extern "C" fn(i32) -> i32 = + core::mem::transmute(set_cycle_ptr); + set_cycle(0); // 0 = VBlank sampling + } + + let set_mode_ptr = sctrl_find_function( + b"sceController_Service\0".as_ptr(), + b"sceCtrl_driver\0".as_ptr(), + 0x1F4011E6, // sceCtrlSetSamplingMode + ); + if !set_mode_ptr.is_null() { + let set_mode: unsafe extern "C" fn(i32) -> i32 = + core::mem::transmute(set_mode_ptr); + set_mode(1); // 1 = analog mode + crate::debug_log(b"[OASIS] ctrl sampling initialized"); + } } HOOK_INSTALLED.store(true, Ordering::Release); From c688cd9728c03c3a1d44049d1ec21377fc80fff7 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 16:20:15 -0600 Subject: [PATCH 22/26] fix: threaded controller polling and flicker-free overlay rendering Move controller polling from display hook context (where syscalls don't work) to a dedicated kernel thread that reads sceCtrl at ~60Hz via AtomicU32. Draw overlay onto uncached framebuffer (addr | 0x40000000) before calling original sceDisplaySetFrameBuf to eliminate horizontal striping from stale cache lines and flickering from mid-scanout writes. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 117 ++++++++++++++++--------- crates/oasis-plugin-psp/src/overlay.rs | 10 +-- 2 files changed, 81 insertions(+), 46 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 6a82882..bb95d84 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -37,43 +37,74 @@ const NID_SCE_DISPLAY_SET_FRAME_BUF: u32 = 0x289D82FE; const NID_SCE_CTRL_PEEK_BUF_POS: u32 = 0x3A622550; /// Resolved kernel-mode sceCtrlPeekBufferPositive function pointer. -/// The user-mode import doesn't work from the display hook context, -/// so we resolve the driver version via sctrlHENFindFunction. static mut CTRL_PEEK_FN: Option i32> = None; -/// One-shot flag: log the first non-zero controller read for diagnostics. -static mut CTRL_LOGGED: bool = false; +/// Current button state, updated by the controller polling thread. +/// The display hook reads this atomically -- no API calls needed. +static CURRENT_BUTTONS: core::sync::atomic::AtomicU32 = + core::sync::atomic::AtomicU32::new(0); -/// Poll controller buttons using the kernel-mode driver function. -/// -/// Returns the raw button bitmask, or 0 if unavailable. +/// Poll controller buttons. Reads the value set by the ctrl thread. pub fn poll_buttons() -> u32 { - // SAFETY: CTRL_PEEK_FN is set once during init and read-only after. - // SceCtrlData layout: [u32 timestamp, u32 buttons, u8 lx, u8 ly, u8[6] rsrv] - unsafe { - let peek = core::ptr::read_volatile(&raw const CTRL_PEEK_FN); + CURRENT_BUTTONS.load(Ordering::Relaxed) +} + +/// Controller polling thread entry point. +/// +/// Runs in a normal kernel thread context where all APIs work. +/// Polls sceCtrlPeekBufferPositive at ~60Hz and stores the result +/// in CURRENT_BUTTONS for the display hook to read. +unsafe extern "C" fn ctrl_thread_entry( + _args: usize, + _argp: *mut core::ffi::c_void, +) -> i32 { + // Brief delay to let the game fully start. + unsafe { psp::sys::sceKernelDelayThread(500_000) }; + + let mut logged = false; + + loop { + // SAFETY: CTRL_PEEK_FN is set once before this thread starts. + let peek = unsafe { core::ptr::read_volatile(&raw const CTRL_PEEK_FN) }; if let Some(peek) = peek { - let mut data = [0u32; 4]; // 16 bytes as u32 array - let ret = peek(data.as_mut_ptr() as *mut u8, 1); - let timestamp = core::ptr::read_volatile(&raw const data[0]); - let buttons = core::ptr::read_volatile(&raw const data[1]); - - // One-time diagnostic: log first poll result. - if !core::ptr::read_volatile(&raw const CTRL_LOGGED) { - core::ptr::write_volatile(&raw mut CTRL_LOGGED, true); + let mut data = [0u32; 4]; // SceCtrlData = 16 bytes + unsafe { peek(data.as_mut_ptr() as *mut u8, 1) }; + let buttons = unsafe { core::ptr::read_volatile(&raw const data[1]) }; + CURRENT_BUTTONS.store(buttons, Ordering::Relaxed); + + // One-time diagnostic (file I/O works from thread context). + if !logged { + logged = true; + let ts = unsafe { core::ptr::read_volatile(&raw const data[0]) }; let mut buf = [0u8; 64]; - let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] ctrl ret="); - pos = write_log_hex(&mut buf, pos, ret as u32); - pos = write_log_bytes(&mut buf, pos, b" ts="); - pos = write_log_hex(&mut buf, pos, timestamp); + let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] ctrl ts="); + pos = write_log_hex(&mut buf, pos, ts); pos = write_log_bytes(&mut buf, pos, b" btn="); pos = write_log_hex(&mut buf, pos, buttons); crate::debug_log(&buf[..pos]); } + } + unsafe { psp::sys::sceKernelDelayThread(16_000) }; // ~60fps + } +} - buttons +/// Start the controller polling thread. +unsafe fn start_ctrl_thread() { + // SAFETY: Creating a kernel thread for controller polling. + unsafe { + let thid = psp::sys::sceKernelCreateThread( + b"OasisCtrl\0".as_ptr(), + ctrl_thread_entry, + 0x18, // priority + 0x1000, // 4KB stack + psp::sys::ThreadAttributes::empty(), // kernel thread + core::ptr::null_mut(), + ); + if thid.0 >= 0 { + psp::sys::sceKernelStartThread(thid, 0, core::ptr::null_mut()); + crate::debug_log(b"[OASIS] ctrl thread started"); } else { - 0 + crate::debug_log(b"[OASIS] ctrl thread FAILED"); } } } @@ -141,19 +172,13 @@ unsafe extern "C" fn hooked_set_frame_buf( pixel_format: u32, sync: u32, ) -> u32 { - // Call original first so the game's frame is displayed - // SAFETY: ORIGINAL_SET_FRAME_BUF is set before the hook is active. - let result = unsafe { - if let Some(original) = ORIGINAL_SET_FRAME_BUF { - original(top_addr, buffer_width, pixel_format, sync) - } else { - 0 - } - }; - - // Only draw overlay on 32-bit ABGR framebuffers (pixel_format == 3) + // Draw overlay BEFORE calling original so the buffer is fully + // composited when the display hardware starts scanning it out. + // Use uncached pointer (| 0x40000000) so writes go directly to + // physical memory, bypassing the data cache. This eliminates + // horizontal striping from stale cache lines. if !top_addr.is_null() && pixel_format == 3 { - let fb = top_addr as *mut u32; + let fb = (top_addr as u32 | 0x4000_0000) as *mut u32; let stride = buffer_width as u32; // Debug beacon: 2x2 green dot at (1,1) confirms the hook is running. @@ -165,13 +190,21 @@ unsafe extern "C" fn hooked_set_frame_buf( *fb.add((2 * stride + 2) as usize) = 0xFF00FF00; } - // SAFETY: fb is a valid framebuffer pointer provided by the OS. + // SAFETY: fb is a valid uncached framebuffer pointer. unsafe { overlay::on_frame(fb, stride); } } - result + // Call original to submit the buffer to the display hardware. + // SAFETY: ORIGINAL_SET_FRAME_BUF is set before the hook is active. + unsafe { + if let Some(original) = ORIGINAL_SET_FRAME_BUF { + original(top_addr, buffer_width, pixel_format, sync) + } else { + 0 + } + } } /// Module/library name pairs to try for finding sceDisplaySetFrameBuf. @@ -418,11 +451,17 @@ pub fn install_display_hook() -> bool { set_mode(1); // 1 = analog mode crate::debug_log(b"[OASIS] ctrl sampling initialized"); } + + // Start the controller polling thread. Runs in normal kernel + // context where sceCtrl APIs actually work (unlike the display + // hook context which can't make syscalls). + start_ctrl_thread(); } HOOK_INSTALLED.store(true, Ordering::Release); } + crate::debug_log(b"[OASIS] hook installed OK"); true } diff --git a/crates/oasis-plugin-psp/src/overlay.rs b/crates/oasis-plugin-psp/src/overlay.rs index e4ae480..112d795 100644 --- a/crates/oasis-plugin-psp/src/overlay.rs +++ b/crates/oasis-plugin-psp/src/overlay.rs @@ -155,13 +155,9 @@ pub unsafe fn on_frame(fb: *mut u32, stride: u32) { } } - // Flush dcache for the overlay region - if state != OverlayState::Hidden { - // SAFETY: Valid framebuffer region. - unsafe { - render::flush_framebuffer(fb, stride, OVERLAY_Y, OVERLAY_H); - } - } + // No dcache flush needed -- the hook passes an uncached framebuffer + // pointer (addr | 0x40000000), so all writes go directly to physical + // memory and are immediately visible to the display hardware. } impl OverlayState { From d41a72b26c91a1781f2aa94353e727059f7ba8fa Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 16:33:06 -0600 Subject: [PATCH 23/26] refactor: simplify plugin hook.rs using SDK's SyscallHook and find_function Replace ~300 lines of manual kernel stub resolution, MIPS instruction encoding, inline hooking, and PatchSyscall workarounds with calls to psp::hook::SyscallHook::install() and psp::hook::find_function() which now handle all of this internally. Removed: resolve_kernel_stub, extract_j_target, encode_j, is_stub_resolved, install_inline_hook, Trampoline, FIND_FUNC_STUB, PATCH_SYSCALL_STUB, ORIGINAL_SET_FRAME_BUF, log_find_function_result, write_log_cstr. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/Cargo.lock | 2 +- crates/oasis-plugin-psp/src/hook.rs | 362 ++++------------------------ crates/oasis-plugin-psp/src/main.rs | 3 - 3 files changed, 48 insertions(+), 319 deletions(-) diff --git a/crates/oasis-plugin-psp/Cargo.lock b/crates/oasis-plugin-psp/Cargo.lock index fbb9719..403355c 100644 --- a/crates/oasis-plugin-psp/Cargo.lock +++ b/crates/oasis-plugin-psp/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "psp" version = "0.4.0" -source = "git+https://github.com/AndrewAltimit/rust-psp?branch=fix%2Fosk-dialog-gu-frame#7030322b95a269a017908a73e5c1e2ee15ead4cc" +source = "git+https://github.com/AndrewAltimit/rust-psp?branch=fix%2Fosk-dialog-gu-frame#598e8c1dc9797e4596061551f7611b303c1eed6c" dependencies = [ "bitflags", "libm", diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index bb95d84..02e0c75 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -1,22 +1,9 @@ //! Display framebuffer hook via CFW syscall patching. //! //! Intercepts `sceDisplaySetFrameBuf` to draw the overlay on top of the -//! game's framebuffer after each frame. The hook calls the original function -//! first (so the game renders normally), then draws overlay elements. -//! -//! # Kernel stub workaround -//! -//! The PSP firmware patches kernel import stubs with `j target` (direct -//! jump) but does NOT write a `nop` to the delay slot. We extract the -//! jump target and call the function directly via raw pointer. -//! -//! # Inline hook fallback -//! -//! If `sctrlHENPatchSyscall` fails (e.g. another plugin already hooked -//! the syscall table entry), we fall back to inline hooking: overwrite the -//! first two instructions of the target function with `j our_hook; nop` -//! and build a trampoline with the saved instructions for calling the -//! original. +//! game's framebuffer after each frame. Uses `psp::hook::SyscallHook` from +//! the SDK which handles kernel stub quirks, syscall patching, and inline +//! hook fallback automatically. use crate::overlay; @@ -25,10 +12,8 @@ use core::sync::atomic::{AtomicBool, Ordering}; /// Whether the hook is currently installed. static HOOK_INSTALLED: AtomicBool = AtomicBool::new(false); -/// Original `sceDisplaySetFrameBuf` function pointer. -static mut ORIGINAL_SET_FRAME_BUF: Option< - unsafe extern "C" fn(*const u8, usize, u32, u32) -> u32, -> = None; +/// The display hook handle (owns the trampoline for inline hooks). +static mut DISPLAY_HOOK: Option = None; /// NID for sceDisplaySetFrameBuf. const NID_SCE_DISPLAY_SET_FRAME_BUF: u32 = 0x289D82FE; @@ -109,59 +94,6 @@ unsafe fn start_ctrl_thread() { } } -/// Trampoline for calling the original function after inline hooking. -/// Layout: [saved_instr1, saved_instr2, j_original_plus_8, nop] -/// Must be 16-byte aligned for cache coherency. -#[repr(C, align(16))] -struct Trampoline([u32; 4]); - -static mut TRAMPOLINE: Trampoline = Trampoline([0; 4]); - -// Reference the raw stub symbols generated by psp_extern! for -// SystemCtrlForKernel functions. -unsafe extern "C" { - #[link_name = "__sctrlHENFindFunction_stub"] - static FIND_FUNC_STUB: [u32; 2]; - - #[link_name = "__sctrlHENPatchSyscall_stub"] - static PATCH_SYSCALL_STUB: [u32; 2]; -} - -/// Encode a MIPS `j target` instruction. -/// -/// The `j` instruction uses the upper 4 bits of the current PC combined -/// with a 26-bit word index: `PC[31:28] | (target[27:2])`. -fn encode_j(target: u32) -> u32 { - 0x0800_0000 | ((target >> 2) & 0x03FF_FFFF) -} - -/// Extract the absolute target address from a MIPS `j` instruction. -fn extract_j_target(instruction: u32, pc: u32) -> Option { - if (instruction >> 26) != 2 { - return None; - } - let offset = (instruction & 0x03FF_FFFF) << 2; - let region = pc & 0xF000_0000; - Some(region | offset) -} - -/// Check if a stub word looks like a resolved import. -fn is_stub_resolved(first_word: u32) -> bool { - first_word == 0x03E00008 || (first_word >> 26) == 2 -} - -/// Resolve a kernel import stub to a direct function pointer. -unsafe fn resolve_kernel_stub(stub: &[u32; 2]) -> Option { - let first_word = unsafe { - core::ptr::read_volatile(&raw const *stub as *const u32) - }; - if !is_stub_resolved(first_word) { - return None; - } - let stub_addr = &raw const *stub as u32; - extract_j_target(first_word, stub_addr) -} - /// Our hook function that replaces `sceDisplaySetFrameBuf`. /// /// # Safety @@ -197,9 +129,11 @@ unsafe extern "C" fn hooked_set_frame_buf( } // Call original to submit the buffer to the display hardware. - // SAFETY: ORIGINAL_SET_FRAME_BUF is set before the hook is active. + // SAFETY: DISPLAY_HOOK is set before the hook is active. unsafe { - if let Some(original) = ORIGINAL_SET_FRAME_BUF { + if let Some(ref hook) = DISPLAY_HOOK { + let original: unsafe extern "C" fn(*const u8, usize, u32, u32) -> u32 = + core::mem::transmute(hook.original_ptr()); original(top_addr, buffer_width, pixel_format, sync) } else { 0 @@ -215,71 +149,6 @@ const DISPLAY_MODULE_NAMES: &[(&[u8], &[u8])] = &[ (b"sceDisplay\0", b"sceDisplay_driver\0"), ]; -/// Install a hook on `sceDisplaySetFrameBuf` using inline patching. -/// -/// Overwrites the first two instructions of the target function with a -/// `j our_hook; nop` sequence, and builds a trampoline to call the -/// original function. -/// -/// # Safety -/// `func_addr` must be a valid kernel function address. -unsafe fn install_inline_hook(func_addr: *mut u8) -> bool { - let func_ptr = func_addr as *mut u32; - - // SAFETY: Reading the first two instructions of the kernel function. - let instr1 = unsafe { core::ptr::read_volatile(func_ptr) }; - let instr2 = unsafe { core::ptr::read_volatile(func_ptr.add(1)) }; - - // Log original instructions for diagnostics. - { - let mut buf = [0u8; 48]; - let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] orig: "); - pos = write_log_hex(&mut buf, pos, instr1); - pos = write_log_bytes(&mut buf, pos, b" "); - pos = write_log_hex(&mut buf, pos, instr2); - crate::debug_log(&buf[..pos]); - } - - // Check if instr1 or instr2 are branch/jump instructions - // (PC-relative). These can't be safely relocated to the trampoline. - // Opcodes: beq=4, bne=5, blez=6, bgtz=7, j=2, jal=3 - // Also: regimm=1 (bltz, bgez, etc.) - let op1 = instr1 >> 26; - let op2 = instr2 >> 26; - let is_branch = |op: u32| matches!(op, 1 | 2 | 3 | 4 | 5 | 6 | 7); - - if is_branch(op1) || is_branch(op2) { - crate::debug_log(b"[OASIS] inline: branch in first 8 bytes, abort"); - return false; - } - - // SAFETY: Building the trampoline with saved instructions. - unsafe { - let orig_plus_8 = func_addr as u32 + 8; - TRAMPOLINE.0[0] = instr1; - TRAMPOLINE.0[1] = instr2; - TRAMPOLINE.0[2] = encode_j(orig_plus_8); - TRAMPOLINE.0[3] = 0x0000_0000; // nop (delay slot) - - // Patch the original function entry point. - let hook_addr = hooked_set_frame_buf as *const () as u32; - core::ptr::write_volatile(func_ptr, encode_j(hook_addr)); - core::ptr::write_volatile(func_ptr.add(1), 0x0000_0000); // nop - - // Flush data cache and invalidate instruction cache. - psp::sys::sceKernelDcacheWritebackAll(); - psp::sys::sceKernelIcacheInvalidateAll(); - - // Set original to point to trampoline. - ORIGINAL_SET_FRAME_BUF = Some(core::mem::transmute( - &raw const TRAMPOLINE.0[0], - )); - } - - crate::debug_log(b"[OASIS] inline hook installed OK"); - true -} - /// Install the `sceDisplaySetFrameBuf` hook. /// /// Returns `true` on success. Must be called from kernel mode during plugin @@ -289,219 +158,94 @@ pub fn install_display_hook() -> bool { return true; } - // Log raw stub bytes for diagnostics. - { - let (w0, w1) = unsafe { - let p = &raw const FIND_FUNC_STUB as *const u32; - ( - core::ptr::read_volatile(p), - core::ptr::read_volatile(p.add(1)), - ) - }; - let mut buf = [0u8; 64]; - let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] stub: "); - pos = write_log_hex(&mut buf, pos, w0); - pos = write_log_bytes(&mut buf, pos, b" "); - pos = write_log_hex(&mut buf, pos, w1); - crate::debug_log(&buf[..pos]); - } - - // Resolve CFW function pointers from patched kernel stubs. - // SAFETY: Reading stub data written by firmware at load time. - let find_func_addr = unsafe { resolve_kernel_stub(&FIND_FUNC_STUB) }; - let patch_syscall_addr = unsafe { resolve_kernel_stub(&PATCH_SYSCALL_STUB) }; - - let find_addr = match find_func_addr { - Some(f) => { - let mut buf = [0u8; 48]; - let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] FindFunc=0x"); - pos = write_log_hex(&mut buf, pos, f); - crate::debug_log(&buf[..pos]); - f - } - None => { - crate::debug_log(b"[OASIS] hook: FindFunc stub NOT resolved!"); - return false; - } - }; - - // Cast FindFunction to the correct type. - let sctrl_find_function: unsafe extern "C" fn( - *const u8, *const u8, u32, - ) -> *mut u8 = unsafe { core::mem::transmute(find_addr) }; - // Wait for CFW and game to fully initialize. crate::debug_log(b"[OASIS] hook: waiting for system init..."); unsafe { psp::sys::sceKernelDelayThread(2_000_000); } - crate::debug_log(b"[OASIS] hook: calling FindFunc..."); - - unsafe { - psp::sys::sceKernelDcacheWritebackAll(); - // Find sceDisplaySetFrameBuf. - let mut display_ptr: *mut u8 = core::ptr::null_mut(); + // Try each module/library pair until we find sceDisplaySetFrameBuf. + let hook = unsafe { + let mut result = None; for &(module, library) in DISPLAY_MODULE_NAMES { - let ptr = sctrl_find_function( + result = psp::hook::SyscallHook::install( module.as_ptr(), library.as_ptr(), NID_SCE_DISPLAY_SET_FRAME_BUF, - ); - if !ptr.is_null() { - display_ptr = ptr; - let mut buf = [0u8; 80]; - let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] found at mod="); - pos = write_log_cstr(&mut buf, pos, module); - pos = write_log_bytes(&mut buf, pos, b" -> 0x"); - pos = write_log_hex(&mut buf, pos, ptr as u32); - crate::debug_log(&buf[..pos]); - break; - } - } - - if display_ptr.is_null() { - crate::debug_log(b"[OASIS] hook: FindFunc returned NULL for all"); - return false; - } - - // Try sctrlHENPatchSyscall first (preferred method). - let mut hooked = false; - if let Some(patch_addr) = patch_syscall_addr { - let sctrl_patch_syscall: unsafe extern "C" fn( - *mut u8, *mut u8, - ) -> i32 = core::mem::transmute(patch_addr); - - crate::debug_log(b"[OASIS] hook: trying PatchSyscall..."); - let ret = sctrl_patch_syscall( - display_ptr, hooked_set_frame_buf as *mut u8, ); - - if ret >= 0 { - ORIGINAL_SET_FRAME_BUF = - Some(core::mem::transmute(display_ptr)); - hooked = true; - crate::debug_log(b"[OASIS] hook: PatchSyscall OK"); - } else { - let mut buf = [0u8; 48]; - let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] PatchSyscall ret="); - pos = write_log_hex(&mut buf, pos, ret as u32); - crate::debug_log(&buf[..pos]); + if result.is_some() { + crate::debug_log(b"[OASIS] display hook installed"); + break; } } + result + }; - // Fallback: inline hook (patch the function bytes directly). - if !hooked { - crate::debug_log(b"[OASIS] hook: falling back to inline hook"); - if !install_inline_hook(display_ptr) { - crate::debug_log(b"[OASIS] hook: inline hook FAILED"); - return false; - } - } + let Some(hook) = hook else { + crate::debug_log(b"[OASIS] hook: all module/library pairs failed"); + return false; + }; - psp::sys::sceKernelIcacheInvalidateAll(); - psp::sys::sceKernelDcacheWritebackAll(); + // SAFETY: Single-threaded init, DISPLAY_HOOK is read-only after this. + unsafe { + DISPLAY_HOOK = Some(hook); + } - // Resolve sceCtrlPeekBufferPositive from the kernel driver. - // The user-mode import doesn't work from the display hook context. - let ctrl_names: &[(&[u8], &[u8])] = &[ - (b"sceController_Service\0", b"sceCtrl_driver\0"), - (b"sceController_Service\0", b"sceCtrl\0"), - ]; + // Resolve sceCtrlPeekBufferPositive from the kernel driver. + // The user-mode import doesn't work from the display hook context. + let ctrl_names: &[(&[u8], &[u8])] = &[ + (b"sceController_Service\0", b"sceCtrl_driver\0"), + (b"sceController_Service\0", b"sceCtrl\0"), + ]; + unsafe { for &(module, library) in ctrl_names { - let ptr = sctrl_find_function( + if let Some(ptr) = psp::hook::find_function( module.as_ptr(), library.as_ptr(), NID_SCE_CTRL_PEEK_BUF_POS, - ); - if !ptr.is_null() { + ) { CTRL_PEEK_FN = Some(core::mem::transmute(ptr)); - let mut buf = [0u8; 48]; - let mut pos = write_log_bytes(&mut buf, 0, b"[OASIS] ctrl=0x"); - pos = write_log_hex(&mut buf, pos, ptr as u32); - crate::debug_log(&buf[..pos]); + crate::debug_log(b"[OASIS] ctrl driver resolved"); break; } } + if core::ptr::read_volatile(&raw const CTRL_PEEK_FN).is_none() { crate::debug_log(b"[OASIS] ctrl driver NOT found"); } else { // Initialize controller sampling via kernel driver. - // The game's user-mode init may not apply to kernel-mode reads. - let set_cycle_ptr = sctrl_find_function( + let set_cycle = psp::hook::find_function( b"sceController_Service\0".as_ptr(), b"sceCtrl_driver\0".as_ptr(), 0x6A2774F3, // sceCtrlSetSamplingCycle ); - if !set_cycle_ptr.is_null() { - let set_cycle: unsafe extern "C" fn(i32) -> i32 = - core::mem::transmute(set_cycle_ptr); - set_cycle(0); // 0 = VBlank sampling + if let Some(ptr) = set_cycle { + let f: unsafe extern "C" fn(i32) -> i32 = core::mem::transmute(ptr); + f(0); // 0 = VBlank sampling } - let set_mode_ptr = sctrl_find_function( + let set_mode = psp::hook::find_function( b"sceController_Service\0".as_ptr(), b"sceCtrl_driver\0".as_ptr(), 0x1F4011E6, // sceCtrlSetSamplingMode ); - if !set_mode_ptr.is_null() { - let set_mode: unsafe extern "C" fn(i32) -> i32 = - core::mem::transmute(set_mode_ptr); - set_mode(1); // 1 = analog mode + if let Some(ptr) = set_mode { + let f: unsafe extern "C" fn(i32) -> i32 = core::mem::transmute(ptr); + f(1); // 1 = analog mode crate::debug_log(b"[OASIS] ctrl sampling initialized"); } - // Start the controller polling thread. Runs in normal kernel - // context where sceCtrl APIs actually work (unlike the display - // hook context which can't make syscalls). + // Start the controller polling thread. start_ctrl_thread(); } - - HOOK_INSTALLED.store(true, Ordering::Release); } + HOOK_INSTALLED.store(true, Ordering::Release); crate::debug_log(b"[OASIS] hook installed OK"); true } -/// Log diagnostic info about sctrlHENFindFunction results. -pub fn log_find_function_result() { - let Some(addr) = (unsafe { resolve_kernel_stub(&FIND_FUNC_STUB) }) else { - crate::debug_log(b"[OASIS] log: skipping (stubs not resolved)"); - return; - }; - - let sctrl_find_function: unsafe extern "C" fn( - *const u8, *const u8, u32, - ) -> *mut u8 = unsafe { core::mem::transmute(addr) }; - - unsafe { - for &(module, library) in DISPLAY_MODULE_NAMES { - let ptr = sctrl_find_function( - module.as_ptr(), - library.as_ptr(), - NID_SCE_DISPLAY_SET_FRAME_BUF, - ); - - let mut buf = [0u8; 96]; - let mut pos = 0usize; - pos = write_log_bytes(&mut buf, pos, b"[OASIS] FindFunc mod="); - pos = write_log_cstr(&mut buf, pos, module); - pos = write_log_bytes(&mut buf, pos, b" lib="); - pos = write_log_cstr(&mut buf, pos, library); - pos = write_log_bytes(&mut buf, pos, b" -> "); - if ptr.is_null() { - pos = write_log_bytes(&mut buf, pos, b"NULL"); - } else { - pos = write_log_bytes(&mut buf, pos, b"0x"); - pos = write_log_hex(&mut buf, pos, ptr as u32); - } - crate::debug_log(&buf[..pos]); - } - } -} - fn write_log_bytes(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { let mut p = pos; for &b in s { @@ -514,18 +258,6 @@ fn write_log_bytes(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { p } -fn write_log_cstr(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { - let mut p = pos; - for &b in s { - if b == 0 || p >= buf.len() { - break; - } - buf[p] = b; - p += 1; - } - p -} - fn write_log_hex(buf: &mut [u8], pos: usize, val: u32) -> usize { let mut p = pos; let hex = b"0123456789ABCDEF"; diff --git a/crates/oasis-plugin-psp/src/main.rs b/crates/oasis-plugin-psp/src/main.rs index 6c2e8fa..4fb2931 100644 --- a/crates/oasis-plugin-psp/src/main.rs +++ b/crates/oasis-plugin-psp/src/main.rs @@ -75,9 +75,6 @@ fn psp_main() { debug_log(b"[OASIS] hook install FAILED"); } - // Also log what sctrlHENFindFunction returns for diagnostics - hook::log_find_function_result(); - // Keep the plugin thread alive (it does nothing after setup -- // all work happens in the display hook and audio thread). loop { From 0ba0444f97747fd6596b4e4c5c71cf7ce0f023d3 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 16:36:06 -0600 Subject: [PATCH 24/26] chore: remove debug beacon dots from overlay Co-Authored-By: Claude Opus 4.6 --- crates/oasis-plugin-psp/src/hook.rs | 9 --------- crates/oasis-plugin-psp/src/overlay.rs | 17 ----------------- 2 files changed, 26 deletions(-) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 02e0c75..35d7bc6 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -113,15 +113,6 @@ unsafe extern "C" fn hooked_set_frame_buf( let fb = (top_addr as u32 | 0x4000_0000) as *mut u32; let stride = buffer_width as u32; - // Debug beacon: 2x2 green dot at (1,1) confirms the hook is running. - // SAFETY: Writing within screen bounds to valid framebuffer. - unsafe { - *fb.add((1 * stride + 1) as usize) = 0xFF00FF00; - *fb.add((1 * stride + 2) as usize) = 0xFF00FF00; - *fb.add((2 * stride + 1) as usize) = 0xFF00FF00; - *fb.add((2 * stride + 2) as usize) = 0xFF00FF00; - } - // SAFETY: fb is a valid uncached framebuffer pointer. unsafe { overlay::on_frame(fb, stride); diff --git a/crates/oasis-plugin-psp/src/overlay.rs b/crates/oasis-plugin-psp/src/overlay.rs index 112d795..7be8e97 100644 --- a/crates/oasis-plugin-psp/src/overlay.rs +++ b/crates/oasis-plugin-psp/src/overlay.rs @@ -89,23 +89,6 @@ pub unsafe fn on_frame(fb: *mut u32, stride: u32) { PREV_BUTTONS = buttons; } - // Debug: 2x2 button-state indicator at (5,5). - // Green = idle, Red = L held, Blue = R held, White = L+R+START. - let combo = BTN_L_TRIGGER | BTN_R_TRIGGER | BTN_START; - let dbg_color = if (buttons & combo) == combo { - 0xFFFFFFFF // white -- full combo - } else if buttons != 0 { - 0xFF0000FF // red -- some button pressed - } else { - 0xFF00FF00 // green -- idle - }; - unsafe { - *fb.add((5 * stride + 5) as usize) = dbg_color; - *fb.add((5 * stride + 6) as usize) = dbg_color; - *fb.add((6 * stride + 5) as usize) = dbg_color; - *fb.add((6 * stride + 6) as usize) = dbg_color; - } - let trigger = config::get_config().trigger_mask(); let state = OverlayState::from_u8(STATE.load(Ordering::Relaxed)); From 1fd34913916a909bf3f3e5dcaa3f2411fb5bbcbc Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 22:05:48 -0600 Subject: [PATCH 25/26] chore: update rust-psp dependency to main branch The fix/osk-dialog-gu-frame branch was merged into main via PR #6. Co-Authored-By: Claude Opus 4.6 --- crates/oasis-backend-psp/Cargo.lock | 2 +- crates/oasis-backend-psp/Cargo.toml | 2 +- crates/oasis-plugin-psp/Cargo.lock | 2 +- crates/oasis-plugin-psp/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/oasis-backend-psp/Cargo.lock b/crates/oasis-backend-psp/Cargo.lock index 1ecac0b..723edab 100644 --- a/crates/oasis-backend-psp/Cargo.lock +++ b/crates/oasis-backend-psp/Cargo.lock @@ -748,7 +748,7 @@ dependencies = [ [[package]] name = "psp" version = "0.4.0" -source = "git+https://github.com/AndrewAltimit/rust-psp?branch=fix%2Fosk-dialog-gu-frame#17e948323f77abe57c7b1a76430fde2131171fdd" +source = "git+https://github.com/AndrewAltimit/rust-psp#edb63cfb7828374fffac218138187b52d0d567f5" dependencies = [ "bitflags", "libm", diff --git a/crates/oasis-backend-psp/Cargo.toml b/crates/oasis-backend-psp/Cargo.toml index a46ba3a..09d4f77 100644 --- a/crates/oasis-backend-psp/Cargo.toml +++ b/crates/oasis-backend-psp/Cargo.toml @@ -23,7 +23,7 @@ kernel-me-clock = ["psp/kernel"] # scePowerGetMeClockFrequency kernel-me = ["psp/kernel"] # ME coprocessor (me test command) [dependencies] -psp = { git = "https://github.com/AndrewAltimit/rust-psp", branch = "fix/osk-dialog-gu-frame", features = ["std"] } +psp = { git = "https://github.com/AndrewAltimit/rust-psp", features = ["std"] } oasis-core = { path = "../oasis-core" } libm = "0.2" diff --git a/crates/oasis-plugin-psp/Cargo.lock b/crates/oasis-plugin-psp/Cargo.lock index 403355c..4d5989d 100644 --- a/crates/oasis-plugin-psp/Cargo.lock +++ b/crates/oasis-plugin-psp/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "psp" version = "0.4.0" -source = "git+https://github.com/AndrewAltimit/rust-psp?branch=fix%2Fosk-dialog-gu-frame#598e8c1dc9797e4596061551f7611b303c1eed6c" +source = "git+https://github.com/AndrewAltimit/rust-psp#edb63cfb7828374fffac218138187b52d0d567f5" dependencies = [ "bitflags", "libm", diff --git a/crates/oasis-plugin-psp/Cargo.toml b/crates/oasis-plugin-psp/Cargo.toml index 45199c3..debb5bf 100644 --- a/crates/oasis-plugin-psp/Cargo.toml +++ b/crates/oasis-plugin-psp/Cargo.toml @@ -18,7 +18,7 @@ repository = "https://github.com/AndrewAltimit/oasis-os" authors = ["AndrewAltimit"] [dependencies] -psp = { git = "https://github.com/AndrewAltimit/rust-psp", branch = "fix/osk-dialog-gu-frame", features = ["kernel"] } +psp = { git = "https://github.com/AndrewAltimit/rust-psp", features = ["kernel"] } # unicode-width workaround for mips target panic. [patch.crates-io] From 5bdb85b11a0932b84c48faa97ef1647311f93991 Mon Sep 17 00:00:00 2001 From: AI Agent Bot Date: Sat, 14 Feb 2026 22:26:24 -0600 Subject: [PATCH 26/26] docs: update stale widget, command, and module counts - Widgets: 15+ -> 20+ (22 modules in oasis-ui) - Commands: 80+ -> 90+ (76 terminal + 17 core = 93) - Modules: 14 -> 17 (12 terminal + 5 core) - Fix PRX output filename in README (oasis-plugin-psp.prx) - Add oasis-plugin-psp to design.md crate tree Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 8 ++++---- CLAUDE.md | 8 ++++---- README.md | 14 +++++++------- docs/design.md | 7 ++++--- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7294e6e..9d396dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,10 +68,10 @@ oasis-types (foundation: Color, Button, InputEvent, backend traits, error ty ├── oasis-sdi (scene display interface: named object registry, z-order) ├── oasis-net (TCP networking, PSK auth, remote terminal, FTP) ├── oasis-audio (audio manager, playlist, MP3 ID3 parsing) -├── oasis-ui (15+ widgets: Button, Card, TabBar, ListView, flex layout) +├── oasis-ui (20+ widgets: Button, Card, TabBar, ListView, flex layout) ├── oasis-wm (window manager: drag/resize, hit testing, decorations) ├── oasis-skin (TOML skin engine, 8 skins, theme derivation) -├── oasis-terminal (80+ commands across 14 modules, shell features) +├── oasis-terminal (90+ commands across 17 modules, shell features) ├── oasis-browser (HTML/CSS/Gemini: DOM, CSS cascade, layout engine) └── oasis-core (coordination: apps, dashboard, agent, plugin, script) ├── oasis-backend-sdl (SDL2 desktop/Pi rendering + input + audio) @@ -100,9 +100,9 @@ The framework is split into 16 workspace crates. Each module below is its own cr - **oasis-sdi** -- Scene Display Interface: named objects with position, size, color, texture, text, z-order, gradients, rounded corners, shadows - **oasis-skin** -- Data-driven TOML skin system with 8 skins (2 external in `skins/`, 7 built-in; xp exists in both forms). Theme derivation from 9 base colors to ~30 UI element colors. - **oasis-browser** -- Embeddable HTML/CSS/Gemini rendering engine: DOM parser, CSS cascade, block/inline/table layout, link navigation, reader mode, bookmarks -- **oasis-ui** -- 15+ reusable widgets: Button, Card, TabBar, Panel, TextField, ListView, ScrollView, ProgressBar, Toggle, NinePatch, flex layout +- **oasis-ui** -- 20+ reusable widgets: Button, Card, TabBar, Panel, TextField, ListView, ScrollView, ProgressBar, Toggle, NinePatch, flex layout - **oasis-vfs** -- Virtual file system: `MemoryVfs` (in-RAM), `RealVfs` (disk), `GameAssetVfs` (UE5 with overlay writes) -- **oasis-terminal** -- Command interpreter with 80+ commands across 14 modules (core, text, file, system, dev, fun, security, doc, audio, network, skin, UI, plus agent/plugin/script/transfer/update registered by oasis-core). Shell features: variable expansion, glob expansion, aliases, history, piping +- **oasis-terminal** -- Command interpreter with 90+ commands across 17 modules (core, text, file, system, dev, fun, security, doc, audio, network, skin, UI, plus agent/plugin/script/transfer/update registered by oasis-core). Shell features: variable expansion, glob expansion, aliases, history, piping - **oasis-wm** -- Window manager (window configs, hit testing, drag/resize, minimize/maximize/close) - **oasis-net** -- TCP networking with PSK authentication, remote terminal, FTP transfer - **oasis-audio** -- Audio manager with playlist, shuffle/repeat modes, MP3 ID3 tag parsing diff --git a/CLAUDE.md b/CLAUDE.md index 587cdca..6f102a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,10 +70,10 @@ oasis-types (foundation: Color, Button, InputEvent, backend traits, error ty ├── oasis-sdi (scene display interface: named object registry, z-order) ├── oasis-net (TCP networking, PSK auth, remote terminal, FTP) ├── oasis-audio (audio manager, playlist, MP3 ID3 parsing) -├── oasis-ui (15+ widgets: Button, Card, TabBar, ListView, flex layout) +├── oasis-ui (20+ widgets: Button, Card, TabBar, ListView, flex layout) ├── oasis-wm (window manager: drag/resize, hit testing, decorations) ├── oasis-skin (TOML skin engine, 8 skins, theme derivation) -├── oasis-terminal (80+ commands across 14 modules, shell features) +├── oasis-terminal (90+ commands across 17 modules, shell features) ├── oasis-browser (HTML/CSS/Gemini: DOM, CSS cascade, layout engine) └── oasis-core (coordination: apps, dashboard, agent, plugin, script) ├── oasis-backend-sdl (SDL2 desktop/Pi rendering + input + audio) @@ -110,9 +110,9 @@ The framework is split into 16 workspace crates. Each module below is its own cr - **oasis-sdi** -- Scene Display Interface: named objects with position, size, color, texture, text, z-order, gradients, rounded corners, shadows - **oasis-skin** -- Data-driven TOML skin system with 8 skins (2 external in `skins/`, 7 built-in; xp exists in both forms). Theme derivation from 9 base colors. - **oasis-browser** -- Embeddable HTML/CSS/Gemini rendering engine: DOM parser, CSS cascade, block/inline/table layout, link navigation, reader mode -- **oasis-ui** -- 15+ reusable widgets: Button, Card, TabBar, Panel, TextField, ListView, ScrollView, ProgressBar, Toggle, NinePatch, flex layout +- **oasis-ui** -- 20+ reusable widgets: Button, Card, TabBar, Panel, TextField, ListView, ScrollView, ProgressBar, Toggle, NinePatch, flex layout - **oasis-vfs** -- Virtual file system: `MemoryVfs` (in-RAM), `RealVfs` (disk), `GameAssetVfs` (UE5 with overlay writes) -- **oasis-terminal** -- Command interpreter with 80+ commands across 14 modules (core, text, file, system, dev, fun, security, doc, audio, network, skin, UI, plus agent/plugin/script/transfer/update registered by oasis-core). Shell features: variable expansion, glob expansion, aliases, history, piping +- **oasis-terminal** -- Command interpreter with 90+ commands across 17 modules (core, text, file, system, dev, fun, security, doc, audio, network, skin, UI, plus agent/plugin/script/transfer/update registered by oasis-core). Shell features: variable expansion, glob expansion, aliases, history, piping - **oasis-wm** -- Window manager (window configs, hit testing, drag/resize, minimize/maximize/close) - **oasis-net** -- TCP networking with PSK authentication, remote terminal, FTP transfer - **oasis-audio** -- Audio manager with playlist, shuffle/repeat modes, MP3 ID3 tag parsing diff --git a/README.md b/README.md index c677bf8..0bd9469 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,9 @@ Native virtual resolution is 480x272 (PSP native) across all backends. - **Scene Graph (SDI)** -- Named object registry with position, size, color, texture, text, z-order, alpha, gradients, rounded corners, shadows - **Browser Engine** -- Embedded HTML/CSS/Gemini renderer with DOM parsing, CSS cascade, block/inline/table layout, link navigation, reader mode, bookmarks - **Window Manager** -- Movable, resizable, overlapping windows with titlebars, minimize/maximize/close, hit testing, and themed decorations -- **UI Widget Toolkit** -- 15+ reusable widgets: Button, Card, TabBar, Panel, TextField, ListView, ScrollView, ProgressBar, Toggle, NinePatch, flex layout, and more +- **UI Widget Toolkit** -- 20+ reusable widgets: Button, Card, TabBar, Panel, TextField, ListView, ScrollView, ProgressBar, Toggle, NinePatch, flex layout, and more - **Proportional Bitmap Font** -- Variable-width glyph rendering from ink bounds with per-character advance values (not fixed-width 8x8) -- **80+ Terminal Commands** -- 14 command modules: core (fs/system), text processing (head, tail, grep, sort, uniq, tr, cut, diff), file utilities (write, tree, du, stat, xxd, checksum), dev tools (base64, json, uuid, seq, expr), fun (cal, fortune, banner, matrix), security (chmod, chown, passwd, audit), documentation (man, tutorial, motd), networking (wifi, ping, http), audio, UI, skin switching, scripting, transfer (FTP), system updates. Shell features include variable expansion, glob expansion, aliases, history (!!/!n), piping, and command chaining +- **90+ Terminal Commands** -- 17 command modules: core (fs/system), text processing (head, tail, grep, sort, uniq, tr, cut, diff), file utilities (write, tree, du, stat, xxd, checksum), dev tools (base64, json, uuid, seq, expr), fun (cal, fortune, banner, matrix), security (chmod, chown, passwd, audit), documentation (man, tutorial, motd), networking (wifi, ping, http), audio, UI, skin switching, scripting, transfer (FTP), system updates. Shell features include variable expansion, glob expansion, aliases, history (!!/!n), piping, and command chaining - **Audio System** -- Playlist management, MP3/WAV playback, ID3 tag parsing, shuffle/repeat modes, volume control - **Plugin System** -- Runtime-extensible via `Plugin` trait, VFS-based IPC, manifest-driven discovery - **Virtual File System** -- `MemoryVfs` (in-RAM), `RealVfs` (disk), `GameAssetVfs` (UE5 with overlay writes) @@ -92,10 +92,10 @@ oasis-os/ | +-- oasis-sdi/ # Scene Display Interface: named object registry, z-order, rendering | +-- oasis-net/ # TCP networking, PSK authentication, remote terminal, FTP transfer | +-- oasis-audio/ # Audio manager, playlist, shuffle/repeat, MP3 ID3 parsing -| +-- oasis-ui/ # 15+ widgets: Button, Card, TabBar, Panel, TextField, ListView, etc. +| +-- oasis-ui/ # 20+ widgets: Button, Card, TabBar, Panel, TextField, ListView, etc. | +-- oasis-wm/ # Window manager: drag/resize, hit testing, minimize/maximize/close | +-- oasis-skin/ # TOML skin engine, 8 skins, theme derivation from 9 base colors -| +-- oasis-terminal/ # Command interpreter: 80+ commands across 14 modules, shell features +| +-- oasis-terminal/ # Command interpreter: 90+ commands across 17 modules, shell features | +-- oasis-browser/ # HTML/CSS/Gemini browser: DOM, CSS cascade, block/inline/table layout | +-- oasis-core/ # Coordination layer: apps, dashboard, agent, plugin, script, etc. | +-- oasis-backend-sdl/ # SDL2 rendering and input (desktop + Pi) @@ -121,10 +121,10 @@ oasis-os/ | `oasis-sdi` | Scene Display Interface: named object registry with position, size, color, texture, text, z-order, gradients, shadows | | `oasis-net` | TCP networking with PSK authentication, remote terminal, FTP transfer | | `oasis-audio` | Audio manager with playlist, shuffle/repeat modes, MP3 ID3 tag parsing | -| `oasis-ui` | 15+ reusable widgets: Button, Card, TabBar, Panel, TextField, ListView, ScrollView, ProgressBar, Toggle, NinePatch, flex layout | +| `oasis-ui` | 20+ reusable widgets: Button, Card, TabBar, Panel, TextField, ListView, ScrollView, ProgressBar, Toggle, NinePatch, flex layout | | `oasis-wm` | Window manager: movable/resizable windows, titlebar buttons, hit testing, themed decorations | | `oasis-skin` | Data-driven TOML skin system with 8 skins, theme derivation from 9 base colors to ~30 UI element colors | -| `oasis-terminal` | Command interpreter with 80+ commands across 14 modules, shell features (variables, globs, aliases, history, piping) | +| `oasis-terminal` | Command interpreter with 90+ commands across 17 modules, shell features (variables, globs, aliases, history, piping) | | `oasis-browser` | Embeddable HTML/CSS/Gemini rendering engine: DOM parser, CSS cascade, block/inline/table layout, reader mode | | `oasis-core` | Coordination layer: app runner (dual-panel file manager), dashboard, agent/MCP, plugin, scripting, status/bottom bars | | `oasis-backend-sdl` | SDL2 rendering and input backend for desktop and Raspberry Pi | @@ -163,7 +163,7 @@ RUST_PSP_BUILD_STD=1 cargo +nightly psp --release ```bash cd crates/oasis-plugin-psp RUST_PSP_BUILD_STD=1 cargo +nightly psp --release -# Output: target/mipsel-sony-psp-std/release/oasis_plugin.prx +# Output: target/mipsel-sony-psp-std/release/oasis-plugin-psp.prx ``` See [PSP Plugin Guide](docs/psp-plugin.md) for installation and usage. diff --git a/docs/design.md b/docs/design.md index db5bdf0..12669fd 100644 --- a/docs/design.md +++ b/docs/design.md @@ -102,15 +102,16 @@ oasis-os/ | +-- oasis-sdi/ # Scene graph: named registry, z-order, alpha, layout, theming | +-- oasis-net/ # TCP networking, PSK auth, remote terminal, FTP transfer | +-- oasis-audio/ # Audio manager, playlist, shuffle/repeat, MP3 ID3 parsing -| +-- oasis-ui/ # 15+ widgets: Button, Card, TabBar, ListView, flex layout +| +-- oasis-ui/ # 20+ widgets: Button, Card, TabBar, ListView, flex layout | +-- oasis-wm/ # Window manager: lifecycle, drag/resize, hit testing, clipping | +-- oasis-skin/ # TOML skin engine, 8 skins, theme derivation from 9 base colors -| +-- oasis-terminal/ # 80+ commands across 14 modules, shell features +| +-- oasis-terminal/ # 90+ commands across 17 modules, shell features | +-- oasis-browser/ # HTML/CSS/Gemini: DOM, CSS cascade, block/inline/table layout | +-- oasis-core/ # Coordination: apps (dual-panel FM), dashboard, agent, plugin, script | +-- oasis-backend-sdl/ # SDL2 rendering and input (desktop dev + Raspberry Pi) | +-- oasis-backend-ue5/ # UE5 render target, software RGBA framebuffer, FFI input queue | +-- oasis-backend-psp/ # [excluded from workspace] sceGu rendering, PSP controller, UMD browsing +| +-- oasis-plugin-psp/ # [excluded from workspace] kernel-mode PRX: in-game overlay + background music | +-- oasis-ffi/ # C FFI boundary for UE5: exported functions, opaque handles | +-- oasis-app/ # Binary entry points: desktop app + screenshot tool +-- skins/ @@ -297,7 +298,7 @@ SDI is deliberately simple. It is not a DOM, not a layout engine, and not a reta The command interpreter is a registry-based dispatch system in the `oasis-terminal` crate. Commands implement a `Command` trait with an `execute()` method returning structured output. The interpreter includes full shell features: variable expansion (`$VAR`, `${VAR}`), glob expansion, aliases, history (`!!`, `!n`), piping, and command chaining. Skins control which commands are registered -- a terminal skin exposes everything, a locked-down kiosk skin exposes only approved commands, a corrupted skin registers broken versions of standard commands that produce garbled output. The agent-terminal skin adds commands for remote agent interaction (see Section 11). -80+ commands across 14 modules: +90+ commands across 17 modules: | Command Module | Examples | Description | |----------------|----------|-------------|