Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f0ac8eb
feat: PRX audio playback, CPU clock control, and FTP file server
Feb 15, 2026
a509d9c
fix: recursive MP3 scanning for subdirectories in PRX audio
Feb 15, 2026
103c24d
fix: correct sceMp3NotifyAddStreamData NID and module load order
Feb 15, 2026
4849b2b
fix: use sceAudiocodec instead of sceMp3 for kernel-mode MP3 playback
Feb 15, 2026
e185371
fix: use sceUtilityLoadModule to properly load MP3 codec modules
Feb 15, 2026
b96e117
feat(prx): resolve user-mode MP3/codec NIDs via manual export table w…
Feb 15, 2026
e062671
fix(prx): validate module pointers + try alternative kernel APIs
Feb 15, 2026
95a30db
feat(prx): enumerate modules via GetModuleIdList + kernel-load flash0…
Feb 15, 2026
9223c4b
feat(prx): add NID memory scanner for diagnostic dump
Feb 15, 2026
543f3b9
feat(prx): extract sceAudiocodec from game's resolved import stubs
Feb 15, 2026
45dae6b
fix(prx): run stub extraction before heavy NID scan
Feb 15, 2026
572af54
feat(prx): full binary memory dump for offline analysis
Feb 15, 2026
dba2cda
feat(prx): resolve sceAudiocodec via syscall table cross-reference
Feb 15, 2026
868b390
fix(prx): search all user memory for sceAudio import stubs
Feb 15, 2026
966231b
feat(prx): call sceAudiocodec through game's import stubs directly
Feb 15, 2026
91267f7
fix: use dedicated audio channel and fix codec buffer size
Feb 15, 2026
208022f
fix: allocate codec buffers in user memory and add sceAvcodec_wrapper…
Feb 15, 2026
c1d036c
fix: set codec input/output size fields and cap decode error loop
Feb 15, 2026
71a45e0
feat: use SRC audio channel to avoid conflicting with game audio
Feb 15, 2026
9b661ac
fix: set codec[10] field and add diagnostic dumps for decode debugging
Feb 15, 2026
79d130d
fix: replace sceAudiocodecGetEDRAM with user RAM to avoid game crash
Feb 15, 2026
3a749d5
fix: revert to GetEDRAM and skip module loading to prevent game crash
Feb 15, 2026
37f64f2
fix: wait for game to load AVCODEC modules before probing codec
Feb 15, 2026
05e616a
perf: reduce audio choppiness with larger buffer and less logging
Feb 15, 2026
2688622
fix: increase codec probe retries from 8 to 30 (60s total)
Feb 15, 2026
32347f2
perf: streaming buffer refill to eliminate audio hiccups
Feb 15, 2026
2f6f6f5
chore: update rust-psp to main with SrcChannel and AudiocodecDecoder
Feb 15, 2026
d763edb
fix: address AI review feedback (iteration 1)
Feb 15, 2026
2de2768
fix: cap consecutive zero-consumed codec decodes to prevent spin loop
Feb 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/oasis-app/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use oasis_core::skin::Skin;
use oasis_core::startmenu::StartMenuState;
use oasis_core::statusbar::StatusBar;
use oasis_core::terminal::CommandRegistry;
use oasis_core::transfer::FtpServer;
use oasis_core::transition;
use oasis_core::wm::manager::WindowManager;

Expand Down Expand Up @@ -50,6 +51,7 @@ pub struct AppState {
pub browser: Option<BrowserWidget>,
pub net_backend: StdNetworkBackend,
pub listener: Option<RemoteListener>,
pub ftp_server: Option<FtpServer>,
pub remote_client: Option<RemoteClient>,
pub tls_provider: RustlsTlsProvider,
pub mouse_cursor: CursorState,
Expand Down
52 changes: 49 additions & 3 deletions crates/oasis-app/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use oasis_core::sdi::SdiRegistry;
use oasis_core::skin::{Skin, resolve_skin};
use oasis_core::startmenu::StartMenuState;
use oasis_core::terminal::{CommandOutput, Environment};
use oasis_core::transfer::FtpServer;
use oasis_core::vfs::MemoryVfs;

use crate::app_state::AppState;
Expand Down Expand Up @@ -98,6 +99,36 @@ pub fn process_command_output(
};
state.output_lines.push(format!("Browser sandbox: {st}"));
},
Ok(CommandOutput::FtpToggle { port }) => {
if port == 0 {
if let Some(ref mut f) = state.ftp_server {
f.stop();
state.ftp_server = None;
state.output_lines.push("FTP server stopped.".to_string());
} else {
state
.output_lines
.push("No FTP server running.".to_string());
}
} else if state.ftp_server.is_some() {
state
.output_lines
.push("FTP server already running. Use 'ftp stop' first.".to_string());
} else {
let mut server = FtpServer::new(port);
match server.start(&mut state.net_backend) {
Ok(()) => {
state
.output_lines
.push(format!("FTP server listening on port {port}."));
state.ftp_server = Some(server);
},
Err(e) => {
state.output_lines.push(format!("FTP server error: {e}"));
},
}
}
},
Ok(CommandOutput::SkinSwap { name }) => {
return Some(name);
},
Expand Down Expand Up @@ -170,9 +201,9 @@ fn format_remote_response(
},
Ok(CommandOutput::Clear) => "OK".to_string(),
Ok(CommandOutput::None) => "OK".to_string(),
Ok(CommandOutput::ListenToggle { .. }) | Ok(CommandOutput::RemoteConnect { .. }) => {
"Not available via remote.".to_string()
},
Ok(CommandOutput::ListenToggle { .. })
| Ok(CommandOutput::RemoteConnect { .. })
| Ok(CommandOutput::FtpToggle { .. }) => "Not available via remote.".to_string(),
Ok(CommandOutput::BrowserSandbox { enable }) => {
if let Some(bw) = browser {
bw.config.features.sandbox_only = enable;
Expand Down Expand Up @@ -259,6 +290,21 @@ pub fn poll_remote_listener(state: &mut AppState, sdi: &mut SdiRegistry, vfs: &m
}
}

/// Poll the FTP server for incoming connections and commands.
pub fn poll_ftp_server(state: &mut AppState, vfs: &mut MemoryVfs) {
let AppState {
ref mut ftp_server,
ref mut net_backend,
..
} = *state;

let Some(server) = ftp_server else { return };

if let Err(e) = server.poll(net_backend, vfs) {
log::warn!("FTP server poll error: {e}");
}
}

/// Poll the remote client for received data.
pub fn poll_remote_client(state: &mut AppState) {
let Some(ref mut client) = state.remote_client else {
Expand Down
4 changes: 4 additions & 0 deletions crates/oasis-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ fn main() -> Result<()> {
browser: None,
net_backend: StdNetworkBackend::new(),
listener: None,
ftp_server: None,
remote_client: None,
tls_provider: RustlsTlsProvider::new(),
mouse_cursor,
Expand Down Expand Up @@ -227,6 +228,9 @@ fn main() -> Result<()> {
// Poll remote listener for incoming commands.
commands::poll_remote_listener(&mut state, &mut sdi, &mut vfs);

// Poll FTP server for incoming connections.
commands::poll_ftp_server(&mut state, &mut vfs);

// Poll remote client for received data.
commands::poll_remote_client(&mut state);

Expand Down
6 changes: 5 additions & 1 deletion crates/oasis-core/src/script/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ pub fn run_script(
},
Ok(CommandOutput::Clear) => output.push("(clear)".to_string()),
Ok(CommandOutput::None) => {},
Ok(CommandOutput::ListenToggle { .. } | CommandOutput::RemoteConnect { .. }) => {
Ok(
CommandOutput::ListenToggle { .. }
| CommandOutput::RemoteConnect { .. }
| CommandOutput::FtpToggle { .. },
) => {
output.push("(network command skipped in script)".to_string());
},
Ok(CommandOutput::BrowserSandbox { enable }) => {
Expand Down
224 changes: 197 additions & 27 deletions crates/oasis-core/src/transfer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
//!
//! Also provides terminal commands: `ftp start/stop`, `push`, `pull`.

use std::time::Instant;

use oasis_types::backend::{NetworkBackend, NetworkStream};

use crate::error::{OasisError, Result};
use crate::terminal::{Command, CommandOutput, Environment};
use crate::vfs::Vfs;
Expand Down Expand Up @@ -116,6 +120,182 @@ pub fn process_ftp_request(line: &str, vfs: &mut dyn Vfs) -> String {
}
}

// ---------------------------------------------------------------------------
// FTP Server
// ---------------------------------------------------------------------------

/// Maximum simultaneous FTP connections.
const MAX_FTP_CONNECTIONS: usize = 4;

/// Maximum bytes in a single FTP input line.
const MAX_FTP_LINE_LEN: usize = 1024;

/// Maximum commands to process per connection per poll cycle.
const MAX_CMDS_PER_POLL: usize = 16;

/// Idle connection timeout in seconds.
const FTP_IDLE_TIMEOUT_SECS: u64 = 300;

/// A single FTP client connection.
struct FtpConnection {
stream: Box<dyn NetworkStream>,
read_buf: Vec<u8>,
last_activity: Instant,
}

impl FtpConnection {
fn new(stream: Box<dyn NetworkStream>) -> Self {
Self {
stream,
read_buf: Vec::with_capacity(256),
last_activity: Instant::now(),
}
}
}

/// Poll-based FTP file server.
///
/// Accepts TCP connections and processes FTP protocol commands against
/// the VFS. Designed for non-blocking polling from the main loop,
/// following the same pattern as `RemoteListener`.
pub struct FtpServer {
port: u16,
connections: Vec<FtpConnection>,
listening: bool,
}

impl FtpServer {
/// Create a new FTP server on the given port.
pub fn new(port: u16) -> Self {
Self {
port,
connections: Vec::new(),
listening: false,
}
}

/// Start listening on the configured port.
pub fn start(&mut self, backend: &mut dyn NetworkBackend) -> Result<()> {
backend.listen(self.port)?;
self.listening = true;
Ok(())
}

/// Whether the server is active.
pub fn is_listening(&self) -> bool {
self.listening
}

/// Number of active connections.
pub fn connection_count(&self) -> usize {
self.connections.len()
}

/// Poll for new connections and process FTP commands.
///
/// Call from the main loop each frame. Commands are executed
/// immediately against the provided VFS.
pub fn poll(&mut self, backend: &mut dyn NetworkBackend, vfs: &mut dyn Vfs) -> Result<()> {
if !self.listening {
return Ok(());
}

let idle_timeout = std::time::Duration::from_secs(FTP_IDLE_TIMEOUT_SECS);

// Accept new connections.
if self.connections.len() < MAX_FTP_CONNECTIONS {
match backend.accept() {
Ok(Some(stream)) => {
let mut conn = FtpConnection::new(stream);
let _ = conn.stream.write(b"220 OASIS FTP server ready\r\n");
self.connections.push(conn);
},
Ok(None) => {},
Err(e) => log::warn!("FTP accept error: {e}"),
}
}

// Read from all connections.
let mut to_remove = Vec::new();

for (idx, conn) in self.connections.iter_mut().enumerate() {
// Check idle timeout.
if conn.last_activity.elapsed() > idle_timeout {
let _ = conn.stream.write(b"421 Idle timeout\r\n");
to_remove.push(idx);
continue;
}

let mut buf = [0u8; 512];
match conn.stream.read(&mut buf) {
Ok(0) => {},
Err(OasisError::Io(ref e)) if e.kind() == std::io::ErrorKind::WouldBlock => {
// No data yet.
},
Ok(n) => {
conn.last_activity = Instant::now();
conn.read_buf.extend_from_slice(&buf[..n]);

// Process complete lines (capped per poll cycle).
let mut cmds_processed = 0usize;
while cmds_processed < MAX_CMDS_PER_POLL {
let Some(pos) = conn.read_buf.iter().position(|&b| b == b'\n') else {
break;
};
cmds_processed += 1;
let line_bytes: Vec<u8> = conn.read_buf.drain(..=pos).collect();
let line = String::from_utf8_lossy(&line_bytes).trim().to_string();

if line.is_empty() {
continue;
}

// Check for QUIT.
if line.eq_ignore_ascii_case("QUIT") {
let _ = conn.stream.write(b"200 goodbye\r\n");
to_remove.push(idx);
break;
}

// Process command against VFS.
let response = process_ftp_request(&line, vfs);
let _ = conn.stream.write(response.as_bytes());
}

// Guard against overlong lines.
if conn.read_buf.len() > MAX_FTP_LINE_LEN {
conn.read_buf.clear();
let _ = conn.stream.write(b"500 line too long\r\n");
}
},
Err(_) => {
to_remove.push(idx);
},
}
}

// Remove closed connections (in reverse to preserve indices).
to_remove.sort_unstable();
to_remove.dedup();
for idx in to_remove.into_iter().rev() {
let mut conn = self.connections.remove(idx);
let _ = conn.stream.close();
}

Ok(())
}

/// Shut down all connections and stop listening.
pub fn stop(&mut self) {
for conn in &mut self.connections {
let _ = conn.stream.write(b"421 Server shutting down\r\n");
let _ = conn.stream.close();
}
self.connections.clear();
self.listening = false;
}
}

// ---------------------------------------------------------------------------
// Terminal commands
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -145,25 +325,9 @@ impl Command for FtpCmd {
.get(1)
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(DEFAULT_FTP_PORT);

// Signal the FTP server to start via VFS IPC.
if !env.vfs.exists("/var/ftp") {
env.vfs.mkdir("/var").ok();
env.vfs.mkdir("/var/ftp").ok();
}
let request = format!("start {port}");
env.vfs.write(FTP_REQUEST_PATH, request.as_bytes())?;
let status = format!("active port={port}");
env.vfs.write(FTP_STATUS_PATH, status.as_bytes())?;
Ok(CommandOutput::Text(format!(
"FTP server starting on port {port}"
)))
},
"stop" => {
env.vfs.write(FTP_REQUEST_PATH, b"stop")?;
env.vfs.write(FTP_STATUS_PATH, b"inactive")?;
Ok(CommandOutput::Text("FTP server stopping".to_string()))
Ok(CommandOutput::FtpToggle { port })
},
"stop" => Ok(CommandOutput::FtpToggle { port: 0 }),
"status" => {
if env.vfs.exists(FTP_STATUS_PATH) {
let data = env.vfs.read(FTP_STATUS_PATH)?;
Expand Down Expand Up @@ -419,22 +583,28 @@ mod tests {
fn ftp_cmd_start() {
let (reg, mut vfs) = setup();
match exec(&reg, &mut vfs, "ftp start 8021").unwrap() {
CommandOutput::Text(s) => assert!(s.contains("8021")),
_ => panic!("expected text"),
CommandOutput::FtpToggle { port } => assert_eq!(port, 8021),
other => panic!("expected FtpToggle, got {other:?}"),
}
}

#[test]
fn ftp_cmd_start_default_port() {
let (reg, mut vfs) = setup();
match exec(&reg, &mut vfs, "ftp start").unwrap() {
CommandOutput::FtpToggle { port } => {
assert_eq!(port, DEFAULT_FTP_PORT);
},
other => panic!("expected FtpToggle, got {other:?}"),
}
let data = vfs.read(FTP_STATUS_PATH).unwrap();
let text = String::from_utf8_lossy(&data);
assert!(text.contains("active"));
assert!(text.contains("8021"));
}

#[test]
fn ftp_cmd_stop() {
let (reg, mut vfs) = setup();
exec(&reg, &mut vfs, "ftp start").unwrap();
match exec(&reg, &mut vfs, "ftp stop").unwrap() {
CommandOutput::Text(s) => assert!(s.contains("stopping")),
_ => panic!("expected text"),
CommandOutput::FtpToggle { port } => assert_eq!(port, 0),
other => panic!("expected FtpToggle stop, got {other:?}"),
}
}

Expand Down
Loading
Loading