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/AGENTS.md b/AGENTS.md index 0998689..9d396dd 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 @@ -65,17 +68,18 @@ 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) │ └── 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 @@ -96,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 2ec6df2..6f102a1 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 @@ -67,19 +70,28 @@ 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) │ └── 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: @@ -98,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 f0081c9..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) @@ -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/ @@ -92,15 +92,16 @@ 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) | +-- 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/ @@ -120,19 +121,20 @@ 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 | | `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-psp.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/Cargo.lock b/crates/oasis-backend-psp/Cargo.lock index 5150736..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=main#5f6047a25f4840e39c5f9af61b3922b01f2ad67a" +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 f585eea..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 = "main", 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-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-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.lock b/crates/oasis-plugin-psp/Cargo.lock new file mode 100644 index 0000000..4d5989d --- /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#edb63cfb7828374fffac218138187b52d0d567f5" +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/Cargo.toml b/crates/oasis-plugin-psp/Cargo.toml new file mode 100644 index 0000000..debb5bf --- /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", features = ["kernel"] } + +# 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 = "unwind" +# 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..60fdeed --- /dev/null +++ b/crates/oasis-plugin-psp/src/audio.rs @@ -0,0 +1,42 @@ +//! Background MP3 playback -- STUBBED OUT. +//! +//! 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; + +/// Get the current track's display name (stub). +pub fn current_track_name() -> &'static [u8] { + b"\0" +} + +/// Toggle play/pause (stub). +pub fn toggle_playback() { + overlay::show_osd(b"Audio: not available"); +} + +/// Skip to next track (stub). +pub fn next_track() { + overlay::show_osd(b"Audio: not available"); +} + +/// Skip to previous track (stub). +pub fn prev_track() { + overlay::show_osd(b"Audio: not available"); +} + +/// Increase volume (stub). +pub fn volume_up() { + overlay::show_osd(b"Audio: not available"); +} + +/// Decrease volume (stub). +pub fn volume_down() { + overlay::show_osd(b"Audio: not available"); +} + +/// Start the background audio thread (stub). +pub fn start_audio_thread() { + // No-op: sceMp3/sceAudio imports removed to prevent PRX load failure. +} diff --git a/crates/oasis-plugin-psp/src/config.rs b/crates/oasis-plugin-psp/src/config.rs new file mode 100644 index 0000000..b9321eb --- /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 < psp::sys::SceUid(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 *(&raw 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..35d7bc6 --- /dev/null +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -0,0 +1,266 @@ +//! Display framebuffer hook via CFW syscall patching. +//! +//! Intercepts `sceDisplaySetFrameBuf` to draw the overlay on top of the +//! 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; + +use core::sync::atomic::{AtomicBool, Ordering}; + +/// Whether the hook is currently installed. +static HOOK_INSTALLED: AtomicBool = AtomicBool::new(false); + +/// 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; + +/// NID for sceCtrlPeekBufferPositive. +const NID_SCE_CTRL_PEEK_BUF_POS: u32 = 0x3A622550; + +/// Resolved kernel-mode sceCtrlPeekBufferPositive function pointer. +static mut CTRL_PEEK_FN: Option i32> = None; + +/// 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. Reads the value set by the ctrl thread. +pub fn poll_buttons() -> u32 { + 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]; // 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 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 + } +} + +/// 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 { + crate::debug_log(b"[OASIS] ctrl thread FAILED"); + } + } +} + +/// Our hook function that replaces `sceDisplaySetFrameBuf`. +/// +/// # Safety +/// Called by the PSP OS as a syscall replacement. +unsafe extern "C" fn hooked_set_frame_buf( + top_addr: *const u8, + buffer_width: usize, + pixel_format: u32, + sync: u32, +) -> u32 { + // 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 u32 | 0x4000_0000) as *mut u32; + let stride = buffer_width as u32; + + // SAFETY: fb is a valid uncached framebuffer pointer. + unsafe { + overlay::on_frame(fb, stride); + } + } + + // Call original to submit the buffer to the display hardware. + // SAFETY: DISPLAY_HOOK is set before the hook is active. + unsafe { + 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 + } + } +} + +/// Module/library name pairs to try for finding sceDisplaySetFrameBuf. +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 +/// initialization. +pub fn install_display_hook() -> bool { + if HOOK_INSTALLED.load(Ordering::Relaxed) { + return true; + } + + // 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); + } + + // Try each module/library pair until we find sceDisplaySetFrameBuf. + let hook = unsafe { + let mut result = None; + for &(module, library) in DISPLAY_MODULE_NAMES { + result = psp::hook::SyscallHook::install( + module.as_ptr(), + library.as_ptr(), + NID_SCE_DISPLAY_SET_FRAME_BUF, + hooked_set_frame_buf as *mut u8, + ); + if result.is_some() { + crate::debug_log(b"[OASIS] display hook installed"); + break; + } + } + result + }; + + let Some(hook) = hook else { + crate::debug_log(b"[OASIS] hook: all module/library pairs failed"); + return false; + }; + + // 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"), + ]; + unsafe { + for &(module, library) in ctrl_names { + if let Some(ptr) = psp::hook::find_function( + module.as_ptr(), + library.as_ptr(), + NID_SCE_CTRL_PEEK_BUF_POS, + ) { + CTRL_PEEK_FN = Some(core::mem::transmute(ptr)); + 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. + let set_cycle = psp::hook::find_function( + b"sceController_Service\0".as_ptr(), + b"sceCtrl_driver\0".as_ptr(), + 0x6A2774F3, // sceCtrlSetSamplingCycle + ); + 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 = psp::hook::find_function( + b"sceController_Service\0".as_ptr(), + b"sceCtrl_driver\0".as_ptr(), + 0x1F4011E6, // sceCtrlSetSamplingMode + ); + 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. + start_ctrl_thread(); + } + } + + HOOK_INSTALLED.store(true, Ordering::Release); + crate::debug_log(b"[OASIS] hook installed OK"); + true +} + +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 +} + +fn write_log_hex(buf: &mut [u8], pos: usize, val: u32) -> usize { + let mut p = pos; + let hex = b"0123456789ABCDEF"; + 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 new file mode 100644 index 0000000..4fb2931 --- /dev/null +++ b/crates/oasis-plugin-psp/src/main.rs @@ -0,0 +1,86 @@ +//! 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); + +/// 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"); + } + + // 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..7be8e97 --- /dev/null +++ b/crates/oasis-plugin-psp/src/overlay.rs @@ -0,0 +1,355 @@ +//! 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_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; +const BTN_L_TRIGGER: u32 = 0x100; +const BTN_R_TRIGGER: u32 = 0x200; +const BTN_START: u32 = 0x8; + +/// 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 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 + unsafe { + PREV_BUTTONS = buttons; + } + + 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 triggered { + 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 triggered { + STATE.store(OverlayState::Menu as u8, Ordering::Relaxed); + unsafe { + CURSOR = 0; + } + } + } + OverlayState::Menu => { + if triggered { + 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); + } + } + } + } + + // 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 { + 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(47); + 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 (stub -- scePower imports removed). +fn cycle_cpu_clock() { + show_osd(b"CPU clock: not available"); +} + +/// 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 (static text -- power/RTC imports removed). +/// +/// # Safety +/// `fb` must be valid. +unsafe fn draw_status_line(fb: *mut u32, stride: u32) { + // SAFETY: render functions check bounds. + unsafe { + render::draw_string( + fb, stride, + OVERLAY_X + 8, STATUS_Y, + b"OASIS Plugin v0.1", + colors::GREEN, + ); + } +} + +/// Draw the now-playing track name (stub). +/// +/// # Safety +/// `fb` must be valid. +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. +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..3bae54e --- /dev/null +++ b/crates/oasis-plugin-psp/src/render.rs @@ -0,0 +1,185 @@ +//! 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) { + // 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 { + 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..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 | |----------------|----------|-------------| @@ -702,6 +703,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 |