Skip to content

Commit 1a919ec

Browse files
AndrewAltimitAI Agent BotclaudeAI Review Agent
authored
fix: PSP 60fps rendering pipeline + EBOOT XMB assets (#72)
* fix: PSP 60fps rendering pipeline + EBOOT XMB assets Fix frame-doubling (60→30fps) in PSP Desktop mode caused by three compounding bottlenecks: 1. swap_buffers pipeline: reorder sceGuSync after sceDisplayWaitVblankStart so the GE renders the display list in parallel with the vsync wait instead of stalling sequentially on both. 2. SDI draw overhead: replace 108 dashboard SDI objects (HashMap lookups, z-sort, prefix filtering per frame) with direct GU draw calls via chrome::draw_dashboard(). Add allocation-free draw_with_clips_noalloc() to WindowManager using stack-buffer SDI name formatting and closure-based filtering instead of Vec<String> allocations. 3. Kernel syscall throttling: rate-limit StatusBarInfo::poll() (6+ kernel calls) and sceKernelTotalFreeMemSize/MaxFreeMemSize (heap walks) to ~4Hz instead of 60Hz. Replace format!() heap allocations in sysmon, music, settings, and network windows with stack-buffer formatting. Also add EBOOT XMB assets (Psp.toml + ICON0.PNG + PIC1.PNG) so the homebrew shows an icon and background in the PSP menu. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve doc link for draw_with_clips_noalloc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address AI review feedback (iteration 1) Automated fix by Claude in response to Gemini/Codex review. Iteration: 1/5 Co-Authored-By: AI Review Agent <noreply@anthropic.com> --------- Co-authored-by: AI Agent Bot <ai-agent@localhost> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: AI Review Agent <ai-review-agent@localhost>
1 parent 5fde545 commit 1a919ec

File tree

9 files changed

+268
-43
lines changed

9 files changed

+268
-43
lines changed

crates/oasis-backend-psp/Psp.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
title = "OASIS OS"
2+
xmb_icon_png = "assets/ICON0.PNG"
3+
xmb_background_png = "assets/PIC1.PNG"
27.1 KB
Loading
247 KB
Loading

crates/oasis-backend-psp/src/desktop.rs

Lines changed: 62 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -267,14 +267,13 @@ pub(crate) fn draw_music_windowed(
267267
if audio.is_playing() {
268268
let center_x = cx + cw as i32 / 2;
269269
be.draw_text(file_name, cx + 4, cy + 4, 8, Color::rgb(255, 200, 200))?;
270-
let info = format!(
271-
"{}Hz {}kbps {}ch",
272-
audio.sample_rate(),
273-
audio.bitrate(),
274-
audio.channels(),
270+
let mut buf = [0u8; 64];
271+
let info = stack_fmt(
272+
&mut buf,
273+
format_args!("{}Hz {}kbps {}ch", audio.sample_rate(), audio.bitrate(), audio.channels()),
275274
);
276275
let info_x = center_x - (info.len() as i32 * 8) / 2;
277-
be.draw_text(&info, info_x, cy + 18, 8, Color::rgb(180, 180, 180))?;
276+
be.draw_text(info, info_x, cy + 18, 8, Color::rgb(180, 180, 180))?;
278277
let status = if audio.is_paused() {
279278
"PAUSED"
280279
} else {
@@ -317,13 +316,14 @@ pub(crate) fn draw_settings_windowed(
317316
let val = Color::WHITE;
318317
let mut y = cy + 16;
319318
let vx = cx + 110;
319+
let mut buf = [0u8; 64];
320320

321321
be.draw_text("CPU Clock:", cx + 4, y, 8, lbl)?;
322-
be.draw_text(&format!("{} MHz", clock_mhz), vx, y, 8, val)?;
322+
be.draw_text(stack_fmt(&mut buf, format_args!("{} MHz", clock_mhz)), vx, y, 8, val)?;
323323
y += 10;
324324

325325
be.draw_text("Bus Clock:", cx + 4, y, 8, lbl)?;
326-
be.draw_text(&format!("{} MHz", bus_mhz), vx, y, 8, val)?;
326+
be.draw_text(stack_fmt(&mut buf, format_args!("{} MHz", bus_mhz)), vx, y, 8, val)?;
327327
y += 10;
328328

329329
let profile = match clock_mhz {
@@ -344,7 +344,10 @@ pub(crate) fn draw_settings_windowed(
344344
let used_kb = (total - remaining) / 1024;
345345
let total_kb = total / 1024;
346346
be.draw_text("Tex Cache:", cx + 4, y, 8, lbl)?;
347-
be.draw_text(&format!("{}/{} KB", used_kb, total_kb), vx, y, 8, val)?;
347+
be.draw_text(
348+
stack_fmt(&mut buf, format_args!("{}/{} KB", used_kb, total_kb)),
349+
vx, y, 8, val,
350+
)?;
348351
} else {
349352
be.draw_text("Tex Cache:", cx + 4, y, 8, lbl)?;
350353
be.draw_text("N/A (PSP-1000)", vx, y, 8, Color::rgb(140, 140, 140))?;
@@ -397,9 +400,10 @@ pub(crate) fn draw_network_windowed(
397400
y += 10;
398401

399402
if status.battery_percent >= 0 {
403+
let mut buf = [0u8; 64];
400404
be.draw_text("Battery:", cx + 4, y, 8, lbl)?;
401405
be.draw_text(
402-
&format!("{}%", status.battery_percent),
406+
stack_fmt(&mut buf, format_args!("{}%", status.battery_percent)),
403407
vx,
404408
y,
405409
8,
@@ -439,6 +443,9 @@ pub(crate) fn draw_sysmon_windowed(
439443
let mut y = cy + 16;
440444
let vx = cx + 100;
441445

446+
// Use a stack buffer to avoid heap allocations from format!() every frame.
447+
let mut buf = [0u8; 64];
448+
442449
let fps_clr = if fps >= 55.0 {
443450
Color::rgb(120, 255, 120)
444451
} else if fps >= 30.0 {
@@ -447,32 +454,34 @@ pub(crate) fn draw_sysmon_windowed(
447454
Color::rgb(255, 80, 80)
448455
};
449456
be.draw_text("FPS:", cx + 4, y, 8, lbl)?;
450-
be.draw_text(&format!("{:.1}", fps), vx, y, 8, fps_clr)?;
457+
let s = stack_fmt(&mut buf, format_args!("{:.1}", fps));
458+
be.draw_text(s, vx, y, 8, fps_clr)?;
451459
y += 11;
452460

453461
be.draw_text("CPU/Bus/ME:", cx + 4, y, 8, lbl)?;
454-
be.draw_text(
455-
&format!("{}/{}/{}", sysinfo.cpu_mhz, sysinfo.bus_mhz, sysinfo.me_mhz),
456-
vx,
457-
y,
458-
8,
459-
val,
460-
)?;
462+
let s = stack_fmt(
463+
&mut buf,
464+
format_args!("{}/{}/{}", sysinfo.cpu_mhz, sysinfo.bus_mhz, sysinfo.me_mhz),
465+
);
466+
be.draw_text(s, vx, y, 8, val)?;
461467
y += 11;
462468

463469
be.draw_text("Free RAM:", cx + 4, y, 8, lbl)?;
464-
be.draw_text(&format!("{} KB", free_kb), vx, y, 8, val)?;
470+
let s = stack_fmt(&mut buf, format_args!("{} KB", free_kb));
471+
be.draw_text(s, vx, y, 8, val)?;
465472
y += 11;
466473

467474
be.draw_text("Max Block:", cx + 4, y, 8, lbl)?;
468-
be.draw_text(&format!("{} KB", max_blk_kb), vx, y, 8, val)?;
475+
let s = stack_fmt(&mut buf, format_args!("{} KB", max_blk_kb));
476+
be.draw_text(s, vx, y, 8, val)?;
469477
y += 11;
470478

471479
if let Some((total, remaining)) = vol_info {
472480
let used_kb = (total - remaining) / 1024;
473481
let total_kb = total / 1024;
474482
be.draw_text("Tex VRAM:", cx + 4, y, 8, lbl)?;
475-
be.draw_text(&format!("{}/{} KB", used_kb, total_kb), vx, y, 8, val)?;
483+
let s = stack_fmt(&mut buf, format_args!("{}/{} KB", used_kb, total_kb));
484+
be.draw_text(s, vx, y, 8, val)?;
476485
y += 11;
477486
}
478487

@@ -483,19 +492,19 @@ pub(crate) fn draw_sysmon_windowed(
483492
} else {
484493
Color::rgb(255, 80, 80)
485494
};
486-
let bat_str = if status.battery_percent >= 0 {
495+
be.draw_text("Battery:", cx + 4, y, 8, lbl)?;
496+
let bat_s = if status.battery_percent >= 0 {
487497
if status.battery_charging {
488-
format!("{}% CHG", status.battery_percent)
498+
stack_fmt(&mut buf, format_args!("{}% CHG", status.battery_percent))
489499
} else {
490-
format!("{}%", status.battery_percent)
500+
stack_fmt(&mut buf, format_args!("{}%", status.battery_percent))
491501
}
492502
} else if status.ac_power {
493-
"AC".into()
503+
"AC"
494504
} else {
495-
"N/A".into()
505+
"N/A"
496506
};
497-
be.draw_text("Battery:", cx + 4, y, 8, lbl)?;
498-
be.draw_text(&bat_str, vx, y, 8, bat_clr)?;
507+
be.draw_text(bat_s, vx, y, 8, bat_clr)?;
499508
y += 11;
500509

501510
let wifi_str = if status.wifi_on { "ON" } else { "OFF" };
@@ -606,6 +615,31 @@ pub(crate) fn draw_radio_windowed(
606615
// Loading indicator
607616
// ---------------------------------------------------------------------------
608617

618+
/// Format into a stack buffer, returning a `&str`. Avoids heap allocation.
619+
fn stack_fmt<'a>(buf: &'a mut [u8; 64], args: core::fmt::Arguments<'_>) -> &'a str {
620+
use core::fmt::Write;
621+
622+
struct BufWriter<'b> {
623+
buf: &'b mut [u8],
624+
pos: usize,
625+
}
626+
impl core::fmt::Write for BufWriter<'_> {
627+
fn write_str(&mut self, s: &str) -> core::fmt::Result {
628+
let bytes = s.as_bytes();
629+
let avail = self.buf.len() - self.pos;
630+
// Avoid splitting a multi-byte UTF-8 character at the boundary.
631+
let len = s.floor_char_boundary(bytes.len().min(avail));
632+
self.buf[self.pos..self.pos + len].copy_from_slice(&bytes[..len]);
633+
self.pos += len;
634+
Ok(())
635+
}
636+
}
637+
let mut w = BufWriter { buf: &mut buf[..], pos: 0 };
638+
let _ = w.write_fmt(args);
639+
let n = w.pos;
640+
core::str::from_utf8(&buf[..n]).unwrap_or("???")
641+
}
642+
609643
pub(crate) fn draw_loading_indicator(backend: &mut PspBackend, msg: &str) {
610644
let bg = Color::rgba(0, 0, 0, 200);
611645
backend.fill_rect_inner(0, CONTENT_TOP as i32, SCREEN_WIDTH, CONTENT_H, bg);

crates/oasis-backend-psp/src/lib.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,13 +452,21 @@ impl PspBackend {
452452

453453
/// Finalize the current display list, swap buffers, and open the next
454454
/// frame.
455+
///
456+
/// The GE renders the display list asynchronously after `sceGuFinish`.
457+
/// By waiting for vsync *before* blocking on `sceGuSync`, the GE
458+
/// executes in parallel with the vsync wait instead of sequentially,
459+
/// preventing frame-doubling (60→30fps) when the display list is heavy
460+
/// (e.g. window dragging, music playback bus contention).
455461
pub fn swap_buffers_inner(&mut self) {
456462
// SAFETY: GU frame lifecycle calls. DISPLAY_LIST is exclusively
457463
// accessed from the single-threaded main loop (init/swap_buffers).
458464
unsafe {
459465
sys::sceGuFinish();
460-
sys::sceGuSync(GuSyncMode::Finish, GuSyncBehavior::Wait);
466+
// Vsync first: GE renders in parallel while CPU waits for vblank.
461467
sys::sceDisplayWaitVblankStart();
468+
// GE is likely done by now; block only if it isn't.
469+
sys::sceGuSync(GuSyncMode::Finish, GuSyncBehavior::Wait);
462470
sys::sceGuSwapBuffers();
463471
sys::sceGuStart(GuContextType::Direct, &raw mut DISPLAY_LIST as *mut c_void);
464472
}

crates/oasis-backend-psp/src/main.rs

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,11 @@ fn psp_main() {
289289
dbg_log("[EBOOT] entering main loop");
290290
psp::thread::sleep_ms(400);
291291

292+
// Cached values for expensive kernel queries (throttled to ~4Hz).
293+
let mut cached_status = StatusBarInfo::poll();
294+
let mut cached_free_kb: i32 = 0;
295+
let mut cached_max_blk_kb: i32 = 0;
296+
292297
loop {
293298
let _dt = frame_timer.tick();
294299
// Log first frame only.
@@ -420,7 +425,12 @@ fn psp_main() {
420425
}
421426

422427
// -- Render --
423-
let status = StatusBarInfo::poll();
428+
// Throttle expensive kernel syscalls to every 15th frame (~4Hz at 60fps).
429+
// StatusBarInfo::poll() issues 6+ kernel calls (battery, RTC, USB, WiFi).
430+
if viz_frame % 15 == 0 {
431+
cached_status = StatusBarInfo::poll();
432+
}
433+
let status = cached_status;
424434
let fps = frame_timer.fps();
425435
let usb_active = usb_storage.is_some();
426436

@@ -492,13 +502,32 @@ fn psp_main() {
492502
},
493503

494504
AppMode::Desktop => {
495-
// Show dashboard icons behind windows in Desktop mode.
505+
// Always hide SDI dashboard icons in Desktop mode -- drawing
506+
// ~108 SDI objects (HashMap lookups + z-sort + prefix filter)
507+
// eats the frame budget. Instead, draw icons directly via the
508+
// backend which is much cheaper (direct GU calls, no SDI overhead).
509+
dashboard::hide_dashboard_sdi(&mut dashboard_state, &mut sdi);
510+
terminal_sdi::set_terminal_visible(&mut sdi, false);
511+
512+
// Draw desktop icons directly (bypasses SDI entirely).
496513
if !icons_hidden {
497-
dashboard::show_dashboard_sdi(&mut dashboard_state, &mut sdi, &active_theme);
498-
} else {
499-
dashboard::hide_dashboard_sdi(&mut dashboard_state, &mut sdi);
514+
chrome::draw_dashboard(
515+
&mut backend,
516+
dashboard_state.selected,
517+
dashboard_state.page,
518+
viz_frame,
519+
);
520+
}
521+
522+
// Throttle kernel heap queries (~4Hz). These walk kernel
523+
// memory structures and are expensive on every frame.
524+
if viz_frame % 15 == 0 {
525+
// SAFETY: scalar FFI returning available memory stats.
526+
unsafe {
527+
cached_free_kb = psp::sys::sceKernelTotalFreeMemSize() as i32 / 1024;
528+
cached_max_blk_kb = psp::sys::sceKernelMaxFreeMemSize() as i32 / 1024;
529+
}
500530
}
501-
terminal_sdi::set_terminal_visible(&mut sdi, false);
502531

503532
render_desktop(
504533
&mut backend,
@@ -509,6 +538,8 @@ fn psp_main() {
509538
&sysinfo,
510539
fps,
511540
usb_active,
541+
cached_free_kb,
542+
cached_max_blk_kb,
512543
&term,
513544
&fm,
514545
&pv,
@@ -1008,6 +1039,8 @@ fn render_desktop(
10081039
sysinfo: &SystemInfo,
10091040
fps: f32,
10101041
usb_active: bool,
1042+
free_kb: i32,
1043+
max_blk_kb: i32,
10111044
term: &TerminalState,
10121045
fm: &FileManagerState,
10131046
pv: &PhotoViewerState,
@@ -1018,16 +1051,9 @@ fn render_desktop(
10181051
let settings_clock = config.get_i32("clock_mhz").unwrap_or(333);
10191052
let settings_bus = config.get_i32("bus_mhz").unwrap_or(166);
10201053
let current_vol = backend.volatile_mem_info();
1021-
// SAFETY: scalar FFI returning available memory stats.
1022-
let (free_kb, max_blk_kb) = unsafe {
1023-
(
1024-
psp::sys::sceKernelTotalFreeMemSize() as i32 / 1024,
1025-
psp::sys::sceKernelMaxFreeMemSize() as i32 / 1024,
1026-
)
1027-
};
10281054

10291055
backend.force_bitmap_font = true;
1030-
let _ = wm.draw_with_clips(
1056+
let _ = wm.draw_with_clips_noalloc(
10311057
sdi,
10321058
backend,
10331059
|window_id, cx, cy, cw, ch, be| match window_id {

crates/oasis-backend-psp/src/status.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const MONTH_NAMES: [&str; 12] = [
6363
];
6464

6565
/// Dynamic status info polled each frame (or periodically).
66+
#[derive(Clone, Copy)]
6667
pub struct StatusBarInfo {
6768
/// Battery charge percentage (0-100), or -1 if no battery.
6869
pub battery_percent: i32,

crates/oasis-sdi/src/registry.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,47 @@ impl SdiRegistry {
382382
Ok(())
383383
}
384384

385+
/// Draw only the base layer, keeping objects for which `keep` returns true.
386+
///
387+
/// Allocation-free alternative to `draw_base_excluding_prefixes`.
388+
pub fn draw_base_filtered<F>(&mut self, backend: &mut dyn SdiBackend, keep: F) -> Result<()>
389+
where
390+
F: Fn(&str) -> bool,
391+
{
392+
self.ensure_z_sorted();
393+
for name in &self.z_sorted_base {
394+
let obj = &self.objects[name];
395+
if !obj.visible || obj.alpha == 0 {
396+
continue;
397+
}
398+
if !keep(name) {
399+
continue;
400+
}
401+
Self::draw_object(obj, backend)?;
402+
}
403+
Ok(())
404+
}
405+
406+
/// Draw only the overlay layer, keeping objects for which `keep` returns true.
407+
///
408+
/// Allocation-free alternative to `draw_overlay_excluding_prefixes`.
409+
pub fn draw_overlay_filtered<F>(&self, backend: &mut dyn SdiBackend, keep: F) -> Result<()>
410+
where
411+
F: Fn(&str) -> bool,
412+
{
413+
for name in &self.z_sorted_overlay {
414+
let obj = &self.objects[name];
415+
if !obj.visible || obj.alpha == 0 {
416+
continue;
417+
}
418+
if !keep(name) {
419+
continue;
420+
}
421+
Self::draw_object(obj, backend)?;
422+
}
423+
Ok(())
424+
}
425+
385426
/// Render a single SDI object to the backend.
386427
///
387428
/// Dispatch order for non-textured objects with nonzero area:

0 commit comments

Comments
 (0)