From a90ceb0a53d68d0e5149175894d1fff6320fd64c Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:40:57 +0800 Subject: [PATCH 1/2] feat(vita): svc mailbox + video plane + audio over WiFi TCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vita host registered only ops 1–29: no svc channel, no video plane, no audio, no network stack. This implements the whole family (ops 30–38) over the PKNT wire protocol: - net.rs: sceNet init confined to one function (module load, 1 MiB static pool, sceNetCtlInit; failure is remembered and final — Vita3K has no network stack, so svcOpen stays false and emulator e2e never regresses); everything after rides std::net over the newlib shims. Supervisor thread (host.txt override → UDP beacon discovery → connect + handshake → rx loop: CTRL → line queue, FILE → 6 MiB LRU cache, SLOT/CHUNK/MARK → RamStream, PING → PONG) + tx thread draining an mpsc channel. On disconnect the ring is marked ended, 1 s backoff, rediscover. The 60 fps main thread never blocks: its ops pop queues and take short-held mutexes. - svc.rs (ops 30–33): PSP guest semantics verbatim — non-blocking svcOpen the app's connect pump retries, batched svcPoll, side files resolved from the proactive-push cache so the synchronous loadImgFile never waits on the network. - vid.rs (ops 34–37): the PSP vid.rs reader over RamStream::buf() — same epoch/lap/seq-validation logic, same stage-then-present split, minus the IO budget (reads are memcpys). present() commits in the GPU-idle window: Runtime::render now runs begin_frame → vid::present → render_over, and graphics grows update_texture_in_place (rewrites the existing vita2d texture's pixels — the register/recycle path would allocate + drain GXM once per presented frame at 24 fps). The GE race discipline, GXM edition. - audio.rs: the PSP design with sceAudioOut — BGM port at 44.1 kHz, SPSC ring (~743 ms), starvation sleeps, port opened AND released on the main thread (the channel-leak class of bug). Integer ×1/×2/×4 upsampler keeps PSP-profile 22.05 kHz streams playable. - stats.rs + debugStats (op 38): PSP shape + a net section (rxBytes/ txBytes/reconnects/slotsRx/fileEvicts); build.rs bakes the FNV-1a64 bundle hash for the stale-embed tripwire. Validated: full release VPK build (im-main) with the vita toolchain; Vita3K e2e cursor-main 5/5 byte-exact (new modules dormant without svcOpen). The wire/ring logic itself is covered by the engine/core tests (#168); transport verification is real-hardware only (Vita3K has no sceNet). Co-Authored-By: Claude Fable 5 --- hosts/vita/Cargo.toml | 2 +- hosts/vita/build.rs | 16 +- hosts/vita/src/audio.rs | 198 ++++++++++++++++ hosts/vita/src/ffi.rs | 153 ++++++++++++ hosts/vita/src/graphics.rs | 36 +++ hosts/vita/src/lib.rs | 13 +- hosts/vita/src/net.rs | 466 +++++++++++++++++++++++++++++++++++++ hosts/vita/src/stats.rs | 54 +++++ hosts/vita/src/svc.rs | 55 +++++ hosts/vita/src/vid.rs | 208 +++++++++++++++++ 10 files changed, 1197 insertions(+), 4 deletions(-) create mode 100644 hosts/vita/src/audio.rs create mode 100644 hosts/vita/src/net.rs create mode 100644 hosts/vita/src/stats.rs create mode 100644 hosts/vita/src/svc.rs create mode 100644 hosts/vita/src/vid.rs diff --git a/hosts/vita/Cargo.toml b/hosts/vita/Cargo.toml index 5c6ccd50..2676fb77 100644 --- a/hosts/vita/Cargo.toml +++ b/hosts/vita/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" [dependencies] # Vita dependencies. vita2d ships precompiled GXM shaders, so VPKs remain # self-contained and do not require a separately extracted libshacccg.suprx. -vitasdk-sys = { version = "0.3.3", features = ["SceCtrl_stub", "SceGxm_stub", "SceKernelThreadMgr_stub", "SceLibKernel_stub", "SceTouch_stub"] } +vitasdk-sys = { version = "0.3.3", features = ["SceAudio_stub", "SceCtrl_stub", "SceGxm_stub", "SceKernelThreadMgr_stub", "SceLibKernel_stub", "SceNet_stub", "SceNetCtl_stub", "SceSysmodule_stub", "SceTouch_stub"] } vita2d-sys = "0.1.1" libquickjs-sys = { git = "https://github.com/pocket-stack/quickjs-rs.git", rev = "0fc946fb670c0c29bc0135f510bcb0f595415a61" } diff --git a/hosts/vita/build.rs b/hosts/vita/build.rs index c7b9a018..89e3743c 100644 --- a/hosts/vita/build.rs +++ b/hosts/vita/build.rs @@ -105,7 +105,21 @@ fn main() { } else { fs::read(dist.join(format!("{app}.pak"))).unwrap_or_default() }; - fs::write(Path::new(&out_dir).join("app.pak"), pak).unwrap(); + fs::write(Path::new(&out_dir).join("app.pak"), pak.clone()).unwrap(); + + // Build identity for debugStats: FNV-1a64 over the embedded js+pak (the + // PSP host's stale-embed tripwire, verbatim math). + let bundle_hash = { + let code = fs::read(Path::new(&out_dir).join("game.js")).unwrap_or_default(); + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in code.iter().chain(pak.iter()) { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{h:016x}") + }; + println!("cargo:rustc-env=POCKETJS_APP_NAME={app}"); + println!("cargo:rustc-env=POCKETJS_BUNDLE_HASH={bundle_hash}"); let capture_input = env::var("POCKETJS_CAPTURE_INPUT").unwrap_or_default(); let capture_touch = env::var("POCKETJS_CAPTURE_TOUCH").unwrap_or_default(); diff --git a/hosts/vita/src/audio.rs b/hosts/vita/src/audio.rs new file mode 100644 index 00000000..27670fb6 --- /dev/null +++ b/hosts/vita/src/audio.rs @@ -0,0 +1,198 @@ +//! PCM output for the video plane: one BGM port at 44.1 kHz fed by a +//! dedicated thread from an in-RAM SPSC ring — the PSP audio.rs design with +//! sceAudioOut in place of sceAudioCh and std::thread in place of raw kernel +//! threads. The integer upsample path is kept (a PSP-profile 22.05 kHz +//! stream still plays if the host ever serves one); the Vita profile streams +//! native 44.1 kHz, so k = 1 and the interpolator is pass-through. +//! +//! Same disciplines that were earned on PSP hardware: +//! - single writer (main thread, vid::tick) + single reader (audio thread) +//! over absolute frame counters with release/acquire publication; +//! - starvation sleeps instead of queueing silence (resume latency is one +//! block, not a queue of hush); +//! - the PORT is opened and released on the MAIN thread (the channel-leak +//! class of bug: release-from-the-audio-thread left the channel held). + +use core::ffi::c_void; +use core::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering}; + +use vitasdk_sys::{ + sceAudioOutOpenPort, sceAudioOutOutput, sceAudioOutReleasePort, sceKernelDelayThread, + SCE_AUDIO_OUT_MODE_STEREO, SCE_AUDIO_OUT_PORT_TYPE_BGM, +}; + +use crate::stats; + +/// Output frames per port submit at 44.1 kHz (~23 ms per block). +const BLOCK_OUT: usize = 1024; +/// In-RAM ring capacity in SOURCE sample frames (~743 ms at 44.1 kHz). +const RING_FRAMES: usize = 32 * 1024; + +static mut RING: [i16; RING_FRAMES * 2] = [0; RING_FRAMES * 2]; +static mut OUT: [i16; BLOCK_OUT * 2] = [0; BLOCK_OUT * 2]; + +static WRITE_POS: AtomicUsize = AtomicUsize::new(0); +static READ_POS: AtomicUsize = AtomicUsize::new(0); +static RUN: AtomicBool = AtomicBool::new(false); +static LIVE: AtomicBool = AtomicBool::new(false); +/// Open BGM port id (-1 = none). Owned by the main thread. +static PORT: AtomicI32 = AtomicI32::new(-1); +/// Integer upsample factor to 44.1 kHz. +static UPSAMPLE: AtomicUsize = AtomicUsize::new(1); + +fn upsample_factor(sample_rate: u32) -> Option { + match sample_rate { + 44100 => Some(1), + 22050 => Some(2), + 11025 => Some(4), + _ => None, + } +} + +fn audio_thread() { + let mut prev_l: i32 = 0; + let mut prev_r: i32 = 0; + while RUN.load(Ordering::Acquire) { + let k = UPSAMPLE.load(Ordering::Relaxed).max(1); + let need = BLOCK_OUT / k; + let read = READ_POS.load(Ordering::Relaxed); + let avail = WRITE_POS.load(Ordering::Acquire).wrapping_sub(read); + if avail < need { + stats::AUDIO_STARVED.fetch_add(1, Ordering::Relaxed); + unsafe { sceKernelDelayThread(4_000) }; + continue; + } + unsafe { + for i in 0..need { + let src = ((read + i) % RING_FRAMES) * 2; + let l = RING[src] as i32; + let r = RING[src + 1] as i32; + match k { + 1 => { + OUT[i * 2] = l as i16; + OUT[i * 2 + 1] = r as i16; + } + 2 => { + OUT[i * 4] = ((prev_l + l) >> 1) as i16; + OUT[i * 4 + 1] = ((prev_r + r) >> 1) as i16; + OUT[i * 4 + 2] = l as i16; + OUT[i * 4 + 3] = r as i16; + } + _ => { + for step in 0..4 { + let t = step as i32 + 1; + let o = (i * 4 + step) * 2; + OUT[o] = ((prev_l * (4 - t) + l * t) >> 2) as i16; + OUT[o + 1] = ((prev_r * (4 - t) + r * t) >> 2) as i16; + } + } + } + prev_l = l; + prev_r = r; + } + } + READ_POS.store(read.wrapping_add(need), Ordering::Release); + let port = PORT.load(Ordering::Relaxed); + if port >= 0 { + // Blocking: parks this thread until the hardware drains a block. + unsafe { sceAudioOutOutput(port, OUT.as_ptr() as *const c_void) }; + } + } + LIVE.store(false, Ordering::Release); +} + +/// Open the BGM port and start the output thread. Rates without an integer +/// path to 44.1 kHz are refused (video still plays, silently). +pub unsafe fn start(sample_rate: u32) -> bool { + let Some(k) = upsample_factor(sample_rate) else { return false }; + if LIVE.load(Ordering::Acquire) { + stop(); + } + WRITE_POS.store(0, Ordering::Relaxed); + READ_POS.store(0, Ordering::Relaxed); + UPSAMPLE.store(k, Ordering::Relaxed); + let port = sceAudioOutOpenPort( + SCE_AUDIO_OUT_PORT_TYPE_BGM as _, + BLOCK_OUT as i32, + 44100, + SCE_AUDIO_OUT_MODE_STEREO as _, + ); + if port < 0 { + stats::AUDIO_LAST_RESERVE_ERR.store(port, Ordering::Relaxed); + return false; + } + PORT.store(port, Ordering::Relaxed); + RUN.store(true, Ordering::Release); + match std::thread::Builder::new() + .name("pjs-audio".into()) + .stack_size(32 * 1024) + .spawn(audio_thread) + { + Ok(_) => { + LIVE.store(true, Ordering::Release); + true + } + Err(_) => { + RUN.store(false, Ordering::Release); + sceAudioOutReleasePort(port); + PORT.store(-1, Ordering::Relaxed); + false + } + } +} + +/// Signal the thread down, wait out its final blocking block (~23 ms), then +/// release the port from THIS thread. +pub unsafe fn stop() { + if LIVE.load(Ordering::Acquire) { + RUN.store(false, Ordering::Release); + for _ in 0..250 { + if !LIVE.load(Ordering::Acquire) { + break; + } + sceKernelDelayThread(4_000); + } + } + if !LIVE.load(Ordering::Acquire) { + let port = PORT.swap(-1, Ordering::Relaxed); + if port >= 0 { + sceAudioOutReleasePort(port); + } + } +} + +/// SOURCE sample frames the ring can still accept. +pub fn free_frames() -> usize { + let queued = WRITE_POS + .load(Ordering::Relaxed) + .wrapping_sub(READ_POS.load(Ordering::Acquire)); + RING_FRAMES - queued.min(RING_FRAMES) +} + +/// Queue interleaved s16 PCM at the SOURCE rate (mono upmixes to both ears). +pub unsafe fn push(pcm: &[i16], channels: u32) { + let frames = match channels { + 1 => pcm.len(), + 2 => pcm.len() / 2, + _ => return, + }; + let n = frames.min(free_frames()); + let write = WRITE_POS.load(Ordering::Relaxed); + for i in 0..n { + let dst = ((write + i) % RING_FRAMES) * 2; + if channels == 1 { + RING[dst] = pcm[i]; + RING[dst + 1] = pcm[i]; + } else { + RING[dst] = pcm[i * 2]; + RING[dst + 1] = pcm[i * 2 + 1]; + } + } + WRITE_POS.store(write.wrapping_add(n), Ordering::Release); + stats::AUDIO_PUSHED_FRAMES.fetch_add(n as u32, Ordering::Relaxed); +} + +/// Drop everything queued (seek/epoch discontinuity). +pub fn flush() { + READ_POS.store(WRITE_POS.load(Ordering::Relaxed), Ordering::Release); +} diff --git a/hosts/vita/src/ffi.rs b/hosts/vita/src/ffi.rs index 66e7910d..aa495dfa 100644 --- a/hosts/vita/src/ffi.rs +++ b/hosts/vita/src/ffi.rs @@ -595,6 +595,147 @@ unsafe extern "C" fn js_dbg_shot( JS_NewBool(ctx, crate::dbg::shot()) } +/// Borrow a UTF-8 string argument (the PSP ffi.rs helper, verbatim). +unsafe fn with_str_arg( + ctx: *mut JSContext, + argc: i32, + argv: *mut JSValue, + i: isize, + miss: R, + f: impl FnOnce(&str) -> R, +) -> R { + if (i as i32) >= argc { + return miss; + } + let mut len: size_t = 0; + let s = JS_ToCStringLen2(ctx, &mut len, *argv.offset(i), 0); + if s.is_null() { + return miss; + } + let out = match core::str::from_utf8(core::slice::from_raw_parts(s as *const u8, len)) { + Ok(v) => f(v), + Err(_) => miss, + }; + JS_FreeCString(ctx, s); + out +} + +/// ui.svcOpen(app) -> bool (spec op 30): kick the WiFi transport, report +/// connection state (non-blocking; the app's connect pump retries). +unsafe extern "C" fn js_svc_open( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + let ok = with_str_arg(ctx, argc, argv, 0, false, |app| crate::svc::open(app)); + JS_NewBool(ctx, ok) +} + +/// ui.svcPoll() -> string | undefined (spec op 31). +unsafe extern "C" fn js_svc_poll( + ctx: *mut JSContext, + _this: JSValue, + _argc: i32, + _argv: *mut JSValue, +) -> JSValue { + match crate::svc::poll() { + Some(s) => JS_NewStringLen(ctx, s.as_ptr(), s.len()), + None => JS_UNDEFINED, + } +} + +/// ui.svcSend(line) (spec op 32). +unsafe extern "C" fn js_svc_send( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + if argc >= 1 { + let mut len: size_t = 0; + let s = JS_ToCStringLen2(ctx, &mut len, *argv.offset(0), 0); + if !s.is_null() { + crate::svc::send(core::slice::from_raw_parts(s as *const u8, len)); + JS_FreeCString(ctx, s); + } + } + JS_UNDEFINED +} + +/// ui.loadImgFile(path) -> handle | -1 (spec op 33): resolve a pushed side +/// file from the RAM cache into a texture. +unsafe extern "C" fn js_load_img_file( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + let handle = with_str_arg(ctx, argc, argv, 0, -1, |rel| { + match crate::svc::read_side_file(rel, pocketjs_core::spec::svc::IMG_MAX_BYTES) { + Some(blob) => ui().upload_img_entry(&blob), + None => -1, + } + }); + if handle >= 0 { + crate::graphics::register_texture(ui(), handle); + } + JS_NewInt32(ctx, handle) +} + +/// ui.videoOpen(path) -> bool (spec op 34): bind the announced RAM stream. +unsafe extern "C" fn js_video_open( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + let ok = with_str_arg(ctx, argc, argv, 0, false, |rel| crate::vid::open(ui(), rel)); + JS_NewBool(ctx, ok) +} + +/// ui.videoTick() -> frameIndex | -1 (spec op 35). +unsafe extern "C" fn js_video_tick( + ctx: *mut JSContext, + _this: JSValue, + _argc: i32, + _argv: *mut JSValue, +) -> JSValue { + JS_NewInt32(ctx, crate::vid::tick(ui())) +} + +/// ui.videoTexture() -> handle | -1 (spec op 36). +unsafe extern "C" fn js_video_texture( + ctx: *mut JSContext, + _this: JSValue, + _argc: i32, + _argv: *mut JSValue, +) -> JSValue { + JS_NewInt32(ctx, crate::vid::texture()) +} + +/// ui.videoClose() (spec op 37). +unsafe extern "C" fn js_video_close( + _ctx: *mut JSContext, + _this: JSValue, + _argc: i32, + _argv: *mut JSValue, +) -> JSValue { + crate::vid::close(ui()); + JS_UNDEFINED +} + +/// ui.debugStats() -> string (spec op 38): one JSON diagnostics snapshot. +unsafe extern "C" fn js_debug_stats( + ctx: *mut JSContext, + _this: JSValue, + _argc: i32, + _argv: *mut JSValue, +) -> JSValue { + let json = crate::stats::json(); + JS_NewStringLen(ctx, json.as_ptr(), json.len()) +} + /// ui.__dbgSend(line): append one JSON line to the outbound mailbox. unsafe extern "C" fn js_dbg_send( ctx: *mut JSContext, @@ -693,6 +834,18 @@ pub unsafe fn register( add_fn(ctx, ui_obj, b"__dbgPoll\0", js_dbg_poll, 0); add_fn(ctx, ui_obj, b"__dbgSend\0", js_dbg_send, 1); add_fn(ctx, ui_obj, b"__dbgShot\0", js_dbg_shot, 0); + // Host service channel over WiFi (spec ops 30..33; hosts/vita/src/net.rs). + add_fn(ctx, ui_obj, b"svcOpen\0", js_svc_open, 1); + add_fn(ctx, ui_obj, b"svcPoll\0", js_svc_poll, 0); + add_fn(ctx, ui_obj, b"svcSend\0", js_svc_send, 1); + add_fn(ctx, ui_obj, b"loadImgFile\0", js_load_img_file, 1); + // Video plane over the RAM .pkst ring (spec ops 34..37). + add_fn(ctx, ui_obj, b"videoOpen\0", js_video_open, 1); + add_fn(ctx, ui_obj, b"videoTick\0", js_video_tick, 0); + add_fn(ctx, ui_obj, b"videoTexture\0", js_video_texture, 0); + add_fn(ctx, ui_obj, b"videoClose\0", js_video_close, 0); + // Device diagnostics (spec op 38). + add_fn(ctx, ui_obj, b"debugStats\0", js_debug_stats, 0); // Framework-owned host identity. The bundle rejects a VPK assembled for a // different target or HostOps ABI before app code mounts. planHash is a diff --git a/hosts/vita/src/graphics.rs b/hosts/vita/src/graphics.rs index 9ef26732..bfab7222 100644 --- a/hosts/vita/src/graphics.rs +++ b/hosts/vita/src/graphics.rs @@ -272,6 +272,42 @@ unsafe fn resolve_texture(ui: &Ui, handle: i32) -> Option { textures().get(&handle).copied() } +/// Rewrite an existing GPU mirror's pixels in place from the core texture — +/// the per-frame video-plane path (register_texture would allocate + drain +/// GXM once per presented frame at 24 fps). Falls back to full registration +/// when no mirror exists or the size changed. Call inside the GPU-idle +/// window (right after begin_frame's ensure_rendering_done — the previous +/// scene has finished sampling, the next one reads the new pixels at swap). +pub fn update_texture_in_place(ui: &Ui, handle: i32) { + let Some(view) = ui.texture(handle) else { + return; + }; + unsafe { + let Some(existing) = textures().get(&handle).copied() else { + register_texture(ui, handle); + return; + }; + if existing.w != view.w || existing.h != view.h { + register_texture(ui, handle); + return; + } + let Some(rgba) = texture_rgba(view) else { + return; + }; + ensure_rendering_done(); + let stride = vita2d_texture_get_stride(existing.ptr) as usize; + let dst = vita2d_texture_get_datap(existing.ptr) as *mut u8; + let row_bytes = view.w as usize * 4; + for row in 0..view.h as usize { + core::ptr::copy_nonoverlapping( + rgba.as_ptr().add(row * row_bytes), + dst.add(row * stride), + row_bytes, + ); + } + } +} + pub fn free_texture(handle: i32) { unsafe { if let Some(texture) = textures().remove(&handle) { diff --git a/hosts/vita/src/lib.rs b/hosts/vita/src/lib.rs index f90ba9d1..f37dd606 100644 --- a/hosts/vita/src/lib.rs +++ b/hosts/vita/src/lib.rs @@ -9,11 +9,16 @@ use std::string::String; use libquickjs_sys::*; use pocketjs_core::Ui; +pub mod audio; pub mod dbg; pub mod ffi; pub mod graphics; pub mod input; +pub mod net; pub mod pak; +pub mod stats; +pub mod svc; +pub mod vid; extern "C" { fn JS_NewArray(ctx: *mut JSContext) -> JSValue; @@ -277,14 +282,18 @@ impl Runtime { } } - /// Render a standalone PocketJS frame into a new vita2d scene. + /// Render a standalone PocketJS frame into a new vita2d scene. The video + /// plane's staged frame commits here — begin_frame's rendering-done wait + /// is the GXM-idle window (the GE-race discipline, GXM edition). /// /// # Safety /// /// Call only on the Vita render thread with no scene already open. pub unsafe fn render(&mut self) { let (ptr, len) = self.draw_words(); - graphics::render(ffi::ui(), core::slice::from_raw_parts(ptr, len)); + graphics::begin_frame(0xff00_0000); + vid::present(ffi::ui()); + graphics::render_over(ffi::ui(), core::slice::from_raw_parts(ptr, len)); } /// Composite the PocketJS DrawList into the caller's open vita2d scene. diff --git a/hosts/vita/src/net.rs b/hosts/vita/src/net.rs new file mode 100644 index 00000000..d5154b42 --- /dev/null +++ b/hosts/vita/src/net.rs @@ -0,0 +1,466 @@ +//! WiFi transport for the svc mailbox (spec.ts "SVC WIRE protocol"). +//! +//! The Vita has no PSPLINK file share, so the mailbox + side files + .pkst +//! stream ride one TCP connection to the companion host, discovered through +//! its UDP beacon (or a `ux0:data/pocketjs/host.txt` override for broadcast- +//! hostile networks — one line, `192.168.x.y:8622`). +//! +//! Thread model — the 60 fps main thread NEVER blocks on the network: +//! +//! main thread supervisor thread tx thread +//! ─────────── ───────────────── ───────── +//! svcOpen ── spawn once ─▶ discovery loop +//! svcPoll ◀── line queue ◀─ TCP connect + handshake +//! svcSend ──▶ tx channel ───────────────────────────▶ write_all frames +//! loadImgFile ◀ file cache ◀ rx loop: CTRL → queue +//! videoTick ◀ RamStream ◀─ FILE → cache +//! SLOT/CHUNK/MARK → RamStream +//! PING → PONG via tx +//! on error: teardown, 1 s backoff, rediscover +//! +//! Main-thread ops only pop queues and take short-held mutexes (worst case +//! one ~130 KiB slot memcpy in videoTick). All raw sceNet FFI is confined to +//! `init()`; everything after rides std::net over the newlib socket shims. + +use std::collections::{HashMap, VecDeque}; +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpStream, UdpSocket}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::sync::Mutex; +use std::time::Duration; + +use pocketjs_core::spec::wire; +use pocketjs_core::stream_rx::RamStream; +use pocketjs_core::wire as wirecodec; + +use crate::stats; +use crate::vita_log; + +/// sceNet internal buffer pool — the homebrew-conventional 1 MiB. +const NET_POOL_BYTES: u32 = 1024 * 1024; +/// Side-file (thumbnail) cache budget. +const FILE_CACHE_BYTES: usize = 6 * 1024 * 1024; +/// Inbound ctrl lines held for svcPoll before the oldest are dropped. +const LINE_QUEUE_CAP: usize = 256; +const HOST_TXT: &str = "ux0:data/pocketjs/host.txt"; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +const READ_TIMEOUT: Duration = Duration::from_millis(250); +const BACKOFF: Duration = Duration::from_secs(1); + +// init() outcome: 0 = not tried, 1 = ok, 2 = failed (Vita3K stubs land here — +// svcOpen then stays false forever and the app sits on its connect screen). +static INIT_STATE: AtomicU8 = AtomicU8::new(0); +static SUPERVISOR_UP: AtomicBool = AtomicBool::new(false); +static CONNECTED: AtomicBool = AtomicBool::new(false); +static SHUTDOWN: AtomicBool = AtomicBool::new(false); + +static LINES: Mutex> = Mutex::new(VecDeque::new()); +static TX: Mutex>>> = Mutex::new(None); +static FILES: Mutex> = Mutex::new(None); +/// The announced stream path + its RAM ring image. Replaced wholesale on +/// every streamOpen; vid.rs binds to it at videoOpen. +static STREAM: Mutex> = Mutex::new(None); + +struct FileCache { + map: HashMap>, + order: VecDeque, + bytes: usize, +} + +impl FileCache { + fn new() -> Self { + Self { map: HashMap::new(), order: VecDeque::new(), bytes: 0 } + } + + fn insert(&mut self, path: String, blob: Vec) { + if let Some(old) = self.map.remove(&path) { + self.bytes -= old.len(); + self.order.retain(|p| p != &path); + } + self.bytes += blob.len(); + self.order.push_back(path.clone()); + self.map.insert(path, blob); + while self.bytes > FILE_CACHE_BYTES { + let Some(oldest) = self.order.pop_front() else { break }; + if let Some(gone) = self.map.remove(&oldest) { + self.bytes -= gone.len(); + stats::FILE_CACHE_EVICTS.fetch_add(1, Ordering::Relaxed); + } + } + } + + fn get(&self, path: &str) -> Option> { + self.map.get(path).cloned() + } +} + +/// Load the net modules and bring up sceNet/sceNetCtl. Once. Failure is +/// remembered and final (graceful: emulators without a stack keep the app on +/// its connect screen instead of crashing). +fn init() -> bool { + match INIT_STATE.load(Ordering::Acquire) { + 1 => return true, + 2 => return false, + _ => {} + } + let ok = unsafe { init_scenet() }; + INIT_STATE.store(if ok { 1 } else { 2 }, Ordering::Release); + if !ok { + vita_log(format_args!("[PocketJS net] sceNet init failed — svc stays offline")); + } + ok +} + +unsafe fn init_scenet() -> bool { + use vitasdk_sys::*; + // Static pool: sceNet keeps referencing it for the process lifetime. + static mut NET_POOL: [u8; NET_POOL_BYTES as usize] = [0; NET_POOL_BYTES as usize]; + let loaded = sceSysmoduleLoadModule(SCE_SYSMODULE_NET as _); + // 0 = ok; already-loaded is fine too (the error code differs per fw). + if loaded < 0 && sceSysmoduleIsLoaded(SCE_SYSMODULE_NET as _) != 0 { + return false; + } + let mut init = SceNetInitParam { + memory: NET_POOL.as_mut_ptr() as *mut core::ffi::c_void, + size: NET_POOL_BYTES as i32, + flags: 0, + }; + let rc = sceNetInit(&mut init); + // Already-initialized is success for our purposes. + if rc < 0 && rc != SCE_NET_ERROR_EBUSY as i32 { + return false; + } + // vitasdk-sys exports no NetCtl error constants; this is the documented + // SCE_NET_CTL_ERROR_NOT_TERMINATED ("already initialized") code. + const NETCTL_NOT_TERMINATED: u32 = 0x8041_2102; + let rc = sceNetCtlInit(); + if rc < 0 && rc as u32 != NETCTL_NOT_TERMINATED { + return false; + } + true +} + +/// Ensure the transport is coming up for `app`; report the live state. +/// Non-blocking — the app's connect-screen retry loop supplies the cadence. +pub fn open(app: &str) -> bool { + if !init() { + return false; + } + if !SUPERVISOR_UP.swap(true, Ordering::AcqRel) { + let app = app.to_owned(); + if std::thread::Builder::new() + .name("pjs-net".into()) + .stack_size(64 * 1024) + .spawn(move || supervisor(&app)) + .is_err() + { + SUPERVISOR_UP.store(false, Ordering::Release); + return false; + } + } + CONNECTED.load(Ordering::Acquire) +} + +pub fn connected() -> bool { + CONNECTED.load(Ordering::Acquire) +} + +/// Pop queued host→device lines, joined with trailing newlines, capped at +/// the svc poll buffer (the PSP batching contract). +pub fn poll_lines() -> Option { + let mut q = LINES.lock().ok()?; + if q.is_empty() { + return None; + } + let mut out = String::new(); + while let Some(front) = q.front() { + if !out.is_empty() && out.len() + front.len() + 1 > pocketjs_core::spec::svc::POLL_BUF { + break; + } + let line = q.pop_front().expect("front checked"); + out.push_str(&line); + out.push('\n'); + } + Some(out) +} + +/// Queue one device→host ctrl line. Silently dropped while disconnected +/// (the PSP transport's inactive no-op contract). +pub fn send_line(line: &[u8]) { + if line.len() > wire::MAX_PAYLOAD { + return; + } + let mut frame = vec![0u8; wire::HEADER_SIZE + line.len()]; + if !wirecodec::encode_frame_header(wire::MSG_CTRL, 0, line.len() as u32, &mut frame) { + return; + } + frame[wire::HEADER_SIZE..].copy_from_slice(line); + if let Ok(tx) = TX.lock() { + if let Some(tx) = tx.as_ref() { + let _ = tx.send(frame); + stats::NET_TX_BYTES.fetch_add((wire::HEADER_SIZE + line.len()) as u32, Ordering::Relaxed); + } + } +} + +/// Side-file lookup (loadImgFile): pushed FILE payloads, LRU-bounded. +pub fn side_file(path: &str, max: usize) -> Option> { + let files = FILES.lock().ok()?; + let blob = files.as_ref()?.get(path)?; + if blob.len() > max { + return None; + } + Some(blob) +} + +/// Run `f` over the current stream (path, ring) if one is open. +pub fn with_stream(f: impl FnOnce(&str, &RamStream) -> R) -> Option { + let guard = STREAM.lock().ok()?; + let (path, ram) = guard.as_ref()?; + Some(f(path, ram)) +} + +/// Drop guest-visible transport state on app switch (svc reset contract). +pub fn reset() { + if let Ok(mut q) = LINES.lock() { + q.clear(); + } + if let Ok(mut files) = FILES.lock() { + *files = None; + } + if let Ok(mut stream) = STREAM.lock() { + *stream = None; + } +} + +// --------------------------------------------------------------------------- +// supervisor +// --------------------------------------------------------------------------- + +fn supervisor(app: &str) { + while !SHUTDOWN.load(Ordering::Acquire) { + let Some(target) = discover(app) else { + std::thread::sleep(BACKOFF); + continue; + }; + match connect(app, target) { + Ok(stream) => { + vita_log(format_args!("[PocketJS net] connected to {target}")); + CONNECTED.store(true, Ordering::Release); + let why = serve(stream); + CONNECTED.store(false, Ordering::Release); + *TX.lock().unwrap_or_else(|e| e.into_inner()) = None; + stats::NET_RECONNECTS.fetch_add(1, Ordering::Relaxed); + vita_log(format_args!("[PocketJS net] disconnected: {why}")); + // A dead transport must not keep presenting a "live" stream. + if let Ok(mut guard) = STREAM.lock() { + if let Some((_, ram)) = guard.as_mut() { + ram.apply_mark(&wirecodec::StreamMarkMsg { epoch: u32::MAX, ended: true }); + } + } + } + Err(err) => { + vita_log(format_args!("[PocketJS net] connect {target}: {err}")); + } + } + std::thread::sleep(BACKOFF); + } +} + +/// `host.txt` override first, then one beacon-listen window. +fn discover(app: &str) -> Option { + if let Ok(text) = std::fs::read_to_string(HOST_TXT) { + if let Ok(addr) = text.trim().parse::() { + return Some(addr); + } + } + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, wire::BEACON_PORT)).ok()?; + socket.set_read_timeout(Some(Duration::from_secs(2))).ok()?; + let mut datagram = [0u8; 128]; + let deadline = std::time::Instant::now() + Duration::from_secs(4); + while std::time::Instant::now() < deadline { + let Ok((len, from)) = socket.recv_from(&mut datagram) else { + continue; + }; + let Some((port, beacon_app, _name)) = wirecodec::parse_beacon(&datagram[..len]) else { + continue; + }; + if beacon_app == app { + return Some(SocketAddr::new(from.ip(), port)); + } + } + None +} + +fn connect(app: &str, target: SocketAddr) -> std::io::Result { + let SocketAddr::V4(v4) = target else { + return Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "ipv4 only")); + }; + let stream = TcpStream::connect_timeout(&SocketAddr::V4(SocketAddrV4::new(*v4.ip(), v4.port())), CONNECT_TIMEOUT)?; + stream.set_nodelay(true)?; + stream.set_read_timeout(Some(READ_TIMEOUT))?; + let mut hello = [0u8; 80]; + let n = wirecodec::encode_hello(app, &mut hello) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "app id"))?; + let mut w = &stream; + w.write_all(&hello[..n])?; + let mut ack = [0u8; 8]; + read_full(&stream, &mut ack)?; + if wirecodec::parse_hello_ack(&ack) != Some(wire::VERSION) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "handshake")); + } + Ok(stream) +} + +/// read_exact across read-timeout ticks (each tick re-checks shutdown). +fn read_full(mut stream: &TcpStream, buf: &mut [u8]) -> std::io::Result<()> { + let mut got = 0usize; + while got < buf.len() { + if SHUTDOWN.load(Ordering::Acquire) { + return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "shutdown")); + } + match stream.read(&mut buf[got..]) { + Ok(0) => return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "eof")), + Ok(n) => got += n, + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + continue; + } + Err(e) => return Err(e), + } + } + stats::NET_RX_BYTES.fetch_add(buf.len() as u32, Ordering::Relaxed); + Ok(()) +} + +/// One connection's lifetime: spawn the tx thread, then run the rx loop +/// until an error. Returns the reason for the log. +fn serve(stream: TcpStream) -> String { + let (tx, rx): (Sender>, Receiver>) = channel(); + *TX.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx.clone()); + if let Ok(mut files) = FILES.lock() { + *files = Some(FileCache::new()); + } + let writer = match stream.try_clone() { + Ok(w) => w, + Err(e) => return format!("clone: {e}"), + }; + let tx_thread = std::thread::Builder::new() + .name("pjs-net-tx".into()) + .stack_size(32 * 1024) + .spawn(move || { + let mut writer = writer; + // Sender dropped (disconnect) ends the loop. + while let Ok(frame) = rx.recv() { + if writer.write_all(&frame).is_err() { + break; + } + } + }); + + let why = rx_loop(&stream, &tx); + // Dropping the TX sender ends the tx thread's recv loop. + *TX.lock().unwrap_or_else(|e| e.into_inner()) = None; + drop(tx); + if let Ok(handle) = tx_thread { + let _ = handle.join(); + } + why +} + +fn rx_loop(stream: &TcpStream, tx: &Sender>) -> String { + let mut header = [0u8; wire::HEADER_SIZE]; + let mut payload: Vec = Vec::new(); + loop { + if let Err(e) = read_full(stream, &mut header) { + return format!("header: {e}"); + } + let Some(frame) = wirecodec::parse_frame_header(&header) else { + return "bad frame header".into(); + }; + payload.resize(frame.len as usize, 0); + if let Err(e) = read_full(stream, &mut payload) { + return format!("payload: {e}"); + } + match frame.kind { + wire::MSG_PING => { + let mut pong = vec![0u8; wire::HEADER_SIZE + payload.len()]; + if wirecodec::encode_frame_header( + wire::MSG_PONG, + 0, + payload.len() as u32, + &mut pong, + ) { + pong[wire::HEADER_SIZE..].copy_from_slice(&payload); + let _ = tx.send(pong); + } + } + wire::MSG_CTRL => { + if let Ok(line) = core::str::from_utf8(&payload) { + if let Ok(mut q) = LINES.lock() { + if q.len() >= LINE_QUEUE_CAP { + q.pop_front(); + } + q.push_back(line.to_owned()); + } + } + } + wire::MSG_FILE => { + if let Some((path, blob)) = wirecodec::parse_file(&payload) { + if let Ok(mut files) = FILES.lock() { + if let Some(cache) = files.as_mut() { + cache.insert(path.to_owned(), blob.to_vec()); + } + } + } + } + wire::MSG_STREAM_OPEN => { + if let Some((path, block)) = wirecodec::parse_stream_open(&payload) { + if let Some(ram) = RamStream::open(block) { + if let Ok(mut stream_slot) = STREAM.lock() { + *stream_slot = Some((path.to_owned(), ram)); + } + } + } + } + wire::MSG_STREAM_CLOSE => { + if let Ok(mut stream_slot) = STREAM.lock() { + *stream_slot = None; + } + } + wire::MSG_VIDEO_SLOT => { + if let Some(msg) = wirecodec::parse_video_slot(&payload) { + if let Ok(mut stream_slot) = STREAM.lock() { + if let Some((_, ram)) = stream_slot.as_mut() { + if ram.apply_slot(&msg) { + stats::NET_SLOTS_RX.fetch_add(1, Ordering::Relaxed); + } + } + } + } + } + wire::MSG_AUDIO_CHUNK => { + if let Some(msg) = wirecodec::parse_audio_chunk(&payload) { + if let Ok(mut stream_slot) = STREAM.lock() { + if let Some((_, ram)) = stream_slot.as_mut() { + ram.apply_chunk(&msg); + } + } + } + } + wire::MSG_STREAM_MARK => { + if let Some(msg) = wirecodec::parse_stream_mark(&payload) { + if let Ok(mut stream_slot) = STREAM.lock() { + if let Some((_, ram)) = stream_slot.as_mut() { + ram.apply_mark(&msg); + } + } + } + } + _ => {} // forward-compatible: skip unknown types + } + } +} diff --git a/hosts/vita/src/stats.rs b/hosts/vita/src/stats.rs new file mode 100644 index 00000000..3d855be0 --- /dev/null +++ b/hosts/vita/src/stats.rs @@ -0,0 +1,54 @@ +//! Device diagnostic counters (spec OP.debugStats) — the PSP stats.rs shape +//! with a `net` section for the WiFi transport. Counters are u32 and +//! free-running; readers diff two snapshots over a known window. Field names +//! are the devtools wire contract — extend, never rename. + +use core::sync::atomic::{AtomicI32, AtomicU32, Ordering}; + +/// Starved 4 ms waits in the audio output thread (free-running while paused). +pub static AUDIO_STARVED: AtomicU32 = AtomicU32::new(0); +/// Most recent negative sceAudioOutOpenPort result (0 = never failed). +pub static AUDIO_LAST_RESERVE_ERR: AtomicI32 = AtomicI32::new(0); +/// Source sample frames accepted into the RAM ring. +pub static AUDIO_PUSHED_FRAMES: AtomicU32 = AtomicU32::new(0); + +/// Frames committed to the plane texture (on-screen fps numerator). +pub static VID_PRESENTED: AtomicU32 = AtomicU32::new(0); +/// Epoch resyncs observed (seek/resume discontinuities). +pub static VID_EPOCHS: AtomicU32 = AtomicU32::new(0); + +/// WIRE transport bytes in/out (wrap at 4 GiB; diff for throughput). +pub static NET_RX_BYTES: AtomicU32 = AtomicU32::new(0); +pub static NET_TX_BYTES: AtomicU32 = AtomicU32::new(0); +/// Completed connections that later dropped (reconnect count). +pub static NET_RECONNECTS: AtomicU32 = AtomicU32::new(0); +/// Video slots accepted into the RAM ring. +pub static NET_SLOTS_RX: AtomicU32 = AtomicU32::new(0); +/// Side-file cache LRU evictions. +pub static FILE_CACHE_EVICTS: AtomicU32 = AtomicU32::new(0); + +/// One JSON snapshot (the devtools "devStats" payload). +pub fn json() -> String { + let r = |c: &AtomicU32| c.load(Ordering::Relaxed); + format!( + concat!( + "{{\"app\":\"{}\",\"bundle\":\"{}\",", + "\"audio\":{{\"starved\":{},\"lastReserveErr\":{},\"pushedFrames\":{}}},", + "\"vid\":{{\"presented\":{},\"epochs\":{}}},", + "\"net\":{{\"rxBytes\":{},\"txBytes\":{},\"reconnects\":{},", + "\"slotsRx\":{},\"fileEvicts\":{}}}}}" + ), + env!("POCKETJS_APP_NAME"), + env!("POCKETJS_BUNDLE_HASH"), + r(&AUDIO_STARVED), + AUDIO_LAST_RESERVE_ERR.load(Ordering::Relaxed), + r(&AUDIO_PUSHED_FRAMES), + r(&VID_PRESENTED), + r(&VID_EPOCHS), + r(&NET_RX_BYTES), + r(&NET_TX_BYTES), + r(&NET_RECONNECTS), + r(&NET_SLOTS_RX), + r(&FILE_CACHE_EVICTS), + ) +} diff --git a/hosts/vita/src/svc.rs b/hosts/vita/src/svc.rs new file mode 100644 index 00000000..4d072e2a --- /dev/null +++ b/hosts/vita/src/svc.rs @@ -0,0 +1,55 @@ +//! Host service channel (spec ops 30..33) over the WiFi transport (net.rs). +//! +//! Same guest-visible semantics as the PSP's usbhostfs mailbox: svcOpen is a +//! non-blocking probe the app retries (the connect-screen pump supplies the +//! cadence), svcPoll drains complete host lines up to the poll buffer, +//! svcSend appends one line, and loadImgFile resolves a side file — here +//! from the RAM cache the host filled with proactive FILE pushes, so the +//! synchronous op never waits on the network. A cache miss returns None and +//! the app's per-frame retry loop absorbs it. + +use crate::net; + +/// Path validation, verbatim from the PSP transport: svc-relative only. +fn valid_rel(rel: &str) -> bool { + !rel.is_empty() + && !rel.starts_with('/') + && !rel.contains("..") + && !rel.contains(':') + && rel.len() <= 160 +} + +/// spec op 30: kick the transport for `app`, report connection state. +pub fn open(app: &str) -> bool { + if app.is_empty() || app.len() > 64 { + return false; + } + net::open(app) +} + +pub fn active() -> bool { + net::connected() +} + +/// spec op 31: new complete host lines (batched, newline-joined). +pub fn poll() -> Option { + net::poll_lines() +} + +/// spec op 32: append one device line to the outbox. +pub fn send(line: &[u8]) { + net::send_line(line); +} + +/// spec op 33 backing read: a side file from the push cache. +pub fn read_side_file(rel: &str, max: usize) -> Option> { + if !valid_rel(rel) { + return None; + } + net::side_file(rel, max) +} + +/// Guest-switch reset (the PSP svc contract). +pub fn reset() { + net::reset(); +} diff --git a/hosts/vita/src/vid.rs b/hosts/vita/src/vid.rs new file mode 100644 index 00000000..db8f4115 --- /dev/null +++ b/hosts/vita/src/vid.rs @@ -0,0 +1,208 @@ +//! The video plane (spec ops 34..37) over the RAM `.pkst` ring (net.rs). +//! +//! Identical reader semantics to the PSP's file-backed vid.rs — the ring +//! image IS a `.pkst` file image (stream_rx.rs), parsed by the same +//! pocketjs_core::stream readers — minus the IO budget: "reads" are memcpys +//! under a short-held mutex, so a whole slot stages in one tick. +//! +//! The GXM discipline mirrors the PSP's GE one: videoTick only STAGES a +//! validated frame; present() commits it to the plane texture inside the +//! GPU-idle window (Runtime::render calls it right after begin_frame, whose +//! ensure_rendering_done() guarantees the previous scene finished sampling). + +use pocketjs_core::spec::stream as st; +use pocketjs_core::spec::{img, psm}; +use pocketjs_core::stream::{ + chunk_offset, parse_chunk_header, parse_header_block, parse_slot_header, slot_offset, + StreamHeaders, +}; +use pocketjs_core::Ui; + +use crate::{audio, graphics, net, stats}; + +/// Whole audio chunks copied per tick (the RAM ring absorbs jitter). +const AUDIO_CHUNKS_PER_TICK: u32 = 4; + +struct Session { + /// Geometry pinned at open; per-tick reads refresh cursors/epoch/flags. + geo: StreamHeaders, + epoch: u32, + /// The plane texture (core handle, PSM_T8 w x h, linear). + tex: i32, + /// palette + indices of a validated frame awaiting the GPU-idle window. + staging: Vec, + presented_seq: u32, + presented_frame: i32, + staged_frame: i32, + staged_seq: u32, + pending: bool, + /// Next audio chunk seq to copy; 0 = sync to the writer's tail first. + audio_next: u32, + audio_on: bool, +} + +static mut SESSION: Option = None; + +/// spec op 34: bind to the announced RAM stream. The single ordered +/// connection guarantees streamOpen preceded the ctrl line that carried +/// `rel`, so the first try succeeds (the app does not retry videoOpen). +pub unsafe fn open(ui: &mut Ui, rel: &str) -> bool { + close(ui); + let Some(geo) = net::with_stream(|path, ram| { + if path != rel { + crate::vita_log(format_args!("[PocketJS vid] open {rel} vs announced {path}")); + } + parse_header_block(ram.buf()) + }) + .flatten() else { + return false; + }; + let (w, h) = (geo.video.w, geo.video.h); + let px = (w * h) as usize; + // Opaque black start (palette entry 0); linear — the plane draws scaled. + let mut init = vec![0u8; 1024 + px]; + init[3] = 0xff; + let tex = ui.upload_texture_flags(&init, w, h, psm::PSM_T8, img::FLAG_LINEAR); + if tex < 0 { + return false; + } + let audio_on = audio::start(geo.audio.sample_rate); + SESSION = Some(Session { + epoch: geo.epoch, + geo, + tex, + staging: vec![0u8; 1024 + px], + presented_seq: 0, + presented_frame: -1, + staged_frame: -1, + staged_seq: 0, + pending: false, + audio_next: 0, + audio_on, + }); + true +} + +/// spec op 36. +pub unsafe fn texture() -> i32 { + SESSION.as_ref().map_or(-1, |s| s.tex) +} + +/// spec op 35: the per-frame pump. Returns the presented source frame +/// index, -1 before the first one. +pub unsafe fn tick(_ui: &mut Ui) -> i32 { + use core::sync::atomic::Ordering::Relaxed; + let Some(s) = SESSION.as_mut() else { return -1 }; + + let staged = net::with_stream(|_path, ram| { + let buf = ram.buf(); + let Some(now) = parse_header_block(buf) else { + return false; + }; + + // Discontinuity: drop queued audio + staged frame, resync to tail + // (same reasoning as the PSP path — seq gates rewind on seeks). + if now.epoch != s.epoch { + s.epoch = now.epoch; + s.presented_seq = 0; + s.pending = false; + s.audio_next = 0; + audio::flush(); + stats::VID_EPOCHS.fetch_add(1, Relaxed); + } + + // Audio top-up: whole chunks straight out of the ring image. + if s.audio_on { + let a = &now.audio; + if s.audio_next == 0 && a.latest_seq > 0 { + s.audio_next = a.latest_seq.saturating_sub(1).max(1); + } + if s.audio_next != 0 && a.latest_seq >= s.audio_next.saturating_add(a.chunk_count) { + s.audio_next = a.latest_seq; // lapped: rejoin at the tail + audio::flush(); + } + for _ in 0..AUDIO_CHUNKS_PER_TICK { + if s.audio_next == 0 + || s.audio_next > a.latest_seq + || audio::free_frames() < a.chunk_frames as usize + { + break; + } + let Some(off) = chunk_offset(&now, s.audio_next) else { break }; + let off = off as usize; + match parse_chunk_header(&buf[off..]) { + Some(ch) if ch.seq == s.audio_next => { + let pcm_at = off + st::CHUNK_HEADER_SIZE; + let samples = (a.chunk_frames * a.channels) as usize; + let pcm = core::slice::from_raw_parts( + buf.as_ptr().add(pcm_at) as *const i16, + samples, + ); + audio::push(pcm, a.channels); + s.audio_next += 1; + } + _ => { + s.audio_next = 0; + break; + } + } + } + } + + // Video: stage the newest unpresented slot (one memcpy; no torn + // reads over TCP, but the seq/geometry validation stays — it is the + // shared contract, and epoch races remain real). + let v = &now.video; + if s.pending || v.latest_seq == 0 || v.latest_seq <= s.presented_seq { + return false; + } + let seq = v.latest_seq; + let Some(off) = slot_offset(&now, seq) else { return false }; + let off = off as usize; + let Some(sh) = parse_slot_header(&buf[off..], &s.geo.video) else { + return false; + }; + if sh.seq != seq { + return false; + } + let payload = &buf[off + st::SLOT_HEADER_SIZE..off + st::SLOT_HEADER_SIZE + s.staging.len()]; + s.staging.copy_from_slice(payload); + s.staged_seq = seq; + s.staged_frame = sh.frame_index as i32; + true + }) + .unwrap_or(false); + + if staged { + s.pending = true; + } + s.presented_frame +} + +/// Commit the staged frame to the plane texture. MUST run in the GPU-idle +/// window (Runtime::render invokes it right after begin_frame). +pub unsafe fn present(ui: &mut Ui) { + let Some(s) = SESSION.as_mut() else { return }; + if !s.pending { + return; + } + s.pending = false; + let pal = &s.staging[..1024]; + let px = &s.staging[1024..]; + if ui.update_texture_t8(s.tex, pal, px) { + // Rewrite the mirrored vita2d texture in place — the recycle path + // would allocate+drain GXM every frame at 24 fps. + graphics::update_texture_in_place(ui, s.tex); + s.presented_seq = s.staged_seq; + s.presented_frame = s.staged_frame; + stats::VID_PRESENTED.fetch_add(1, core::sync::atomic::Ordering::Relaxed); + } +} + +/// spec op 37: stop audio, free the plane (core + GPU mirror). +pub unsafe fn close(ui: &mut Ui) { + let Some(s) = SESSION.take() else { return }; + audio::stop(); + ui.free_texture(s.tex); + graphics::free_texture(s.tex); +} From 64e68665db1d15517aaa74e634d89040a66ef236 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:54:08 +0800 Subject: [PATCH 2/2] fix(vita): a stale host.txt falls back to beacon discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First hardware bring-up caught it immediately: the Mac's DHCP lease moved, the ux0:data/pocketjs/host.txt override pointed at a dead address, and because the override took precedence unconditionally the transport retried it forever — beacon discovery could never rescue the session. Connect failures now alternate the next discovery round onto the beacon listener, so a stale override degrades to broadcast discovery instead of wedging. Co-Authored-By: Claude Fable 5 --- hosts/vita/src/net.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/hosts/vita/src/net.rs b/hosts/vita/src/net.rs index d5154b42..3632d3d4 100644 --- a/hosts/vita/src/net.rs +++ b/hosts/vita/src/net.rs @@ -239,8 +239,13 @@ pub fn reset() { // --------------------------------------------------------------------------- fn supervisor(app: &str) { + // host.txt failures round-robin into beacon discovery: a STALE override + // (the Mac's DHCP lease moved — observed on first hardware bring-up) + // must degrade to broadcast discovery, never wedge the transport. + let mut skip_host_txt = false; while !SHUTDOWN.load(Ordering::Acquire) { - let Some(target) = discover(app) else { + let Some(target) = discover(app, skip_host_txt) else { + skip_host_txt = false; std::thread::sleep(BACKOFF); continue; }; @@ -262,17 +267,22 @@ fn supervisor(app: &str) { } Err(err) => { vita_log(format_args!("[PocketJS net] connect {target}: {err}")); + // If that came from host.txt, try the beacon next round. + skip_host_txt = !skip_host_txt; } } std::thread::sleep(BACKOFF); } } -/// `host.txt` override first, then one beacon-listen window. -fn discover(app: &str) -> Option { - if let Ok(text) = std::fs::read_to_string(HOST_TXT) { - if let Ok(addr) = text.trim().parse::() { - return Some(addr); +/// `host.txt` override first (unless the last override attempt failed to +/// connect), then one beacon-listen window. +fn discover(app: &str, skip_host_txt: bool) -> Option { + if !skip_host_txt { + if let Ok(text) = std::fs::read_to_string(HOST_TXT) { + if let Ok(addr) = text.trim().parse::() { + return Some(addr); + } } } let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, wire::BEACON_PORT)).ok()?;