Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion hosts/vita/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
16 changes: 15 additions & 1 deletion hosts/vita/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
198 changes: 198 additions & 0 deletions hosts/vita/src/audio.rs
Original file line number Diff line number Diff line change
@@ -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<usize> {
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);
}
153 changes: 153 additions & 0 deletions hosts/vita/src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<R>(
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,
Expand Down Expand Up @@ -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
Expand Down
Loading