1583 lines
62 KiB
Rust
1583 lines
62 KiB
Rust
use crate::firmware::boot_target::BootTarget;
|
|
use crate::firmware::firmware_state::{
|
|
FirmwareState, GameRunningStep, LoadCartridgeStep, ResetStep,
|
|
};
|
|
use crate::firmware::prometeu_context::PrometeuContext;
|
|
use prometeu_hal::app_mode::AppMode;
|
|
use prometeu_hal::cartridge::Cartridge;
|
|
use prometeu_hal::log::LogSource;
|
|
use prometeu_hal::telemetry::CertificationConfig;
|
|
use prometeu_hal::{InputSignals, RuntimePlatform};
|
|
use prometeu_system::{PrometeuHub, ResidentGameState, SystemOS};
|
|
|
|
/// PROMETEU Firmware.
|
|
///
|
|
/// The central orchestrator of the console. The firmware acts as the
|
|
/// "Control Unit", managing the high-level state machine of the system.
|
|
///
|
|
/// It is responsible for transitioning between different modes of operation,
|
|
/// such as showing the splash screen, running the Hub (launcher), or
|
|
/// executing a game/app.
|
|
///
|
|
/// ### Execution Loop:
|
|
/// The firmware is designed to be ticked once per frame (60Hz). During each
|
|
/// tick, it:
|
|
/// 1. Updates peripherals with the latest input signals.
|
|
/// 2. Delegates the logic update to the current active state.
|
|
/// 3. Handles state transitions (e.g., from Loading to Playing).
|
|
pub struct Firmware {
|
|
/// The underlying OS services (Syscalls, Filesystem, Telemetry).
|
|
pub os: SystemOS,
|
|
/// The internal state of the system launcher (Hub).
|
|
pub hub: PrometeuHub,
|
|
/// The current operational state (e.g., Reset, SplashScreen, GameRunning).
|
|
pub state: FirmwareState,
|
|
/// The desired application to run after boot (Hub or specific Cartridge).
|
|
pub boot_target: BootTarget,
|
|
/// State-machine lifecycle tracker.
|
|
state_initialized: bool,
|
|
game_input_barrier_frames: u8,
|
|
}
|
|
|
|
impl Firmware {
|
|
/// Initializes the firmware in the `Reset` state.
|
|
pub fn new(cap_config: Option<CertificationConfig>) -> Self {
|
|
Self {
|
|
os: SystemOS::new(cap_config),
|
|
hub: PrometeuHub::new(),
|
|
state: FirmwareState::Reset(ResetStep),
|
|
boot_target: BootTarget::Hub,
|
|
state_initialized: false,
|
|
game_input_barrier_frames: 0,
|
|
}
|
|
}
|
|
|
|
/// The main entry point for the Host to advance the system logic.
|
|
///
|
|
/// This method is called exactly once per Host frame (60Hz).
|
|
/// It updates peripheral signals and delegates the logic to the current state.
|
|
pub fn tick(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
|
|
let barriered_signals = InputSignals::default();
|
|
let effective_signals = if self.game_input_barrier_frames > 0 {
|
|
self.game_input_barrier_frames -= 1;
|
|
&barriered_signals
|
|
} else {
|
|
signals
|
|
};
|
|
|
|
// 0. Process asset commits at the beginning of the frame boundary.
|
|
platform.assets_mut().apply_commits();
|
|
|
|
// 1. Update the peripheral state using the latest signals from the Host.
|
|
// This ensures input is consistent throughout the entire update.
|
|
platform.input_mut().pad_mut().begin_frame(effective_signals);
|
|
platform.input_mut().touch_mut().begin_frame(effective_signals);
|
|
|
|
// 2. State machine lifecycle management.
|
|
if !self.state_initialized {
|
|
self.on_enter(effective_signals, platform);
|
|
self.state_initialized = true;
|
|
}
|
|
|
|
// 3. Update the current state and check for transitions.
|
|
if let Some(next_state) = self.on_update(effective_signals, platform) {
|
|
self.change_state(next_state, effective_signals, platform);
|
|
}
|
|
}
|
|
|
|
/// Transitions the system to a new state, handling lifecycle hooks.
|
|
pub fn change_state(
|
|
&mut self,
|
|
new_state: FirmwareState,
|
|
signals: &InputSignals,
|
|
platform: &mut dyn RuntimePlatform,
|
|
) {
|
|
let new_state = match new_state {
|
|
FirmwareState::ResumeResidentGame => {
|
|
let Some(resume_state) = self.resident_game_resume_state() else {
|
|
return;
|
|
};
|
|
resume_state
|
|
}
|
|
state => state,
|
|
};
|
|
let entering_game_from_home = matches!(self.state, FirmwareState::HubHome(_))
|
|
&& matches!(new_state, FirmwareState::GameRunning(_));
|
|
self.on_exit(signals, platform);
|
|
self.state = new_state;
|
|
self.state_initialized = false;
|
|
if entering_game_from_home {
|
|
self.game_input_barrier_frames = self.game_input_barrier_frames.max(1);
|
|
}
|
|
|
|
// Enter the new state immediately to avoid "empty" frames during transitions.
|
|
self.on_enter(signals, platform);
|
|
self.state_initialized = true;
|
|
}
|
|
|
|
/// Dispatches the `on_enter` event to the current state implementation.
|
|
fn on_enter(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
|
|
let mut req = PrometeuContext {
|
|
os: &mut self.os,
|
|
hub: &mut self.hub,
|
|
boot_target: &self.boot_target,
|
|
signals,
|
|
platform,
|
|
};
|
|
match &mut self.state {
|
|
FirmwareState::Reset(s) => s.on_enter(&mut req),
|
|
FirmwareState::SplashScreen(s) => s.on_enter(&mut req),
|
|
FirmwareState::LaunchHub(s) => s.on_enter(&mut req),
|
|
FirmwareState::HubHome(s) => s.on_enter(&mut req),
|
|
FirmwareState::LoadCartridge(s) => s.on_enter(&mut req),
|
|
FirmwareState::ResumeResidentGame => {}
|
|
FirmwareState::GameRunning(s) => s.on_enter(&mut req),
|
|
FirmwareState::ShellRunning(s) => s.on_enter(&mut req),
|
|
FirmwareState::AppCrashes(s) => s.on_enter(&mut req),
|
|
}
|
|
}
|
|
|
|
/// Dispatches the `on_update` event to the current state implementation.
|
|
/// Returns an optional `FirmwareState` if a transition is requested.
|
|
fn on_update(
|
|
&mut self,
|
|
signals: &InputSignals,
|
|
platform: &mut dyn RuntimePlatform,
|
|
) -> Option<FirmwareState> {
|
|
let mut req = PrometeuContext {
|
|
os: &mut self.os,
|
|
hub: &mut self.hub,
|
|
boot_target: &self.boot_target,
|
|
signals,
|
|
platform,
|
|
};
|
|
match &mut self.state {
|
|
FirmwareState::Reset(s) => s.on_update(&mut req),
|
|
FirmwareState::SplashScreen(s) => s.on_update(&mut req),
|
|
FirmwareState::LaunchHub(s) => s.on_update(&mut req),
|
|
FirmwareState::HubHome(s) => s.on_update(&mut req),
|
|
FirmwareState::LoadCartridge(s) => s.on_update(&mut req),
|
|
FirmwareState::ResumeResidentGame => None,
|
|
FirmwareState::GameRunning(s) => s.on_update(&mut req),
|
|
FirmwareState::ShellRunning(s) => s.on_update(&mut req),
|
|
FirmwareState::AppCrashes(s) => s.on_update(&mut req),
|
|
}
|
|
}
|
|
|
|
/// Dispatches the `on_exit` event to the current state implementation.
|
|
fn on_exit(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
|
|
let mut req = PrometeuContext {
|
|
os: &mut self.os,
|
|
hub: &mut self.hub,
|
|
boot_target: &self.boot_target,
|
|
signals,
|
|
platform,
|
|
};
|
|
match &mut self.state {
|
|
FirmwareState::Reset(s) => s.on_exit(&mut req),
|
|
FirmwareState::SplashScreen(s) => s.on_exit(&mut req),
|
|
FirmwareState::LaunchHub(s) => s.on_exit(&mut req),
|
|
FirmwareState::HubHome(s) => s.on_exit(&mut req),
|
|
FirmwareState::LoadCartridge(s) => s.on_exit(&mut req),
|
|
FirmwareState::ResumeResidentGame => {}
|
|
FirmwareState::GameRunning(s) => s.on_exit(&mut req),
|
|
FirmwareState::ShellRunning(s) => s.on_exit(&mut req),
|
|
FirmwareState::AppCrashes(s) => s.on_exit(&mut req),
|
|
}
|
|
}
|
|
|
|
pub fn load_cartridge(&mut self, cartridge: Cartridge) {
|
|
self.state = FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge));
|
|
self.state_initialized = false;
|
|
}
|
|
|
|
pub fn request_home_from_host(&mut self) {
|
|
self.os.info(LogSource::Pos, "Host requested Home/SystemOS".to_string());
|
|
if let FirmwareState::GameRunning(step) = &self.state {
|
|
if let Err(error) = self.os.lifecycle().request_home_from_game(step.task_id) {
|
|
self.os.warn(
|
|
LogSource::Pos,
|
|
format!("Home/SystemOS request could not pause Game: {error:?}"),
|
|
);
|
|
} else {
|
|
self.game_input_barrier_frames = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn resume_resident_game_from_home(&mut self) -> bool {
|
|
let Some(state) = self.resident_game_resume_state() else {
|
|
return false;
|
|
};
|
|
|
|
self.state = state;
|
|
self.state_initialized = false;
|
|
true
|
|
}
|
|
|
|
fn resident_game_resume_state(&mut self) -> Option<FirmwareState> {
|
|
let task_id = self.os.lifecycle().resident_game_task()?;
|
|
|
|
if let Err(error) = self.os.lifecycle().resume_task(task_id) {
|
|
self.os.warn(LogSource::Pos, format!("Resident Game resume failed: {error:?}"));
|
|
return None;
|
|
}
|
|
|
|
let app_id = self
|
|
.os
|
|
.sessions()
|
|
.vm_session_for_task(task_id)
|
|
.map(|session| session.app_id)
|
|
.unwrap_or_else(|| self.os.vm().current_app_id());
|
|
self.os.vm().transition_render_owner(AppMode::Game, app_id);
|
|
self.game_input_barrier_frames = self.game_input_barrier_frames.max(1);
|
|
Some(FirmwareState::GameRunning(GameRunningStep::new(task_id)))
|
|
}
|
|
|
|
pub fn game_lifecycle_audio_paused(&mut self) -> bool {
|
|
matches!(
|
|
self.os.lifecycle().resident_game().map(|game| game.state),
|
|
Some(
|
|
ResidentGameState::PauseRequested { .. }
|
|
| ResidentGameState::PausedSuspended
|
|
| ResidentGameState::ResumeRequested,
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::firmware::firmware_state::HubHomeStep;
|
|
use crate::firmware::resident_game_termination::{self, ResidentGameTerminationOutcome};
|
|
use prometeu_bytecode::assembler::assemble;
|
|
use prometeu_bytecode::model::{BytecodeModule, FunctionMeta, SyscallDecl};
|
|
use prometeu_drivers::{GlyphBankPoolAccess, SceneBankPoolAccess, TestPlatform};
|
|
use prometeu_hal::RuntimePlatform;
|
|
use prometeu_hal::app_mode::AppMode;
|
|
use prometeu_hal::asset::{BankType, SlotRef};
|
|
use prometeu_hal::cartridge::AssetsPayloadSource;
|
|
use prometeu_hal::color::Color;
|
|
use prometeu_hal::log::{LogLevel, LogSource};
|
|
use prometeu_hal::primitives::Rect;
|
|
use prometeu_hal::syscalls::{Syscall, caps};
|
|
use prometeu_system::process::ProcessState;
|
|
use prometeu_system::task::{TaskId, TaskState};
|
|
use prometeu_system::windows::WindowOwner;
|
|
use prometeu_system::{
|
|
CrashReport, ForegroundOwner, GameLifecycleEventKind, ResidentGameState,
|
|
discover_games_root,
|
|
};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
struct TestGameRoot {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl TestGameRoot {
|
|
fn new() -> Self {
|
|
let unique = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let path = std::env::temp_dir().join(format!(
|
|
"prometeu-firmware-game-root-test-{}-{}",
|
|
std::process::id(),
|
|
unique
|
|
));
|
|
fs::create_dir_all(&path).expect("test game root should be created");
|
|
Self { path }
|
|
}
|
|
|
|
fn add_game(&self, name: &str, with_program: bool) -> PathBuf {
|
|
self.add_game_with_identity(name, 42, "Stress", "1.0.0", with_program)
|
|
}
|
|
|
|
fn add_game_with_identity(
|
|
&self,
|
|
name: &str,
|
|
app_id: u32,
|
|
title: &str,
|
|
app_version: &str,
|
|
with_program: bool,
|
|
) -> PathBuf {
|
|
let program = with_program.then(halting_program);
|
|
self.add_game_with_program(name, app_id, title, app_version, program)
|
|
}
|
|
|
|
fn add_game_with_program(
|
|
&self,
|
|
name: &str,
|
|
app_id: u32,
|
|
title: &str,
|
|
app_version: &str,
|
|
program: Option<Vec<u8>>,
|
|
) -> PathBuf {
|
|
let cart = self.path.join(name);
|
|
fs::create_dir_all(&cart).expect("test cartridge should be created");
|
|
let manifest = format!(
|
|
r#"{{
|
|
"magic": "PMTU",
|
|
"cartridge_version": 1,
|
|
"app_id": {app_id},
|
|
"title": "{title}",
|
|
"app_version": "{app_version}",
|
|
"app_mode": "Game"
|
|
}}"#
|
|
);
|
|
fs::write(cart.join("manifest.json"), manifest)
|
|
.expect("test manifest should be written");
|
|
if let Some(program) = program {
|
|
fs::write(cart.join("program.pbx"), program)
|
|
.expect("test program should be written");
|
|
}
|
|
cart
|
|
}
|
|
}
|
|
|
|
impl Drop for TestGameRoot {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.path);
|
|
}
|
|
}
|
|
|
|
fn halting_program() -> Vec<u8> {
|
|
let code = assemble("HALT").expect("assemble");
|
|
BytecodeModule {
|
|
version: 0,
|
|
const_pool: vec![],
|
|
functions: vec![FunctionMeta {
|
|
code_offset: 0,
|
|
code_len: code.len() as u32,
|
|
..Default::default()
|
|
}],
|
|
code,
|
|
debug_info: None,
|
|
exports: vec![],
|
|
syscalls: vec![],
|
|
}
|
|
.serialize()
|
|
}
|
|
|
|
fn repository_test_cartridges_root() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..").join("test-cartridges")
|
|
}
|
|
|
|
fn invalid_game_cartridge() -> Cartridge {
|
|
Cartridge {
|
|
app_id: 7,
|
|
title: "Broken Cart".into(),
|
|
app_version: "1.0.0".into(),
|
|
app_mode: AppMode::Game,
|
|
capabilities: 0,
|
|
program: vec![0, 0, 0, 0],
|
|
assets: AssetsPayloadSource::empty(),
|
|
asset_table: vec![],
|
|
preload: vec![],
|
|
}
|
|
}
|
|
|
|
fn trapping_game_cartridge() -> Cartridge {
|
|
let code = assemble("PUSH_BOOL 1\nHOSTCALL 0\nHALT").expect("assemble");
|
|
let program = BytecodeModule {
|
|
version: 0,
|
|
const_pool: vec![],
|
|
functions: vec![FunctionMeta {
|
|
code_offset: 0,
|
|
code_len: code.len() as u32,
|
|
..Default::default()
|
|
}],
|
|
code,
|
|
debug_info: None,
|
|
exports: vec![],
|
|
syscalls: vec![SyscallDecl {
|
|
module: "gfx2d".into(),
|
|
name: "clear".into(),
|
|
version: 1,
|
|
arg_slots: 1,
|
|
ret_slots: 0,
|
|
}],
|
|
}
|
|
.serialize();
|
|
|
|
Cartridge {
|
|
app_id: 8,
|
|
title: "Trap Cart".into(),
|
|
app_version: "1.0.0".into(),
|
|
app_mode: AppMode::Game,
|
|
capabilities: caps::GFX,
|
|
program,
|
|
assets: AssetsPayloadSource::empty(),
|
|
asset_table: vec![],
|
|
preload: vec![],
|
|
}
|
|
}
|
|
|
|
fn valid_cartridge(app_mode: AppMode) -> Cartridge {
|
|
valid_cartridge_with(app_mode, 9, "Valid Cart")
|
|
}
|
|
|
|
fn valid_cartridge_with(app_mode: AppMode, app_id: u32, title: &str) -> Cartridge {
|
|
Cartridge {
|
|
app_id,
|
|
title: title.into(),
|
|
app_version: "1.0.0".into(),
|
|
app_mode,
|
|
capabilities: caps::NONE,
|
|
program: halting_program(),
|
|
assets: AssetsPayloadSource::empty(),
|
|
asset_table: vec![],
|
|
preload: vec![],
|
|
}
|
|
}
|
|
|
|
fn vm_session_tick_index(firmware: &mut Firmware, task_id: TaskId) -> u64 {
|
|
firmware
|
|
.os
|
|
.sessions()
|
|
.vm_session_for_task(task_id)
|
|
.expect("VM session should exist")
|
|
.runtime
|
|
.tick_index
|
|
}
|
|
|
|
fn load_shell_running_firmware() -> (Firmware, TestPlatform, InputSignals, TaskId) {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Shell));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::ShellRunning(step) => step.task_id,
|
|
other => panic!("expected ShellRunning state, got {:?}", other),
|
|
};
|
|
|
|
(firmware, platform, signals, task_id)
|
|
}
|
|
|
|
fn hub_home_firmware() -> (Firmware, TestPlatform) {
|
|
let mut firmware = Firmware::new(None);
|
|
firmware.state = FirmwareState::HubHome(HubHomeStep);
|
|
firmware.state_initialized = false;
|
|
|
|
(firmware, TestPlatform::new())
|
|
}
|
|
|
|
fn focus_window(firmware: &mut Firmware, owner: WindowOwner) {
|
|
let mut windows = firmware.os.windows();
|
|
windows.remove_all_windows();
|
|
let id = windows.add_window(
|
|
"Test Window".to_string(),
|
|
owner,
|
|
Rect { x: 0, y: 0, w: 10, h: 10 },
|
|
Color::WHITE,
|
|
);
|
|
windows.set_focus(id);
|
|
}
|
|
|
|
#[test]
|
|
fn load_cartridge_transitions_to_app_crashes_when_vm_init_fails() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(invalid_game_cartridge());
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
match &firmware.state {
|
|
FirmwareState::AppCrashes(step) => match &step.report {
|
|
CrashReport::VmInit { error } => {
|
|
assert!(matches!(error, prometeu_vm::VmInitError::InvalidFormat));
|
|
}
|
|
other => panic!("expected VmInit crash report, got {:?}", other),
|
|
},
|
|
other => panic!("expected AppCrashes state, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn game_running_transitions_to_app_crashes_when_runtime_surfaces_trap() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(trapping_game_cartridge());
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
};
|
|
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
match &firmware.state {
|
|
FirmwareState::AppCrashes(step) => match &step.report {
|
|
CrashReport::VmTrap { trap } => {
|
|
assert!(trap.message.contains("Expected integer at index 0"));
|
|
}
|
|
other => panic!("expected VmTrap crash report, got {:?}", other),
|
|
},
|
|
other => panic!("expected AppCrashes state, got {:?}", other),
|
|
}
|
|
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Crashed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Crashed)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reset_routes_hub_boot_target_to_splash_screen() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.boot_target = BootTarget::Hub;
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::SplashScreen(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn reset_routes_cartridge_boot_target_to_launch_hub() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.boot_target =
|
|
BootTarget::Cartridge { path: "missing-cart".into(), debug: false, debug_port: 7777 };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::LaunchHub(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn reset_clears_vm_sessions_and_runtime_identity() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Game));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert_eq!(firmware.os.sessions().vm_session_count(), 1);
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Valid Cart");
|
|
|
|
firmware.change_state(FirmwareState::Reset(ResetStep), &signals, &mut platform);
|
|
|
|
assert_eq!(firmware.os.sessions().vm_session_count(), 0);
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn load_cartridge_routes_system_apps_to_system_running() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Shell));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::ShellRunning(_)));
|
|
assert!(firmware.os.windows().focused_window().is_some());
|
|
assert_eq!(firmware.os.windows().window_count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn system_running_ticks_focused_system_app_through_hub_pipeline() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Shell));
|
|
firmware.tick(&signals, &mut platform);
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::ShellRunning(step) => step.task_id,
|
|
other => panic!("expected ShellRunning state, got {:?}", other),
|
|
};
|
|
|
|
let tick_index_before_system_update = firmware.os.vm().tick_index();
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::ShellRunning(_)));
|
|
assert!(firmware.os.windows().focused_window_belongs_to_task(task_id));
|
|
assert_eq!(firmware.os.vm().tick_index(), tick_index_before_system_update + 1);
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_does_not_launch_shell_before_home_input_is_armed() {
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
let signals = InputSignals { f_signal: true, x_pos: 112, y_pos: 172, ..Default::default() };
|
|
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.windows().window_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_click_launches_shell_a_as_native_shell_task_window() {
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let signals = InputSignals { f_signal: true, x_pos: 112, y_pos: 172, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::ShellRunning(step) => step.task_id,
|
|
other => panic!("expected ShellRunning state, got {:?}", other),
|
|
};
|
|
|
|
assert!(firmware.os.windows().focused_window_belongs_to_task(task_id));
|
|
assert_eq!(firmware.os.windows().window_count(), 1);
|
|
assert_eq!(firmware.os.windows().windows()[0].title, "ShellA");
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Foreground));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Running)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_click_launches_shell_b_as_native_shell_task_window() {
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let signals = InputSignals { f_signal: true, x_pos: 256, y_pos: 172, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::ShellRunning(step) => step.task_id,
|
|
other => panic!("expected ShellRunning state, got {:?}", other),
|
|
};
|
|
|
|
assert!(firmware.os.windows().focused_window_belongs_to_task(task_id));
|
|
assert_eq!(firmware.os.windows().window_count(), 1);
|
|
assert_eq!(firmware.os.windows().windows()[0].title, "ShellB");
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Foreground));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Running)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_click_launches_game_library_entry_through_load_cartridge() {
|
|
let root = TestGameRoot::new();
|
|
root.add_game("stress", true);
|
|
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.hub.set_game_library(discover_games_root(&root.path));
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let signals = InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
match &firmware.state {
|
|
FirmwareState::LoadCartridge(step) => {
|
|
assert_eq!(step.cartridge.title, "Stress");
|
|
assert_eq!(step.cartridge.app_id, 42);
|
|
}
|
|
other => panic!("expected LoadCartridge state, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn games_root_home_click_reaches_game_running() {
|
|
let root = TestGameRoot::new();
|
|
root.add_game("stress", true);
|
|
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.hub.set_game_library(discover_games_root(&root.path));
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let signals = InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress");
|
|
assert_eq!(firmware.os.windows().window_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_clicking_resident_game_resumes_same_task_without_reloading() {
|
|
let root = TestGameRoot::new();
|
|
root.add_game("stress", true);
|
|
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.hub.set_game_library(discover_games_root(&root.path));
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let launch_click =
|
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&launch_click, &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let game_task = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
};
|
|
|
|
firmware.request_home_from_host();
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task));
|
|
assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended));
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
firmware.tick(&launch_click, &mut platform);
|
|
|
|
match &firmware.state {
|
|
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, game_task),
|
|
other => panic!("expected resident GameRunning state, got {:?}", other),
|
|
}
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task));
|
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(game_task));
|
|
assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Foreground));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::ResumeForeground)
|
|
);
|
|
|
|
let held_click =
|
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&held_click, &mut platform);
|
|
|
|
assert!(!platform.input().pad().any());
|
|
assert!(!platform.input().touch().f().down);
|
|
assert!(firmware.os.lifecycle().pending_game_lifecycle_events().is_empty());
|
|
assert_eq!(
|
|
firmware.os.vm().delivered_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::ResumeForeground)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_clicking_different_game_terminates_previous_resident_session() {
|
|
let root = TestGameRoot::new();
|
|
root.add_game("stress", true);
|
|
root.add_game_with_identity("zzz-dummy", 43, "Zzz Dummy", "1.0.0", true);
|
|
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.hub.set_game_library(discover_games_root(&root.path));
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let stress_click =
|
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&stress_click, &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let first_game_task = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected first GameRunning state, got {:?}", other),
|
|
};
|
|
|
|
firmware.request_home_from_host();
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(first_game_task));
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
let dummy_click =
|
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
|
|
firmware.tick(&dummy_click, &mut platform);
|
|
|
|
assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(first_game_task),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
assert_eq!(
|
|
firmware
|
|
.os
|
|
.sessions()
|
|
.vm_session_for_task(first_game_task)
|
|
.map(|session| session.task_id),
|
|
None
|
|
);
|
|
|
|
let second_game_task = firmware
|
|
.os
|
|
.lifecycle()
|
|
.resident_game_task()
|
|
.expect("replacement game should be resident");
|
|
assert_ne!(second_game_task, first_game_task);
|
|
assert_eq!(
|
|
firmware
|
|
.os
|
|
.sessions()
|
|
.vm_session_for_task(second_game_task)
|
|
.map(|session| session.app_id),
|
|
Some(43)
|
|
);
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
match &firmware.state {
|
|
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, second_game_task),
|
|
other => panic!("expected replacement GameRunning state, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn repository_stress_to_dummy_boy_switch_is_end_to_end() {
|
|
let library = discover_games_root(repository_test_cartridges_root());
|
|
assert!(
|
|
library
|
|
.entries()
|
|
.iter()
|
|
.any(|entry| entry.app_id == 1 && entry.title == "Stress Console")
|
|
);
|
|
assert!(
|
|
library.entries().iter().any(|entry| entry.app_id == 2 && entry.title == "Dummy Boy")
|
|
);
|
|
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.hub.set_game_library(library);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let stress_click =
|
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
|
|
firmware.tick(&stress_click, &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let stress_task = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected Stress GameRunning state, got {:?}", other),
|
|
};
|
|
assert_eq!(firmware.os.vm().current_app_id(), 1);
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress Console");
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
|
Some(100)
|
|
);
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
|
Some(1)
|
|
);
|
|
|
|
firmware.request_home_from_host();
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
let hub_ownership = firmware.os.vm().active_render_ownership();
|
|
assert_eq!(hub_ownership.app_mode, AppMode::Shell);
|
|
assert_eq!(hub_ownership.app_id, 0);
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
|
Some(100)
|
|
);
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
|
Some(1)
|
|
);
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
let dummy_click =
|
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&dummy_click, &mut platform);
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
|
None
|
|
);
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
|
None
|
|
);
|
|
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_none());
|
|
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_none());
|
|
|
|
assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(stress_task),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
assert_eq!(
|
|
firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id),
|
|
None
|
|
);
|
|
|
|
let dummy_task = firmware
|
|
.os
|
|
.lifecycle()
|
|
.resident_game_task()
|
|
.expect("Dummy Boy should be resident after switch load");
|
|
assert_ne!(dummy_task, stress_task);
|
|
assert_eq!(
|
|
firmware.os.sessions().vm_session_for_task(dummy_task).map(|session| {
|
|
(session.app_id, session.title.clone(), session.app_version.clone())
|
|
}),
|
|
Some((2, "Dummy Boy".to_string(), "0.1.0".to_string()))
|
|
);
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
match &firmware.state {
|
|
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, dummy_task),
|
|
other => panic!("expected Dummy Boy GameRunning state, got {:?}", other),
|
|
}
|
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(dummy_task));
|
|
assert_eq!(firmware.os.vm().current_app_id(), 2);
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Dummy Boy");
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let dummy_ownership = firmware.os.vm().active_render_ownership();
|
|
assert_eq!(dummy_ownership.app_mode, AppMode::Game);
|
|
assert_eq!(dummy_ownership.app_id, 2);
|
|
|
|
let front = platform.local_hardware().gfx.front_buffer();
|
|
let center = 240 + 135 * 480;
|
|
assert_eq!(front[center], Color::GREEN.raw());
|
|
|
|
firmware.request_home_from_host();
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
let hub_ownership = firmware.os.vm().active_render_ownership();
|
|
assert_eq!(hub_ownership.app_mode, AppMode::Shell);
|
|
assert_eq!(hub_ownership.app_id, 0);
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
let stress_click =
|
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
|
|
firmware.tick(&stress_click, &mut platform);
|
|
|
|
assert_eq!(firmware.os.lifecycle().task_state(dummy_task), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(dummy_task),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
assert_eq!(
|
|
firmware.os.sessions().vm_session_for_task(dummy_task).map(|session| session.task_id),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
|
Some(100)
|
|
);
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
|
Some(1)
|
|
);
|
|
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_some());
|
|
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_some());
|
|
|
|
let second_stress_task = firmware
|
|
.os
|
|
.lifecycle()
|
|
.resident_game_task()
|
|
.expect("Stress should be resident after switching back");
|
|
assert_ne!(second_stress_task, stress_task);
|
|
assert_ne!(second_stress_task, dummy_task);
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
match &firmware.state {
|
|
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, second_stress_task),
|
|
other => panic!("expected second Stress GameRunning state, got {:?}", other),
|
|
}
|
|
assert_eq!(firmware.os.vm().current_app_id(), 1);
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress Console");
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let stress_ownership = firmware.os.vm().active_render_ownership();
|
|
assert_eq!(stress_ownership.app_mode, AppMode::Game);
|
|
assert_eq!(stress_ownership.app_id, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn resident_game_termination_noops_when_no_game_is_resident() {
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
let ownership_before = firmware.os.vm().active_render_ownership();
|
|
|
|
let outcome = resident_game_termination::terminate_resident_game(
|
|
&mut firmware.os,
|
|
&mut platform,
|
|
true,
|
|
)
|
|
.expect("no resident game termination should not fail");
|
|
|
|
assert_eq!(outcome, ResidentGameTerminationOutcome::NoResidentGame);
|
|
assert_eq!(firmware.os.vm().active_render_ownership(), ownership_before);
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), None);
|
|
assert!(
|
|
!firmware
|
|
.os
|
|
.recent_logs(16)
|
|
.iter()
|
|
.any(|event| event.msg.contains("Resident game killed from Hub"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resident_game_termination_closes_session_and_cleans_runtime_state() {
|
|
let library = discover_games_root(repository_test_cartridges_root());
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.hub.set_game_library(library);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let stress_click =
|
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
|
|
firmware.tick(&stress_click, &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let stress_task = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected Stress GameRunning state, got {:?}", other),
|
|
};
|
|
|
|
firmware.request_home_from_host();
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(stress_task));
|
|
assert!(firmware.game_lifecycle_audio_paused());
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
|
Some(100)
|
|
);
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
|
Some(1)
|
|
);
|
|
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_some());
|
|
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_some());
|
|
|
|
let outcome = resident_game_termination::terminate_resident_game(
|
|
&mut firmware.os,
|
|
&mut platform,
|
|
true,
|
|
)
|
|
.expect("resident game termination should succeed");
|
|
|
|
assert_eq!(
|
|
outcome,
|
|
ResidentGameTerminationOutcome::Terminated {
|
|
task_id: stress_task,
|
|
title: "Stress Console".to_string()
|
|
}
|
|
);
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), None);
|
|
assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(stress_task),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
assert_eq!(
|
|
firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id),
|
|
None
|
|
);
|
|
assert!(!firmware.game_lifecycle_audio_paused());
|
|
|
|
let ownership = firmware.os.vm().active_render_ownership();
|
|
assert_eq!(ownership.app_mode, AppMode::Shell);
|
|
assert_eq!(ownership.app_id, 0);
|
|
assert_eq!(platform.local_hardware().frame_composer.active_scene_id(), None);
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id,
|
|
None
|
|
);
|
|
assert_eq!(
|
|
platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id,
|
|
None
|
|
);
|
|
assert!(platform.local_hardware().memory_banks.glyph_bank_slot(0).is_none());
|
|
assert!(platform.local_hardware().memory_banks.scene_bank_slot(0).is_none());
|
|
|
|
assert!(
|
|
firmware.os.recent_logs(32).iter().any(|event| {
|
|
event.level == LogLevel::Info
|
|
&& event.source == LogSource::Hub
|
|
&& event.msg == "Resident game killed from Hub: Stress Console"
|
|
}),
|
|
"manual Hub kill should emit an INFO log"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_game_switch_returns_home_without_restoring_previous_game() {
|
|
let root = TestGameRoot::new();
|
|
root.add_game("stress", true);
|
|
root.add_game_with_program("zzz-broken", 43, "Zzz Broken", "1.0.0", Some(vec![0, 0, 0, 0]));
|
|
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.hub.set_game_library(discover_games_root(&root.path));
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let stress_click =
|
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&stress_click, &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let first_game_task = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected first GameRunning state, got {:?}", other),
|
|
};
|
|
|
|
firmware.request_home_from_host();
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let broken_click =
|
|
InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() };
|
|
firmware.tick(&broken_click, &mut platform);
|
|
|
|
assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware
|
|
.os
|
|
.sessions()
|
|
.vm_session_for_task(first_game_task)
|
|
.map(|session| session.task_id),
|
|
None
|
|
);
|
|
let partial_task = firmware
|
|
.os
|
|
.lifecycle()
|
|
.resident_game_task()
|
|
.expect("failed target should be resident until recovery tick");
|
|
assert_ne!(partial_task, first_game_task);
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), None);
|
|
assert_eq!(firmware.os.lifecycle().task_state(first_game_task), Some(TaskState::Closed));
|
|
assert_eq!(firmware.os.lifecycle().task_state(partial_task), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(partial_task),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
assert_eq!(
|
|
firmware.os.sessions().vm_session_for_task(partial_task).map(|session| session.task_id),
|
|
None
|
|
);
|
|
assert!(
|
|
firmware
|
|
.os
|
|
.recent_logs(16)
|
|
.iter()
|
|
.any(|event| event.msg.contains("Failed to switch to game Zzz Broken app_id 43"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() {
|
|
let root = TestGameRoot::new();
|
|
root.add_game("broken", false);
|
|
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
let library = discover_games_root(&root.path);
|
|
assert!(library.is_empty());
|
|
assert!(!library.diagnostics().is_empty());
|
|
firmware.hub.set_game_library(library);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let signals = InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn direct_boot_target_still_reaches_game_running_without_games_root() {
|
|
let root = TestGameRoot::new();
|
|
let cart = root.add_game("stress", true);
|
|
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
firmware.boot_target = BootTarget::Cartridge {
|
|
path: cart.display().to_string(),
|
|
debug: false,
|
|
debug_port: 7777,
|
|
};
|
|
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Stress");
|
|
}
|
|
|
|
#[test]
|
|
fn hub_home_failed_game_launch_remains_home_and_logs_error() {
|
|
let root = TestGameRoot::new();
|
|
let cart = root.add_game("stress", true);
|
|
|
|
let (mut firmware, mut platform) = hub_home_firmware();
|
|
firmware.hub.set_game_library(discover_games_root(&root.path));
|
|
fs::remove_file(cart.join("program.pbx")).expect("program should be removable");
|
|
firmware.tick(&InputSignals::default(), &mut platform);
|
|
|
|
let signals = InputSignals { f_signal: true, x_pos: 72, y_pos: 90, ..Default::default() };
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert!(
|
|
firmware
|
|
.os
|
|
.recent_logs(16)
|
|
.iter()
|
|
.any(|entry| entry.msg.contains("Failed to launch Home game"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_start_close_uses_lifecycle_and_returns_hub_home() {
|
|
let (mut firmware, mut platform, _signals, task_id) = load_shell_running_firmware();
|
|
let close_signals = InputSignals { start_signal: true, ..Default::default() };
|
|
|
|
firmware.tick(&close_signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_close_click_uses_lifecycle_and_returns_hub_home() {
|
|
let (mut firmware, mut platform, _signals, task_id) = load_shell_running_firmware();
|
|
let close_signals =
|
|
InputSignals { f_signal: true, x_pos: 420, y_pos: 24, ..Default::default() };
|
|
|
|
firmware.tick(&close_signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_closes_task_and_returns_hub_home_without_focused_window() {
|
|
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
|
|
|
|
firmware.os.windows().remove_all_windows();
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_closes_task_and_returns_hub_home_for_focused_hub_window() {
|
|
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
|
|
|
|
focus_window(&mut firmware, WindowOwner::Hub);
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_closes_task_and_returns_hub_home_for_focused_overlay_window() {
|
|
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
|
|
|
|
focus_window(&mut firmware, WindowOwner::Overlay);
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_running_closes_task_and_returns_hub_home_for_other_task_window() {
|
|
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
|
|
|
|
focus_window(&mut firmware, WindowOwner::Task(TaskId(task_id.0 + 1)));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Stopped)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn load_cartridge_routes_game_apps_to_game_running() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Game));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
|
assert_eq!(firmware.os.windows().window_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn host_home_request_delivers_pause_tick_before_forced_suspension() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Game));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
};
|
|
let tick_index_before_home = firmware.os.vm().tick_index();
|
|
let render_ownership_before_home = firmware.os.vm().active_render_ownership();
|
|
assert_eq!(render_ownership_before_home.app_mode, AppMode::Game);
|
|
assert_eq!(render_ownership_before_home.app_id, 9);
|
|
|
|
firmware.request_home_from_host();
|
|
|
|
assert!(firmware.game_lifecycle_audio_paused());
|
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(task_id));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().resident_game().map(|game| game.state),
|
|
Some(ResidentGameState::PauseRequested { remaining_ticks: 1 })
|
|
);
|
|
assert_eq!(
|
|
firmware.os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::Pause)
|
|
);
|
|
|
|
let held_signals =
|
|
InputSignals { up_signal: true, f_signal: true, x_pos: 72, y_pos: 90, ..signals };
|
|
firmware.tick(&held_signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Suspended));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().process_state_for_task(task_id),
|
|
Ok(ProcessState::Suspended)
|
|
);
|
|
assert_eq!(firmware.os.vm().tick_index(), tick_index_before_home + 1);
|
|
assert!(!platform.input().pad().any());
|
|
assert!(!platform.input().touch().f().down);
|
|
|
|
let render_ownership_after_home = firmware.os.vm().active_render_ownership();
|
|
assert_eq!(render_ownership_after_home.app_mode, AppMode::Shell);
|
|
assert_eq!(render_ownership_after_home.app_id, 0);
|
|
assert!(firmware.os.lifecycle().pending_game_lifecycle_events().is_empty());
|
|
assert_eq!(
|
|
firmware.os.vm().delivered_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::Pause)
|
|
);
|
|
|
|
let tick_index_after_suspension = firmware.os.vm().tick_index();
|
|
firmware.tick(&signals, &mut platform);
|
|
assert_eq!(firmware.os.vm().tick_index(), tick_index_after_suspension);
|
|
}
|
|
|
|
#[test]
|
|
fn resident_game_resume_restores_render_owner_after_resume_event_and_barriers_input() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Game));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let task_id = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
};
|
|
|
|
firmware.request_home_from_host();
|
|
firmware.tick(&signals, &mut platform);
|
|
let hub_ownership = firmware.os.vm().active_render_ownership();
|
|
assert_eq!(hub_ownership.app_mode, AppMode::Shell);
|
|
assert_eq!(hub_ownership.app_id, 0);
|
|
|
|
assert!(firmware.resume_resident_game_from_home());
|
|
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
|
assert!(!firmware.game_lifecycle_audio_paused());
|
|
assert_eq!(
|
|
firmware.os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::ResumeForeground)
|
|
);
|
|
|
|
let game_ownership = firmware.os.vm().active_render_ownership();
|
|
assert_eq!(game_ownership.app_mode, AppMode::Game);
|
|
assert_eq!(game_ownership.app_id, 9);
|
|
|
|
let held_signals =
|
|
InputSignals { up_signal: true, f_signal: true, x_pos: 72, y_pos: 90, ..signals };
|
|
firmware.tick(&held_signals, &mut platform);
|
|
|
|
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Foreground));
|
|
assert!(!platform.input().pad().any());
|
|
assert!(!platform.input().touch().f().down);
|
|
assert!(firmware.os.lifecycle().pending_game_lifecycle_events().is_empty());
|
|
assert_eq!(
|
|
firmware.os.vm().delivered_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::ResumeForeground)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn game_home_shell_hub_same_game_flow_is_end_to_end_deterministic() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Game));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let game_task = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
};
|
|
|
|
firmware.request_home_from_host();
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Hub);
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task));
|
|
assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended));
|
|
|
|
firmware.tick(&signals, &mut platform);
|
|
let shell_a_click =
|
|
InputSignals { f_signal: true, x_pos: 112, y_pos: 172, ..Default::default() };
|
|
firmware.tick(&shell_a_click, &mut platform);
|
|
|
|
let shell_task = match &firmware.state {
|
|
FirmwareState::ShellRunning(step) => step.task_id,
|
|
other => panic!("expected ShellRunning state, got {:?}", other),
|
|
};
|
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell_task));
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task));
|
|
assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended));
|
|
|
|
let tick_index_before_native_shell_updates = firmware.os.vm().tick_index();
|
|
for _ in 0..61 {
|
|
firmware.tick(&signals, &mut platform);
|
|
}
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::ShellRunning(_)));
|
|
assert_eq!(firmware.os.vm().tick_index(), tick_index_before_native_shell_updates);
|
|
assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended));
|
|
|
|
let close_shell = InputSignals { start_signal: true, ..Default::default() };
|
|
firmware.tick(&close_shell, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Hub);
|
|
assert_eq!(firmware.os.lifecycle().task_state(shell_task), Some(TaskState::Closed));
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task));
|
|
|
|
assert!(firmware.resume_resident_game_from_home());
|
|
|
|
match &firmware.state {
|
|
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, game_task),
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
}
|
|
assert_eq!(firmware.os.lifecycle().foreground_owner(), ForegroundOwner::Game(game_task));
|
|
assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Foreground));
|
|
assert_eq!(
|
|
firmware.os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind),
|
|
Some(GameLifecycleEventKind::ResumeForeground)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resident_game_and_vm_shell_sessions_tick_independently() {
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge_with(AppMode::Game, 9, "Resident Game"));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let game_task = match &firmware.state {
|
|
FirmwareState::GameRunning(step) => step.task_id,
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
};
|
|
|
|
firmware.tick(&signals, &mut platform);
|
|
let game_tick_before_home = vm_session_tick_index(&mut firmware, game_task);
|
|
|
|
firmware.request_home_from_host();
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended));
|
|
let game_tick_after_suspend = vm_session_tick_index(&mut firmware, game_task);
|
|
assert_eq!(game_tick_after_suspend, game_tick_before_home + 1);
|
|
|
|
firmware.load_cartridge(valid_cartridge_with(AppMode::Shell, 77, "VM Shell"));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
let shell_task = match &firmware.state {
|
|
FirmwareState::ShellRunning(step) => step.task_id,
|
|
other => panic!("expected ShellRunning state, got {:?}", other),
|
|
};
|
|
|
|
assert_ne!(shell_task, game_task);
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task));
|
|
assert_eq!(firmware.os.lifecycle().task_state(game_task), Some(TaskState::Suspended));
|
|
|
|
let shell_tick_before = vm_session_tick_index(&mut firmware, shell_task);
|
|
for _ in 0..3 {
|
|
firmware.tick(&signals, &mut platform);
|
|
}
|
|
|
|
assert_eq!(vm_session_tick_index(&mut firmware, game_task), game_tick_after_suspend);
|
|
assert_eq!(vm_session_tick_index(&mut firmware, shell_task), shell_tick_before + 3);
|
|
|
|
let close_shell = InputSignals { start_signal: true, ..Default::default() };
|
|
firmware.tick(&close_shell, &mut platform);
|
|
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
|
|
assert_eq!(firmware.os.lifecycle().task_state(shell_task), Some(TaskState::Closed));
|
|
assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(game_task));
|
|
|
|
assert!(firmware.resume_resident_game_from_home());
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
match &firmware.state {
|
|
FirmwareState::GameRunning(step) => assert_eq!(step.task_id, game_task),
|
|
other => panic!("expected GameRunning state, got {:?}", other),
|
|
}
|
|
assert!(vm_session_tick_index(&mut firmware, game_task) > game_tick_after_suspend);
|
|
}
|
|
|
|
#[test]
|
|
fn direct_game_boot_does_not_depend_on_userland_run_cart_syscall() {
|
|
assert!(Syscall::from_u32(0x0002).is_none());
|
|
|
|
let mut firmware = Firmware::new(None);
|
|
let mut platform = TestPlatform::new();
|
|
let signals = InputSignals::default();
|
|
|
|
firmware.load_cartridge(valid_cartridge(AppMode::Game));
|
|
firmware.tick(&signals, &mut platform);
|
|
|
|
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
|
assert_eq!(firmware.os.vm().current_cartridge_title(), "Valid Cart");
|
|
}
|
|
}
|