986 lines
37 KiB
Rust
986 lines
37 KiB
Rust
use crate::audio::HostAudio;
|
|
use crate::debugger::HostDebugger;
|
|
use crate::fs_backend::HostDirBackend;
|
|
use crate::input::HostInputHandler;
|
|
use crate::log_sink::HostConsoleSink;
|
|
use crate::stats::HostStats;
|
|
use crate::utilities::{draw_owned_rgba8888_frame_to_rgba8, draw_rgba8888_to_rgba8};
|
|
use pixels::wgpu::PresentMode;
|
|
use pixels::{Pixels, PixelsBuilder, SurfaceTexture};
|
|
use prometeu_drivers::hardware::Hardware;
|
|
use prometeu_drivers::{AudioCommand, LocalFramebufferRenderBackend, MemoryBanks};
|
|
use prometeu_firmware::{BootTarget, Firmware, FirmwareState};
|
|
use prometeu_hal::telemetry::CertificationConfig;
|
|
use prometeu_hal::{OwnedRgba8888Frame, RenderOwnership, RuntimePlatform};
|
|
use prometeu_system::{LatestRenderFrameStore, RenderWorkerConfig, discover_games_root};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use winit::application::ApplicationHandler;
|
|
use winit::dpi::LogicalSize;
|
|
use winit::event::{ElementState, WindowEvent};
|
|
use winit::event_loop::{ActiveEventLoop, ControlFlow};
|
|
use winit::keyboard::{KeyCode, PhysicalKey};
|
|
use winit::window::{Window, WindowAttributes, WindowId};
|
|
|
|
const IDLE_HOST_POLL_DT: Duration = Duration::from_millis(100);
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct PresentationState {
|
|
latest_published_frame: Option<PresentationFrameMarker>,
|
|
last_presented_frame: Option<PresentationFrameMarker>,
|
|
host_invalidated: bool,
|
|
redraw_requested: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct PresentationFrameMarker {
|
|
ownership: RenderOwnership,
|
|
frame_id: u64,
|
|
}
|
|
|
|
impl Default for PresentationState {
|
|
fn default() -> Self {
|
|
Self {
|
|
latest_published_frame: None,
|
|
last_presented_frame: None,
|
|
host_invalidated: true,
|
|
redraw_requested: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PresentationState {
|
|
fn note_published_frame(&mut self, ownership: RenderOwnership, frame_id: u64) {
|
|
let marker = PresentationFrameMarker { ownership, frame_id };
|
|
if self.latest_published_frame != Some(marker) {
|
|
self.latest_published_frame = Some(marker);
|
|
}
|
|
}
|
|
|
|
fn invalidate_host_surface(&mut self) {
|
|
self.host_invalidated = true;
|
|
}
|
|
|
|
fn needs_redraw(&self) -> bool {
|
|
self.host_invalidated || self.last_presented_frame != self.latest_published_frame
|
|
}
|
|
|
|
fn should_request_redraw(&mut self) -> bool {
|
|
if self.needs_redraw() && !self.redraw_requested {
|
|
self.redraw_requested = true;
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn mark_presented(&mut self) {
|
|
self.redraw_requested = false;
|
|
self.host_invalidated = false;
|
|
self.last_presented_frame = self.latest_published_frame;
|
|
}
|
|
}
|
|
|
|
fn desired_control_flow(
|
|
now: Instant,
|
|
machine_running: bool,
|
|
accumulator: Duration,
|
|
frame_target_dt: Duration,
|
|
) -> ControlFlow {
|
|
if machine_running {
|
|
let remaining = frame_target_dt.saturating_sub(accumulator);
|
|
ControlFlow::WaitUntil(now + remaining)
|
|
} else {
|
|
ControlFlow::WaitUntil(now + IDLE_HOST_POLL_DT)
|
|
}
|
|
}
|
|
|
|
fn should_present_worker_frame(
|
|
state: &FirmwareState,
|
|
frame: &OwnedRgba8888Frame,
|
|
active_ownership: RenderOwnership,
|
|
) -> bool {
|
|
matches!(state, FirmwareState::GameRunning(_)) && frame.ownership == active_ownership
|
|
}
|
|
|
|
/// The Desktop implementation of the PROMETEU Runtime.
|
|
///
|
|
/// This struct acts as the physical "chassis" of the virtual console. It is
|
|
/// responsible for:
|
|
/// - Creating and managing the OS window (via `winit`).
|
|
/// - Initializing the GPU-accelerated framebuffer (via `pixels`).
|
|
/// - Handling real keyboard/gamepad events and converting them to virtual signals.
|
|
/// - Providing a high-fidelity audio backend (via `cpal`).
|
|
/// - Implementing the DevTools Protocol for remote debugging.
|
|
/// - Maintaining a deterministic 60Hz timing loop.
|
|
pub struct HostRunner {
|
|
/// The OS window handle.
|
|
window: Option<&'static Window>,
|
|
/// The pixel buffer interface for rendering to the GPU.
|
|
pixels: Option<Pixels<'static>>,
|
|
|
|
/// The instance of the virtual hardware peripherals.
|
|
hardware: Hardware,
|
|
/// The instance of the system firmware and OS logic.
|
|
firmware: Firmware,
|
|
|
|
/// Helper to collect and normalize input signals.
|
|
input: HostInputHandler,
|
|
/// Root path for the virtual sandbox filesystem.
|
|
fs_root: Option<String>,
|
|
|
|
/// Sink for system and application logs (prints to console).
|
|
log_sink: HostConsoleSink,
|
|
|
|
/// Target duration for a single frame (nominally 16.66ms for 60Hz).
|
|
frame_target_dt: Duration,
|
|
/// Last recorded wall-clock time to calculate deltas.
|
|
last_frame_time: Instant,
|
|
/// Time accumulator used to guarantee exact 60Hz logic updates.
|
|
accumulator: Duration,
|
|
|
|
/// Performance metrics collector.
|
|
stats: HostStats,
|
|
/// Remote debugger interface. It consumes host-owned telemetry and runtime state
|
|
/// directly instead of depending on guest-visible inspection syscalls.
|
|
debugger: HostDebugger,
|
|
|
|
/// The physical audio driver.
|
|
audio: HostAudio,
|
|
/// Last known pause state to sync with audio.
|
|
last_paused_state: bool,
|
|
/// Worker-published frame store used as the single observable publication source.
|
|
render_frame_store: LatestRenderFrameStore,
|
|
/// Tracks whether a new logical frame or host-only surface invalidation requires presentation.
|
|
presentation: PresentationState,
|
|
}
|
|
|
|
impl HostRunner {
|
|
/// Configures the boot target (Hub or specific Cartridge).
|
|
pub(crate) fn set_boot_target(&mut self, boot_target: BootTarget) {
|
|
self.firmware.boot_target = boot_target.clone();
|
|
self.debugger.setup_boot_target(&boot_target, &mut self.firmware);
|
|
}
|
|
|
|
pub(crate) fn set_games_root(&mut self, games_root: String) {
|
|
let library = discover_games_root(&games_root);
|
|
for diagnostic in library.diagnostics() {
|
|
eprintln!(
|
|
"games-root: omitted candidate {}: {:?}",
|
|
diagnostic.path.display(),
|
|
diagnostic.reason
|
|
);
|
|
}
|
|
self.firmware.hub.set_game_library(library);
|
|
}
|
|
|
|
/// Creates a new desktop runner instance.
|
|
pub(crate) fn new(fs_root: Option<String>, cap_config: Option<CertificationConfig>) -> Self {
|
|
let target_fps = 60;
|
|
|
|
let mut firmware = Firmware::new(cap_config);
|
|
if let Some(root) = &fs_root {
|
|
let backend = HostDirBackend::new(root);
|
|
firmware.os.fs().mount(Box::new(backend));
|
|
}
|
|
|
|
let memory_banks = Arc::new(MemoryBanks::new());
|
|
let hardware = Hardware::new_with_memory_banks(Arc::clone(&memory_banks));
|
|
let display_size = hardware.display_size();
|
|
let worker_backend = LocalFramebufferRenderBackend::new_with_memory_banks(
|
|
display_size.0,
|
|
display_size.1,
|
|
memory_banks,
|
|
hardware.assets.glyph_asset_slot_index(),
|
|
);
|
|
let render_frame_store = LatestRenderFrameStore::default();
|
|
firmware.os.vm().start_render_worker(
|
|
RenderWorkerConfig::default(),
|
|
worker_backend,
|
|
render_frame_store.clone(),
|
|
);
|
|
|
|
Self {
|
|
window: None,
|
|
pixels: None,
|
|
hardware,
|
|
firmware,
|
|
input: HostInputHandler::new(display_size),
|
|
fs_root,
|
|
log_sink: HostConsoleSink::new(),
|
|
frame_target_dt: Duration::from_nanos(1_000_000_000 / target_fps),
|
|
last_frame_time: Instant::now(),
|
|
accumulator: Duration::ZERO,
|
|
|
|
stats: HostStats::new(),
|
|
debugger: HostDebugger::new(),
|
|
audio: HostAudio::new(),
|
|
last_paused_state: false,
|
|
render_frame_store,
|
|
presentation: PresentationState::default(),
|
|
}
|
|
}
|
|
|
|
fn window(&self) -> &'static Window {
|
|
self.window.expect("window not created yet")
|
|
}
|
|
|
|
fn resize_surface(&mut self, width: u32, height: u32) {
|
|
if let Some(p) = self.pixels.as_mut() {
|
|
let _ = p.resize_surface(width, height);
|
|
}
|
|
}
|
|
|
|
fn request_redraw(&self) {
|
|
if let Some(w) = self.window.as_ref() {
|
|
w.request_redraw();
|
|
}
|
|
}
|
|
|
|
fn invalidate_host_surface(&mut self) {
|
|
self.presentation.invalidate_host_surface();
|
|
}
|
|
|
|
fn request_redraw_if_needed(&mut self) {
|
|
match self.firmware.state {
|
|
FirmwareState::GameRunning(_) => {
|
|
if self.presentation.should_request_redraw() {
|
|
self.request_redraw();
|
|
}
|
|
}
|
|
_ => {
|
|
self.request_redraw();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn machine_running(&mut self) -> bool {
|
|
!self.firmware.os.vm().paused() && !self.debugger.waiting_for_start
|
|
}
|
|
}
|
|
|
|
impl ApplicationHandler for HostRunner {
|
|
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
|
let attrs = WindowAttributes::default()
|
|
.with_title(format!(
|
|
"PROMETEU | GFX: {:.1} KB | FPS: {:.1} | Load: {:.1}% (C) + {:.1}% (A) | Frame: tick {} logical {} done {}",
|
|
0.0, 0.0, 0.0, 0, 0, 0, 0))
|
|
.with_inner_size(LogicalSize::new(960.0, 540.0))
|
|
.with_min_inner_size(LogicalSize::new(320.0, 180.0));
|
|
|
|
let window = event_loop.create_window(attrs).expect("failed to create window");
|
|
|
|
// `pixels` stores a surface tied to the window lifetime. The current
|
|
// winit runner owns one process-lifetime window, so we intentionally
|
|
// promote it to a static reference for the duration of the host process.
|
|
let window: &'static Window = Box::leak(Box::new(window));
|
|
self.window = Some(window);
|
|
|
|
let size = window.inner_size();
|
|
let surface_texture = SurfaceTexture::new(size.width, size.height, window);
|
|
|
|
let (display_w, display_h) = self.hardware.display_size();
|
|
let mut pixels = PixelsBuilder::new(display_w as u32, display_h as u32, surface_texture)
|
|
.present_mode(PresentMode::Fifo)
|
|
.build()
|
|
.expect("failed to create Pixels");
|
|
|
|
pixels.frame_mut().fill(0);
|
|
|
|
self.pixels = Some(pixels);
|
|
|
|
if let Err(err) = self.audio.init() {
|
|
eprintln!("[HostAudio] Disabled: {}", err);
|
|
}
|
|
|
|
self.invalidate_host_surface();
|
|
self.request_redraw_if_needed();
|
|
event_loop.set_control_flow(ControlFlow::Wait);
|
|
}
|
|
|
|
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
|
self.input.handle_event(&event, self.window());
|
|
|
|
match event {
|
|
WindowEvent::CloseRequested => event_loop.exit(),
|
|
|
|
WindowEvent::Resized(size) => {
|
|
self.resize_surface(size.width, size.height);
|
|
self.invalidate_host_surface();
|
|
self.request_redraw_if_needed();
|
|
}
|
|
|
|
WindowEvent::ScaleFactorChanged { .. } => {
|
|
let size = self.window().inner_size();
|
|
self.resize_surface(size.width, size.height);
|
|
self.invalidate_host_surface();
|
|
self.request_redraw_if_needed();
|
|
}
|
|
|
|
WindowEvent::RedrawRequested => {
|
|
let pixels = self.pixels.as_mut().expect("pixels not initialized");
|
|
|
|
{
|
|
let frame = pixels.frame_mut();
|
|
|
|
let active_ownership = self.firmware.os.vm().active_render_ownership();
|
|
if let Some(worker_frame) = self.render_frame_store.latest_frame()
|
|
&& should_present_worker_frame(
|
|
&self.firmware.state,
|
|
&worker_frame,
|
|
active_ownership,
|
|
)
|
|
{
|
|
self.firmware
|
|
.os
|
|
.vm()
|
|
.record_repeated_render_worker_frame(worker_frame.frame_id);
|
|
draw_owned_rgba8888_frame_to_rgba8(&worker_frame, frame);
|
|
} else {
|
|
let src = self.hardware.gfx.front_buffer();
|
|
draw_rgba8888_to_rgba8(src, frame);
|
|
}
|
|
}
|
|
|
|
if pixels.render().is_err() {
|
|
event_loop.exit();
|
|
} else {
|
|
self.presentation.mark_presented();
|
|
}
|
|
}
|
|
|
|
WindowEvent::KeyboardInput { event, .. } => {
|
|
if let PhysicalKey::Code(code) = event.physical_key {
|
|
let is_down = event.state == ElementState::Pressed;
|
|
|
|
if is_down && code == KeyCode::KeyD && self.debugger.waiting_for_start {
|
|
self.debugger.waiting_for_start = false;
|
|
self.invalidate_host_surface();
|
|
self.request_redraw_if_needed();
|
|
println!("[Debugger] Execution started!");
|
|
}
|
|
}
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Called by `winit` when the application is idle and ready to perform updates.
|
|
/// This is where the core execution loop lives.
|
|
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
|
let was_debugger_connected = self.debugger.stream.is_some();
|
|
let was_waiting_for_start = self.debugger.waiting_for_start;
|
|
let was_paused = self.firmware.os.vm().paused();
|
|
|
|
// 1. Process pending debug commands from the network.
|
|
self.debugger.check_commands(&mut self.firmware, &mut self.hardware);
|
|
|
|
// Sync debugger inspection state; this is not a guest-visible debug ABI switch.
|
|
self.firmware.os.vm().set_inspection_active(self.debugger.stream.is_some());
|
|
|
|
// 2. Maintain a filesystem connection if it was lost (e.g., directory removed).
|
|
if let Some(root) = &self.fs_root
|
|
&& self.firmware.os.fs().needs_mount()
|
|
&& std::path::Path::new(root).exists()
|
|
{
|
|
let backend = HostDirBackend::new(root);
|
|
self.firmware.os.fs().mount(Box::new(backend));
|
|
}
|
|
|
|
if self.input.take_home_requested() {
|
|
self.input.clear_guest_signals();
|
|
self.firmware.request_home_from_host();
|
|
self.invalidate_host_surface();
|
|
self.request_redraw_if_needed();
|
|
}
|
|
|
|
// 3. Timing Management (The heart of determinism).
|
|
// We measure the elapsed time since the last iteration and add it to an
|
|
// accumulator. We then execute exactly as many 60Hz slices as the
|
|
// accumulator allows.
|
|
let now = Instant::now();
|
|
let mut frame_delta = now.duration_since(self.last_frame_time);
|
|
|
|
// Safety cap: if the OS freezes or we fall behind too much, we don't try
|
|
// to catch up indefinitely (avoiding the "death spiral").
|
|
if frame_delta > Duration::from_millis(100) {
|
|
frame_delta = Duration::from_millis(100);
|
|
}
|
|
|
|
self.last_frame_time = now;
|
|
self.accumulator += frame_delta;
|
|
|
|
// Consume time in exact 60Hz slices.
|
|
while self.accumulator >= self.frame_target_dt {
|
|
// Unless the debugger is waiting for a 'start' command, advance the system.
|
|
if !self.debugger.waiting_for_start {
|
|
self.firmware.tick(&self.input.signals, &mut self.hardware);
|
|
self.stats.record_host_cpu_time(self.firmware.os.vm().last_frame_cpu_time_us());
|
|
}
|
|
|
|
// Sync pause state with audio.
|
|
// We do this AFTER firmware.tick to avoid MasterPause/Resume commands
|
|
// being cleared by the OS if a new logical frame starts in this tick.
|
|
let lifecycle_audio_paused = self.firmware.game_lifecycle_audio_paused();
|
|
let is_paused = lifecycle_audio_paused
|
|
|| self.firmware.os.vm().paused()
|
|
|| self.debugger.waiting_for_start;
|
|
if is_paused != self.last_paused_state {
|
|
self.last_paused_state = is_paused;
|
|
let cmd =
|
|
if is_paused { AudioCommand::MasterPause } else { AudioCommand::MasterResume };
|
|
self.hardware.audio.commands.push(cmd);
|
|
}
|
|
|
|
// Sync virtual audio commands to the physical mixer.
|
|
self.audio.send_commands(&mut self.hardware.audio.commands);
|
|
|
|
self.accumulator -= self.frame_target_dt;
|
|
self.stats.record_frame();
|
|
}
|
|
|
|
let active_ownership = self.firmware.os.vm().active_render_ownership();
|
|
if let Some(worker_frame) = self.render_frame_store.latest_frame()
|
|
&& should_present_worker_frame(&self.firmware.state, &worker_frame, active_ownership)
|
|
{
|
|
self.presentation
|
|
.note_published_frame(worker_frame.ownership, worker_frame.frame_id.get());
|
|
} else {
|
|
self.presentation
|
|
.note_published_frame(active_ownership, self.firmware.os.frame_index());
|
|
}
|
|
|
|
if was_debugger_connected != self.debugger.stream.is_some()
|
|
|| was_waiting_for_start != self.debugger.waiting_for_start
|
|
|| was_paused != self.firmware.os.vm().paused()
|
|
{
|
|
self.invalidate_host_surface();
|
|
}
|
|
|
|
// 4. Feedback and Synchronization.
|
|
self.audio.update_stats(&mut self.stats);
|
|
|
|
// Update technical statistics displayed in the window title.
|
|
self.stats.update(now, self.window, &mut self.firmware);
|
|
|
|
// Synchronize system logs to the host console.
|
|
let last_seq = self.log_sink.last_seq().unwrap_or(u64::MAX);
|
|
let new_events = if last_seq == u64::MAX {
|
|
self.firmware.os.recent_logs(4096)
|
|
} else {
|
|
self.firmware.os.logs_after(last_seq)
|
|
};
|
|
self.log_sink.process_events(new_events);
|
|
|
|
// 5. Request redraw only when a new logical frame was published or the
|
|
// host-owned presentation surface became invalid.
|
|
self.request_redraw_if_needed();
|
|
event_loop.set_control_flow(desired_control_flow(
|
|
Instant::now(),
|
|
self.machine_running(),
|
|
self.accumulator,
|
|
self.frame_target_dt,
|
|
));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use prometeu_firmware::BootTarget;
|
|
use prometeu_firmware::firmware::firmware_state::{
|
|
GameRunningStep, HubHomeStep, ShellRunningStep,
|
|
};
|
|
use prometeu_hal::app_mode::AppMode;
|
|
use prometeu_hal::debugger_protocol::DEVTOOLS_PROTOCOL_VERSION;
|
|
use prometeu_hal::telemetry::{CertificationConfig, TelemetryFrame};
|
|
use prometeu_hal::{FrameId, OwnedRgba8888Frame, RenderOwnership, RenderWorkerFrameSink};
|
|
use prometeu_system::task::TaskId;
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpStream;
|
|
|
|
#[test]
|
|
fn presentation_state_requires_initial_redraw_only_once() {
|
|
let mut state = PresentationState::default();
|
|
|
|
assert!(state.should_request_redraw());
|
|
assert!(!state.should_request_redraw());
|
|
|
|
state.mark_presented();
|
|
|
|
assert!(!state.should_request_redraw());
|
|
}
|
|
|
|
#[test]
|
|
fn presentation_state_requests_redraw_for_new_frame_publication() {
|
|
let ownership = RenderOwnership::new(1, AppMode::Game, 1);
|
|
let mut state = PresentationState::default();
|
|
state.mark_presented();
|
|
|
|
state.note_published_frame(ownership, 1);
|
|
|
|
assert!(state.should_request_redraw());
|
|
state.mark_presented();
|
|
assert_eq!(
|
|
state.last_presented_frame,
|
|
Some(PresentationFrameMarker { ownership, frame_id: 1 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn presentation_state_requests_redraw_when_ownership_changes_with_lower_frame_id() {
|
|
let first = RenderOwnership::new(3, AppMode::Game, 1);
|
|
let next = RenderOwnership::new(4, AppMode::Game, 2);
|
|
let mut state = PresentationState::default();
|
|
|
|
state.note_published_frame(first, 120);
|
|
state.mark_presented();
|
|
|
|
state.note_published_frame(next, 0);
|
|
|
|
assert!(state.should_request_redraw());
|
|
state.mark_presented();
|
|
assert_eq!(
|
|
state.last_presented_frame,
|
|
Some(PresentationFrameMarker { ownership: next, frame_id: 0 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn presentation_state_requests_redraw_for_host_invalidation_without_new_frame() {
|
|
let mut state = PresentationState::default();
|
|
state.mark_presented();
|
|
|
|
state.invalidate_host_surface();
|
|
|
|
assert!(state.should_request_redraw());
|
|
state.mark_presented();
|
|
assert!(!state.needs_redraw());
|
|
}
|
|
|
|
#[test]
|
|
fn desired_control_flow_waits_until_next_logical_deadline_while_running() {
|
|
let now = Instant::now();
|
|
let flow =
|
|
desired_control_flow(now, true, Duration::from_millis(5), Duration::from_millis(16));
|
|
|
|
match flow {
|
|
ControlFlow::WaitUntil(deadline) => {
|
|
assert_eq!(deadline.duration_since(now), Duration::from_millis(11));
|
|
}
|
|
other => panic!("unexpected control flow: {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn desired_control_flow_uses_idle_poll_when_machine_is_not_running() {
|
|
let now = Instant::now();
|
|
let flow =
|
|
desired_control_flow(now, false, Duration::from_millis(5), Duration::from_millis(16));
|
|
|
|
match flow {
|
|
ControlFlow::WaitUntil(deadline) => {
|
|
assert_eq!(deadline.duration_since(now), IDLE_HOST_POLL_DT);
|
|
}
|
|
other => panic!("unexpected control flow: {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn worker_frame_presentation_requires_game_state_and_current_ownership() {
|
|
let active = RenderOwnership::new(3, AppMode::Game, 1);
|
|
let current = OwnedRgba8888Frame::packed(FrameId::new(9), active, 1, 1, vec![0xFF00FFFF])
|
|
.expect("test frame");
|
|
let stale = OwnedRgba8888Frame::packed(
|
|
FrameId::new(8),
|
|
RenderOwnership::new(2, AppMode::Game, 1),
|
|
1,
|
|
1,
|
|
vec![0xFF00FFFF],
|
|
)
|
|
.expect("test frame");
|
|
|
|
assert!(should_present_worker_frame(
|
|
&FirmwareState::GameRunning(GameRunningStep::new(TaskId(1))),
|
|
¤t,
|
|
active,
|
|
));
|
|
assert!(!should_present_worker_frame(
|
|
&FirmwareState::GameRunning(GameRunningStep::new(TaskId(1),)),
|
|
&stale,
|
|
active,
|
|
));
|
|
assert!(!should_present_worker_frame(
|
|
&FirmwareState::ShellRunning(ShellRunningStep::new(TaskId(2))),
|
|
¤t,
|
|
active,
|
|
));
|
|
assert!(!should_present_worker_frame(
|
|
&FirmwareState::HubHome(HubHomeStep),
|
|
¤t,
|
|
active,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn host_presentation_observes_worker_publication_store() {
|
|
let store = LatestRenderFrameStore::default();
|
|
let frame = OwnedRgba8888Frame::packed(
|
|
FrameId::new(9),
|
|
RenderOwnership::new(1, AppMode::Game, 1),
|
|
1,
|
|
1,
|
|
vec![0xFF00FFFF],
|
|
)
|
|
.expect("test frame");
|
|
|
|
store.publish(frame).expect("store publish");
|
|
|
|
let mut presentation = PresentationState::default();
|
|
presentation.mark_presented();
|
|
let published = store.latest_frame().expect("published frame");
|
|
presentation.note_published_frame(published.ownership, published.frame_id.get());
|
|
|
|
assert!(presentation.should_request_redraw());
|
|
presentation.mark_presented();
|
|
assert_eq!(
|
|
presentation.last_presented_frame,
|
|
Some(PresentationFrameMarker { ownership: published.ownership, frame_id: 9 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn host_debugger_maps_cert_events_from_host_owned_sources() {
|
|
let telemetry = TelemetryFrame {
|
|
glyph_slots_used: 3,
|
|
heap_used_bytes: 4096,
|
|
logs_count: 7,
|
|
..Default::default()
|
|
};
|
|
let config = CertificationConfig {
|
|
enabled: true,
|
|
max_glyph_slots_used: Some(2),
|
|
max_heap_bytes: Some(2048),
|
|
max_logs_per_frame: Some(5),
|
|
..Default::default()
|
|
};
|
|
|
|
match HostDebugger::cert_event_from_snapshot(0xCA04, telemetry, &config, 12) {
|
|
Some(prometeu_hal::debugger_protocol::DebugEvent::Cert {
|
|
rule,
|
|
used,
|
|
limit,
|
|
frame_index,
|
|
}) => {
|
|
assert_eq!(rule, "max_glyph_slots_used");
|
|
assert_eq!(used, 3);
|
|
assert_eq!(limit, 2);
|
|
assert_eq!(frame_index, 12);
|
|
}
|
|
other => panic!("unexpected glyph cert event: {:?}", other),
|
|
}
|
|
match HostDebugger::cert_event_from_snapshot(0xCA06, telemetry, &config, 12) {
|
|
Some(prometeu_hal::debugger_protocol::DebugEvent::Cert {
|
|
rule,
|
|
used,
|
|
limit,
|
|
frame_index,
|
|
}) => {
|
|
assert_eq!(rule, "max_heap_bytes");
|
|
assert_eq!(used, 4096);
|
|
assert_eq!(limit, 2048);
|
|
assert_eq!(frame_index, 12);
|
|
}
|
|
other => panic!("unexpected heap cert event: {:?}", other),
|
|
}
|
|
match HostDebugger::cert_event_from_snapshot(0xCA07, telemetry, &config, 12) {
|
|
Some(prometeu_hal::debugger_protocol::DebugEvent::Cert {
|
|
rule,
|
|
used,
|
|
limit,
|
|
frame_index,
|
|
}) => {
|
|
assert_eq!(rule, "max_logs_per_frame");
|
|
assert_eq!(used, 7);
|
|
assert_eq!(limit, 5);
|
|
assert_eq!(frame_index, 12);
|
|
}
|
|
other => panic!("unexpected logs cert event: {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn host_debugger_maps_telemetry_from_atomic_snapshot_fields() {
|
|
let telemetry = TelemetryFrame {
|
|
frame_index: 8,
|
|
vm_steps: 123,
|
|
syscalls: 9,
|
|
cycles_used: 456,
|
|
cycles_budget: 789,
|
|
host_cpu_time_us: 321,
|
|
violations: 2,
|
|
glyph_slots_used: 1,
|
|
glyph_slots_total: 16,
|
|
sound_slots_used: 0,
|
|
sound_slots_total: 8,
|
|
scene_slots_used: 2,
|
|
scene_slots_total: 16,
|
|
..Default::default()
|
|
};
|
|
|
|
match HostDebugger::telemetry_event_from_snapshot(telemetry) {
|
|
prometeu_hal::debugger_protocol::DebugEvent::Telemetry {
|
|
frame_index,
|
|
vm_steps,
|
|
syscalls,
|
|
cycles,
|
|
cycles_budget,
|
|
host_cpu_time_us,
|
|
violations,
|
|
glyph_slots_used,
|
|
glyph_slots_total,
|
|
sound_slots_used,
|
|
sound_slots_total,
|
|
scene_slots_used,
|
|
scene_slots_total,
|
|
} => {
|
|
assert_eq!(frame_index, 8);
|
|
assert_eq!(vm_steps, 123);
|
|
assert_eq!(syscalls, 9);
|
|
assert_eq!(cycles, 456);
|
|
assert_eq!(cycles_budget, 789);
|
|
assert_eq!(host_cpu_time_us, 321);
|
|
assert_eq!(violations, 2);
|
|
assert_eq!(glyph_slots_used, 1);
|
|
assert_eq!(glyph_slots_total, 16);
|
|
assert_eq!(sound_slots_used, 0);
|
|
assert_eq!(sound_slots_total, 8);
|
|
assert_eq!(scene_slots_used, 2);
|
|
assert_eq!(scene_slots_total, 16);
|
|
}
|
|
other => panic!("unexpected telemetry event: {:?}", other),
|
|
}
|
|
}
|
|
|
|
// These ignored tests intentionally cover host-dependent behavior that requires
|
|
// real socket bind/connect coordination. Deterministic debugger state changes
|
|
// are covered below the host layer in `debugger.rs`.
|
|
#[test]
|
|
#[ignore = "requires localhost TCP bind/connect; run via `cargo test -p prometeu-host-desktop-winit --lib -- --ignored`"]
|
|
fn test_debug_port_opens() {
|
|
let mut runner = HostRunner::new(None, None);
|
|
let port = 9999;
|
|
runner.set_boot_target(BootTarget::Cartridge {
|
|
path: "dummy.bin".to_string(),
|
|
debug: true,
|
|
debug_port: port,
|
|
});
|
|
|
|
assert!(runner.debugger.waiting_for_start);
|
|
assert!(runner.debugger.listener.is_some());
|
|
|
|
// Check if we can connect
|
|
{
|
|
let mut stream =
|
|
TcpStream::connect(format!("127.0.0.1:{}", port)).expect("Should connect");
|
|
// Short sleep to ensure the OS processes
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
|
|
// Simulates the loop to accept the connection
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
assert!(runner.debugger.stream.is_some(), "Stream should have been kept open");
|
|
|
|
// Handshake Check
|
|
let mut buf = [0u8; 2048];
|
|
let n = stream.read(&mut buf).expect("Should read handshake");
|
|
let resp: serde_json::Value =
|
|
serde_json::from_slice(&buf[..n]).expect("Handshake should be valid JSON");
|
|
assert_eq!(resp["type"], "handshake");
|
|
assert_eq!(resp["protocol_version"], DEVTOOLS_PROTOCOL_VERSION);
|
|
|
|
// Send start via JSON
|
|
stream
|
|
.write_all(b"{\"type\":\"start\"}\n")
|
|
.expect("Connection should be open for writing");
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
|
|
// Process the received command
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
assert!(
|
|
!runner.debugger.waiting_for_start,
|
|
"Execution should have started after start command"
|
|
);
|
|
assert!(
|
|
runner.debugger.listener.is_some(),
|
|
"Listener should remain open for reconnections"
|
|
);
|
|
}
|
|
|
|
// Now that the stream is out of the test scope, the runner should detect closure on next check
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
assert!(
|
|
runner.debugger.stream.is_none(),
|
|
"Stream should have been closed after client disconnected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires localhost TCP bind/connect; run via `cargo test -p prometeu-host-desktop-winit --lib -- --ignored`"]
|
|
fn test_debug_reconnection() {
|
|
let mut runner = HostRunner::new(None, None);
|
|
let port = 9998;
|
|
runner.set_boot_target(BootTarget::Cartridge {
|
|
path: "dummy.bin".to_string(),
|
|
debug: true,
|
|
debug_port: port,
|
|
});
|
|
|
|
// 1. Connect and start
|
|
{
|
|
let mut stream =
|
|
TcpStream::connect(format!("127.0.0.1:{}", port)).expect("Should connect 1");
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
assert!(runner.debugger.stream.is_some());
|
|
|
|
stream.write_all(b"{\"type\":\"start\"}\n").expect("Should write start");
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
assert!(!runner.debugger.waiting_for_start);
|
|
// Currently the listener is closed here.
|
|
}
|
|
|
|
// 2. Disconnect (clears stream in runner)
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
assert!(runner.debugger.stream.is_none());
|
|
|
|
// 3. Try to reconnect - SHOULD FAIL currently, but we want it to WORK
|
|
let stream2 = TcpStream::connect(format!("127.0.0.1:{}", port));
|
|
assert!(stream2.is_ok(), "Should accept new connection even after start");
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
assert!(
|
|
runner.debugger.stream.is_some(),
|
|
"Stream should have been accepted on reconnection"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires localhost TCP bind/connect; run via `cargo test -p prometeu-host-desktop-winit --lib -- --ignored`"]
|
|
fn test_debug_refuse_second_connection() {
|
|
let mut runner = HostRunner::new(None, None);
|
|
let port = 9997;
|
|
runner.set_boot_target(BootTarget::Cartridge {
|
|
path: "dummy.bin".to_string(),
|
|
debug: true,
|
|
debug_port: port,
|
|
});
|
|
|
|
// 1. First connection
|
|
let mut _stream1 =
|
|
TcpStream::connect(format!("127.0.0.1:{}", port)).expect("Should connect 1");
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
assert!(runner.debugger.stream.is_some());
|
|
|
|
// 2. Second connection
|
|
let mut stream2 = TcpStream::connect(format!("127.0.0.1:{}", port))
|
|
.expect("Should connect 2 (OS accepts)");
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware); // Should accept and close stream2
|
|
|
|
// Check if stream2 was closed by the server
|
|
let mut buf = [0u8; 10];
|
|
stream2.set_read_timeout(Some(std::time::Duration::from_millis(100))).unwrap();
|
|
let res = stream2.read(&mut buf);
|
|
assert!(
|
|
matches!(res, Ok(0)) || res.is_err(),
|
|
"Second connection should be closed by server"
|
|
);
|
|
|
|
assert!(runner.debugger.stream.is_some(), "First connection should continue active");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires localhost TCP bind/connect; run via `cargo test -p prometeu-host-desktop-winit --lib -- --ignored`"]
|
|
fn test_get_state_returns_response() {
|
|
let mut runner = HostRunner::new(None, None);
|
|
let port = 9996;
|
|
runner.set_boot_target(BootTarget::Cartridge {
|
|
path: "dummy.bin".to_string(),
|
|
debug: true,
|
|
debug_port: port,
|
|
});
|
|
|
|
let stream = TcpStream::connect(format!("127.0.0.1:{}", port)).expect("Should connect");
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
|
|
use std::io::BufRead;
|
|
let mut reader = std::io::BufReader::new(stream);
|
|
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).expect("Should read handshake");
|
|
assert!(line.contains("handshake"));
|
|
|
|
// Send getState
|
|
reader.get_mut().write_all(b"{\"type\":\"getState\"}\n").expect("Should write getState");
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
|
|
// Check if response received (may have events/logs before)
|
|
loop {
|
|
line.clear();
|
|
reader.read_line(&mut line).expect("Should read line");
|
|
if line.is_empty() {
|
|
break;
|
|
}
|
|
|
|
if let Ok(resp) = serde_json::from_str::<serde_json::Value>(&line)
|
|
&& resp["type"] == "getState"
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
panic!("Did not receive getState response");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires localhost TCP bind/connect; run via `cargo test -p prometeu-host-desktop-winit --lib -- --ignored`"]
|
|
fn test_debug_resume_on_disconnect() {
|
|
let mut runner = HostRunner::new(None, None);
|
|
let port = 9995;
|
|
runner.set_boot_target(BootTarget::Cartridge {
|
|
path: "dummy.bin".to_string(),
|
|
debug: true,
|
|
debug_port: port,
|
|
});
|
|
|
|
// 1. Connect and pause
|
|
{
|
|
let mut stream =
|
|
TcpStream::connect(format!("127.0.0.1:{}", port)).expect("Should connect");
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
|
|
stream.write_all(b"{\"type\":\"pause\"}\n").expect("Should write pause");
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
assert!(runner.firmware.os.vm().paused(), "VM should be paused");
|
|
}
|
|
|
|
// 2. Disconnect (stream goes out of scope)
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
runner.debugger.check_commands(&mut runner.firmware, &mut runner.hardware);
|
|
|
|
// 3. Check if unpaused
|
|
assert!(!runner.firmware.os.vm().paused(), "VM should have unpaused after disconnect");
|
|
assert!(!runner.debugger.waiting_for_start, "VM should have left waiting_for_start state");
|
|
}
|
|
}
|