Merge pull request 'dev/foreground-stack-game-pause-shell-vm-backed' (#35) from dev/foreground-stack-game-pause-shell-vm-backed into master
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good
Reviewed-on: #35
This commit is contained in:
commit
313b896e7b
@ -1,11 +1,14 @@
|
|||||||
use crate::firmware::boot_target::BootTarget;
|
use crate::firmware::boot_target::BootTarget;
|
||||||
use crate::firmware::firmware_state::{FirmwareState, LoadCartridgeStep, ResetStep};
|
use crate::firmware::firmware_state::{
|
||||||
|
FirmwareState, GameRunningStep, LoadCartridgeStep, ResetStep,
|
||||||
|
};
|
||||||
use crate::firmware::prometeu_context::PrometeuContext;
|
use crate::firmware::prometeu_context::PrometeuContext;
|
||||||
|
use prometeu_hal::app_mode::AppMode;
|
||||||
use prometeu_hal::cartridge::Cartridge;
|
use prometeu_hal::cartridge::Cartridge;
|
||||||
|
use prometeu_hal::log::LogSource;
|
||||||
use prometeu_hal::telemetry::CertificationConfig;
|
use prometeu_hal::telemetry::CertificationConfig;
|
||||||
use prometeu_hal::{InputSignals, RuntimePlatform};
|
use prometeu_hal::{InputSignals, RuntimePlatform};
|
||||||
use prometeu_system::{PrometeuHub, SystemOS};
|
use prometeu_system::{PrometeuHub, ResidentGameState, SystemOS};
|
||||||
use prometeu_vm::VirtualMachine;
|
|
||||||
|
|
||||||
/// PROMETEU Firmware.
|
/// PROMETEU Firmware.
|
||||||
///
|
///
|
||||||
@ -23,8 +26,6 @@ use prometeu_vm::VirtualMachine;
|
|||||||
/// 2. Delegates the logic update to the current active state.
|
/// 2. Delegates the logic update to the current active state.
|
||||||
/// 3. Handles state transitions (e.g., from Loading to Playing).
|
/// 3. Handles state transitions (e.g., from Loading to Playing).
|
||||||
pub struct Firmware {
|
pub struct Firmware {
|
||||||
/// The execution engine (PVM) for user applications.
|
|
||||||
pub vm: VirtualMachine,
|
|
||||||
/// The underlying OS services (Syscalls, Filesystem, Telemetry).
|
/// The underlying OS services (Syscalls, Filesystem, Telemetry).
|
||||||
pub os: SystemOS,
|
pub os: SystemOS,
|
||||||
/// The internal state of the system launcher (Hub).
|
/// The internal state of the system launcher (Hub).
|
||||||
@ -35,18 +36,19 @@ pub struct Firmware {
|
|||||||
pub boot_target: BootTarget,
|
pub boot_target: BootTarget,
|
||||||
/// State-machine lifecycle tracker.
|
/// State-machine lifecycle tracker.
|
||||||
state_initialized: bool,
|
state_initialized: bool,
|
||||||
|
game_input_barrier_frames: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Firmware {
|
impl Firmware {
|
||||||
/// Initializes the firmware in the `Reset` state.
|
/// Initializes the firmware in the `Reset` state.
|
||||||
pub fn new(cap_config: Option<CertificationConfig>) -> Self {
|
pub fn new(cap_config: Option<CertificationConfig>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
vm: VirtualMachine::default(),
|
|
||||||
os: SystemOS::new(cap_config),
|
os: SystemOS::new(cap_config),
|
||||||
hub: PrometeuHub::new(),
|
hub: PrometeuHub::new(),
|
||||||
state: FirmwareState::Reset(ResetStep),
|
state: FirmwareState::Reset(ResetStep),
|
||||||
boot_target: BootTarget::Hub,
|
boot_target: BootTarget::Hub,
|
||||||
state_initialized: false,
|
state_initialized: false,
|
||||||
|
game_input_barrier_frames: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,23 +57,31 @@ impl Firmware {
|
|||||||
/// This method is called exactly once per Host frame (60Hz).
|
/// This method is called exactly once per Host frame (60Hz).
|
||||||
/// It updates peripheral signals and delegates the logic to the current state.
|
/// It updates peripheral signals and delegates the logic to the current state.
|
||||||
pub fn tick(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
|
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.
|
// 0. Process asset commits at the beginning of the frame boundary.
|
||||||
platform.assets_mut().apply_commits();
|
platform.assets_mut().apply_commits();
|
||||||
|
|
||||||
// 1. Update the peripheral state using the latest signals from the Host.
|
// 1. Update the peripheral state using the latest signals from the Host.
|
||||||
// This ensures input is consistent throughout the entire update.
|
// This ensures input is consistent throughout the entire update.
|
||||||
platform.input_mut().pad_mut().begin_frame(signals);
|
platform.input_mut().pad_mut().begin_frame(effective_signals);
|
||||||
platform.input_mut().touch_mut().begin_frame(signals);
|
platform.input_mut().touch_mut().begin_frame(effective_signals);
|
||||||
|
|
||||||
// 2. State machine lifecycle management.
|
// 2. State machine lifecycle management.
|
||||||
if !self.state_initialized {
|
if !self.state_initialized {
|
||||||
self.on_enter(signals, platform);
|
self.on_enter(effective_signals, platform);
|
||||||
self.state_initialized = true;
|
self.state_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update the current state and check for transitions.
|
// 3. Update the current state and check for transitions.
|
||||||
if let Some(next_state) = self.on_update(signals, platform) {
|
if let Some(next_state) = self.on_update(effective_signals, platform) {
|
||||||
self.change_state(next_state, signals, platform);
|
self.change_state(next_state, effective_signals, platform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,9 +92,23 @@ impl Firmware {
|
|||||||
signals: &InputSignals,
|
signals: &InputSignals,
|
||||||
platform: &mut dyn RuntimePlatform,
|
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.on_exit(signals, platform);
|
||||||
self.state = new_state;
|
self.state = new_state;
|
||||||
self.state_initialized = false;
|
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.
|
// Enter the new state immediately to avoid "empty" frames during transitions.
|
||||||
self.on_enter(signals, platform);
|
self.on_enter(signals, platform);
|
||||||
@ -94,7 +118,6 @@ impl Firmware {
|
|||||||
/// Dispatches the `on_enter` event to the current state implementation.
|
/// Dispatches the `on_enter` event to the current state implementation.
|
||||||
fn on_enter(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
|
fn on_enter(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
|
||||||
let mut req = PrometeuContext {
|
let mut req = PrometeuContext {
|
||||||
vm: &mut self.vm,
|
|
||||||
os: &mut self.os,
|
os: &mut self.os,
|
||||||
hub: &mut self.hub,
|
hub: &mut self.hub,
|
||||||
boot_target: &self.boot_target,
|
boot_target: &self.boot_target,
|
||||||
@ -107,6 +130,7 @@ impl Firmware {
|
|||||||
FirmwareState::LaunchHub(s) => s.on_enter(&mut req),
|
FirmwareState::LaunchHub(s) => s.on_enter(&mut req),
|
||||||
FirmwareState::HubHome(s) => s.on_enter(&mut req),
|
FirmwareState::HubHome(s) => s.on_enter(&mut req),
|
||||||
FirmwareState::LoadCartridge(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::GameRunning(s) => s.on_enter(&mut req),
|
||||||
FirmwareState::ShellRunning(s) => s.on_enter(&mut req),
|
FirmwareState::ShellRunning(s) => s.on_enter(&mut req),
|
||||||
FirmwareState::AppCrashes(s) => s.on_enter(&mut req),
|
FirmwareState::AppCrashes(s) => s.on_enter(&mut req),
|
||||||
@ -121,7 +145,6 @@ impl Firmware {
|
|||||||
platform: &mut dyn RuntimePlatform,
|
platform: &mut dyn RuntimePlatform,
|
||||||
) -> Option<FirmwareState> {
|
) -> Option<FirmwareState> {
|
||||||
let mut req = PrometeuContext {
|
let mut req = PrometeuContext {
|
||||||
vm: &mut self.vm,
|
|
||||||
os: &mut self.os,
|
os: &mut self.os,
|
||||||
hub: &mut self.hub,
|
hub: &mut self.hub,
|
||||||
boot_target: &self.boot_target,
|
boot_target: &self.boot_target,
|
||||||
@ -134,6 +157,7 @@ impl Firmware {
|
|||||||
FirmwareState::LaunchHub(s) => s.on_update(&mut req),
|
FirmwareState::LaunchHub(s) => s.on_update(&mut req),
|
||||||
FirmwareState::HubHome(s) => s.on_update(&mut req),
|
FirmwareState::HubHome(s) => s.on_update(&mut req),
|
||||||
FirmwareState::LoadCartridge(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::GameRunning(s) => s.on_update(&mut req),
|
||||||
FirmwareState::ShellRunning(s) => s.on_update(&mut req),
|
FirmwareState::ShellRunning(s) => s.on_update(&mut req),
|
||||||
FirmwareState::AppCrashes(s) => s.on_update(&mut req),
|
FirmwareState::AppCrashes(s) => s.on_update(&mut req),
|
||||||
@ -143,7 +167,6 @@ impl Firmware {
|
|||||||
/// Dispatches the `on_exit` event to the current state implementation.
|
/// Dispatches the `on_exit` event to the current state implementation.
|
||||||
fn on_exit(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
|
fn on_exit(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
|
||||||
let mut req = PrometeuContext {
|
let mut req = PrometeuContext {
|
||||||
vm: &mut self.vm,
|
|
||||||
os: &mut self.os,
|
os: &mut self.os,
|
||||||
hub: &mut self.hub,
|
hub: &mut self.hub,
|
||||||
boot_target: &self.boot_target,
|
boot_target: &self.boot_target,
|
||||||
@ -156,6 +179,7 @@ impl Firmware {
|
|||||||
FirmwareState::LaunchHub(s) => s.on_exit(&mut req),
|
FirmwareState::LaunchHub(s) => s.on_exit(&mut req),
|
||||||
FirmwareState::HubHome(s) => s.on_exit(&mut req),
|
FirmwareState::HubHome(s) => s.on_exit(&mut req),
|
||||||
FirmwareState::LoadCartridge(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::GameRunning(s) => s.on_exit(&mut req),
|
||||||
FirmwareState::ShellRunning(s) => s.on_exit(&mut req),
|
FirmwareState::ShellRunning(s) => s.on_exit(&mut req),
|
||||||
FirmwareState::AppCrashes(s) => s.on_exit(&mut req),
|
FirmwareState::AppCrashes(s) => s.on_exit(&mut req),
|
||||||
@ -166,6 +190,60 @@ impl Firmware {
|
|||||||
self.state = FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge));
|
self.state = FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge));
|
||||||
self.state_initialized = false;
|
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)]
|
#[cfg(test)]
|
||||||
@ -175,6 +253,7 @@ mod tests {
|
|||||||
use prometeu_bytecode::assembler::assemble;
|
use prometeu_bytecode::assembler::assemble;
|
||||||
use prometeu_bytecode::model::{BytecodeModule, FunctionMeta, SyscallDecl};
|
use prometeu_bytecode::model::{BytecodeModule, FunctionMeta, SyscallDecl};
|
||||||
use prometeu_drivers::TestPlatform;
|
use prometeu_drivers::TestPlatform;
|
||||||
|
use prometeu_hal::RuntimePlatform;
|
||||||
use prometeu_hal::app_mode::AppMode;
|
use prometeu_hal::app_mode::AppMode;
|
||||||
use prometeu_hal::cartridge::AssetsPayloadSource;
|
use prometeu_hal::cartridge::AssetsPayloadSource;
|
||||||
use prometeu_hal::color::Color;
|
use prometeu_hal::color::Color;
|
||||||
@ -183,7 +262,10 @@ mod tests {
|
|||||||
use prometeu_system::process::ProcessState;
|
use prometeu_system::process::ProcessState;
|
||||||
use prometeu_system::task::{TaskId, TaskState};
|
use prometeu_system::task::{TaskId, TaskState};
|
||||||
use prometeu_system::windows::WindowOwner;
|
use prometeu_system::windows::WindowOwner;
|
||||||
use prometeu_system::{CrashReport, discover_games_root};
|
use prometeu_system::{
|
||||||
|
CrashReport, ForegroundOwner, GameLifecycleEventKind, ResidentGameState,
|
||||||
|
discover_games_root,
|
||||||
|
};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
@ -302,9 +384,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn valid_cartridge(app_mode: AppMode) -> Cartridge {
|
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 {
|
Cartridge {
|
||||||
app_id: 9,
|
app_id,
|
||||||
title: "Valid Cart".into(),
|
title: title.into(),
|
||||||
app_version: "1.0.0".into(),
|
app_version: "1.0.0".into(),
|
||||||
app_mode,
|
app_mode,
|
||||||
capabilities: caps::NONE,
|
capabilities: caps::NONE,
|
||||||
@ -315,6 +401,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
fn load_shell_running_firmware() -> (Firmware, TestPlatform, InputSignals, TaskId) {
|
||||||
let mut firmware = Firmware::new(None);
|
let mut firmware = Firmware::new(None);
|
||||||
let mut platform = TestPlatform::new();
|
let mut platform = TestPlatform::new();
|
||||||
@ -429,6 +525,24 @@ mod tests {
|
|||||||
assert!(matches!(firmware.state, FirmwareState::LaunchHub(_)));
|
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]
|
#[test]
|
||||||
fn load_cartridge_routes_system_apps_to_system_running() {
|
fn load_cartridge_routes_system_apps_to_system_running() {
|
||||||
let mut firmware = Firmware::new(None);
|
let mut firmware = Firmware::new(None);
|
||||||
@ -560,6 +674,60 @@ mod tests {
|
|||||||
assert_eq!(firmware.os.windows().window_count(), 0);
|
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]
|
#[test]
|
||||||
fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() {
|
fn invalid_games_root_candidate_is_omitted_from_home_launch_boundary() {
|
||||||
let root = TestGameRoot::new();
|
let root = TestGameRoot::new();
|
||||||
@ -727,6 +895,236 @@ mod tests {
|
|||||||
assert_eq!(firmware.os.windows().window_count(), 0);
|
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();
|
||||||
|
|
||||||
|
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!(render_ownership_after_home.epoch > render_ownership_before_home.epoch);
|
||||||
|
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!(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);
|
||||||
|
assert!(game_ownership.epoch > hub_ownership.epoch);
|
||||||
|
|
||||||
|
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]
|
#[test]
|
||||||
fn direct_game_boot_does_not_depend_on_userland_run_cart_syscall() {
|
fn direct_game_boot_does_not_depend_on_userland_run_cart_syscall() {
|
||||||
assert!(Syscall::from_u32(0x0002).is_none());
|
assert!(Syscall::from_u32(0x0002).is_none());
|
||||||
|
|||||||
@ -14,6 +14,7 @@ pub enum FirmwareState {
|
|||||||
LaunchHub(LaunchHubStep),
|
LaunchHub(LaunchHubStep),
|
||||||
HubHome(HubHomeStep),
|
HubHome(HubHomeStep),
|
||||||
LoadCartridge(LoadCartridgeStep),
|
LoadCartridge(LoadCartridgeStep),
|
||||||
|
ResumeResidentGame,
|
||||||
GameRunning(GameRunningStep),
|
GameRunning(GameRunningStep),
|
||||||
ShellRunning(ShellRunningStep),
|
ShellRunning(ShellRunningStep),
|
||||||
AppCrashes(AppCrashesStep),
|
AppCrashes(AppCrashesStep),
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
use crate::firmware::firmware_state::{AppCrashesStep, FirmwareState};
|
use crate::firmware::firmware_state::{AppCrashesStep, FirmwareState, HubHomeStep};
|
||||||
use crate::firmware::prometeu_context::PrometeuContext;
|
use crate::firmware::prometeu_context::PrometeuContext;
|
||||||
|
use prometeu_hal::app_mode::AppMode;
|
||||||
use prometeu_hal::log::{LogLevel, LogSource};
|
use prometeu_hal::log::{LogLevel, LogSource};
|
||||||
use prometeu_system::CrashReport;
|
|
||||||
use prometeu_system::task::{TaskId, TaskState};
|
use prometeu_system::task::{TaskId, TaskState};
|
||||||
|
use prometeu_system::{CrashReport, ResidentGameState};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GameRunningStep {
|
pub struct GameRunningStep {
|
||||||
@ -36,13 +37,35 @@ impl GameRunningStep {
|
|||||||
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = ctx.os.vm().tick(ctx.vm, ctx.signals, ctx.platform);
|
let pause_requested = matches!(
|
||||||
|
ctx.os.lifecycle().resident_game().map(|game| game.state),
|
||||||
|
Some(ResidentGameState::PauseRequested { .. })
|
||||||
|
);
|
||||||
|
let result = ctx.os.vm().tick_session(self.task_id, ctx.signals, ctx.platform);
|
||||||
|
|
||||||
if let Some(report) = result {
|
if let Some(report) = result {
|
||||||
let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report));
|
let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report));
|
||||||
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pause_requested {
|
||||||
|
match ctx.os.lifecycle().advance_game_pause_budget(self.task_id) {
|
||||||
|
Ok(true) => {
|
||||||
|
ctx.os.vm().transition_render_owner(AppMode::Shell, 0);
|
||||||
|
return Some(FirmwareState::HubHome(HubHomeStep));
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(_) => {
|
||||||
|
let report = CrashReport::VmPanic {
|
||||||
|
message: "failed to advance game pause budget".to_string(),
|
||||||
|
pc: None,
|
||||||
|
};
|
||||||
|
let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report));
|
||||||
|
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ use crate::firmware::firmware_state::{
|
|||||||
AppCrashesStep, FirmwareState, LoadCartridgeStep, ShellRunningStep,
|
AppCrashesStep, FirmwareState, LoadCartridgeStep, ShellRunningStep,
|
||||||
};
|
};
|
||||||
use crate::firmware::prometeu_context::PrometeuContext;
|
use crate::firmware::prometeu_context::PrometeuContext;
|
||||||
|
use prometeu_hal::app_mode::AppMode;
|
||||||
use prometeu_hal::cartridge_loader::CartridgeLoader;
|
use prometeu_hal::cartridge_loader::CartridgeLoader;
|
||||||
use prometeu_hal::log::{LogLevel, LogSource};
|
use prometeu_hal::log::{LogLevel, LogSource};
|
||||||
use prometeu_hal::primitives::Rect;
|
use prometeu_hal::primitives::Rect;
|
||||||
@ -19,14 +20,29 @@ impl HubHomeStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
|
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
|
||||||
let outcome = ctx.hub.update_shell_profile(ctx.os, ctx.vm, ctx.signals, ctx.platform);
|
let outcome = ctx.hub.update_shell_profile(ctx.os, ctx.platform);
|
||||||
if let Some(report) = outcome.crash {
|
if let Some(report) = outcome.crash {
|
||||||
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
||||||
}
|
}
|
||||||
|
|
||||||
match outcome.action {
|
match outcome.action {
|
||||||
Some(SystemProfileAction::LaunchNativeShell(app)) => {
|
Some(SystemProfileAction::LaunchNativeShell(app)) => {
|
||||||
let task_id = ctx.os.sessions().create_native_shell_task(app.app_id(), app.title());
|
let task_id = match ctx
|
||||||
|
.os
|
||||||
|
.sessions()
|
||||||
|
.try_create_native_shell_task(app.app_id(), app.title())
|
||||||
|
{
|
||||||
|
Ok(task_id) => task_id,
|
||||||
|
Err(error) => {
|
||||||
|
ctx.os.log(
|
||||||
|
LogLevel::Warn,
|
||||||
|
LogSource::Hub,
|
||||||
|
0,
|
||||||
|
format!("Failed to launch native shell {}: {:?}", app.title(), error),
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
let id = ctx.os.windows().add_window(
|
let id = ctx.os.windows().add_window(
|
||||||
app.title().to_string(),
|
app.title().to_string(),
|
||||||
WindowOwner::Task(task_id),
|
WindowOwner::Task(task_id),
|
||||||
@ -38,6 +54,32 @@ impl HubHomeStep {
|
|||||||
}
|
}
|
||||||
Some(SystemProfileAction::LaunchGame { path }) => match CartridgeLoader::load(&path) {
|
Some(SystemProfileAction::LaunchGame { path }) => match CartridgeLoader::load(&path) {
|
||||||
Ok(cartridge) => {
|
Ok(cartridge) => {
|
||||||
|
if cartridge.app_mode == AppMode::Game
|
||||||
|
&& let Some(resident_task_id) = ctx.os.lifecycle().resident_game_task()
|
||||||
|
{
|
||||||
|
let resident_app_id = ctx
|
||||||
|
.os
|
||||||
|
.sessions()
|
||||||
|
.vm_session_for_task(resident_task_id)
|
||||||
|
.map(|session| session.app_id)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if resident_app_id == cartridge.app_id {
|
||||||
|
return Some(FirmwareState::ResumeResidentGame);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.os.log(
|
||||||
|
LogLevel::Warn,
|
||||||
|
LogSource::Hub,
|
||||||
|
0,
|
||||||
|
format!(
|
||||||
|
"Cannot launch Home game {} while resident game app_id {} is active",
|
||||||
|
cartridge.title, resident_app_id
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge)));
|
return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge)));
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|||||||
@ -14,11 +14,13 @@ use prometeu_system::windows::WindowOwner;
|
|||||||
pub struct LoadCartridgeStep {
|
pub struct LoadCartridgeStep {
|
||||||
pub cartridge: Cartridge,
|
pub cartridge: Cartridge,
|
||||||
init_error: Option<CrashReport>,
|
init_error: Option<CrashReport>,
|
||||||
|
loaded_task_id: Option<prometeu_system::task::TaskId>,
|
||||||
|
loaded_app_mode: Option<AppMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoadCartridgeStep {
|
impl LoadCartridgeStep {
|
||||||
pub fn new(cartridge: Cartridge) -> Self {
|
pub fn new(cartridge: Cartridge) -> Self {
|
||||||
Self { cartridge, init_error: None }
|
Self { cartridge, init_error: None, loaded_task_id: None, loaded_app_mode: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
|
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
|
||||||
@ -36,7 +38,15 @@ impl LoadCartridgeStep {
|
|||||||
self.cartridge.assets.clone(),
|
self.cartridge.assets.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
self.init_error = ctx.os.vm().initialize(ctx.vm, &self.cartridge).err();
|
match ctx.os.sessions().load_vm_cartridge(&self.cartridge) {
|
||||||
|
Ok(loaded) => {
|
||||||
|
self.loaded_task_id = Some(loaded.task_id);
|
||||||
|
self.loaded_app_mode = Some(loaded.app_mode);
|
||||||
|
}
|
||||||
|
Err(report) => {
|
||||||
|
self.init_error = Some(report.into_crash_report("load VM cartridge into session"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
|
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
|
||||||
@ -44,11 +54,18 @@ impl LoadCartridgeStep {
|
|||||||
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.cartridge.app_mode == AppMode::Shell {
|
let Some(task_id) = self.loaded_task_id.take() else {
|
||||||
let task_id = ctx
|
let report = CrashReport::VmPanic {
|
||||||
.os
|
message: format!(
|
||||||
.sessions()
|
"cartridge {} was not loaded into a VM session",
|
||||||
.create_vm_shell_task(self.cartridge.app_id, self.cartridge.title.clone());
|
self.cartridge.title
|
||||||
|
),
|
||||||
|
pc: None,
|
||||||
|
};
|
||||||
|
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.loaded_app_mode == Some(AppMode::Shell) {
|
||||||
let id = ctx.os.windows().add_window(
|
let id = ctx.os.windows().add_window(
|
||||||
self.cartridge.title.clone(),
|
self.cartridge.title.clone(),
|
||||||
WindowOwner::Task(task_id),
|
WindowOwner::Task(task_id),
|
||||||
@ -59,10 +76,6 @@ impl LoadCartridgeStep {
|
|||||||
return Some(FirmwareState::ShellRunning(ShellRunningStep::new(task_id)));
|
return Some(FirmwareState::ShellRunning(ShellRunningStep::new(task_id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let task_id = ctx
|
|
||||||
.os
|
|
||||||
.sessions()
|
|
||||||
.create_vm_game_task(self.cartridge.app_id, self.cartridge.title.clone());
|
|
||||||
Some(FirmwareState::GameRunning(GameRunningStep::new(task_id)))
|
Some(FirmwareState::GameRunning(GameRunningStep::new(task_id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,8 @@ pub struct ResetStep;
|
|||||||
impl ResetStep {
|
impl ResetStep {
|
||||||
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
|
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
|
||||||
ctx.os.log(LogLevel::Info, LogSource::Pos, 0, "Firmware Reset".to_string());
|
ctx.os.log(LogLevel::Info, LogSource::Pos, 0, "Firmware Reset".to_string());
|
||||||
ctx.os.vm().reset(ctx.vm);
|
ctx.os.sessions().clear_vm_sessions();
|
||||||
|
ctx.os.vm().reset_global_runtime_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
|
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use crate::firmware::firmware_state::{AppCrashesStep, FirmwareState, HubHomeStep};
|
use crate::firmware::firmware_state::{AppCrashesStep, FirmwareState, HubHomeStep};
|
||||||
use crate::firmware::prometeu_context::PrometeuContext;
|
use crate::firmware::prometeu_context::PrometeuContext;
|
||||||
use prometeu_hal::log::LogSource;
|
use prometeu_hal::log::LogSource;
|
||||||
|
use prometeu_system::process::ProcessKind;
|
||||||
use prometeu_system::task::{TaskId, TaskState};
|
use prometeu_system::task::{TaskId, TaskState};
|
||||||
use prometeu_system::{CrashReport, SystemProfileAction};
|
use prometeu_system::{CrashReport, SystemProfileAction};
|
||||||
|
|
||||||
@ -59,7 +60,36 @@ impl ShellRunningStep {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let outcome = ctx.hub.update_shell_profile(ctx.os, ctx.vm, ctx.signals, ctx.platform);
|
let process_kind = match ctx.os.lifecycle().process_kind_for_task(self.task_id) {
|
||||||
|
Ok(process_kind) => process_kind,
|
||||||
|
Err(error) => {
|
||||||
|
let report = CrashReport::VmPanic {
|
||||||
|
message: format!(
|
||||||
|
"ShellRunningStep cannot resolve process for {:?}: {:?}",
|
||||||
|
self.task_id, error,
|
||||||
|
),
|
||||||
|
pc: None,
|
||||||
|
};
|
||||||
|
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = match process_kind {
|
||||||
|
ProcessKind::VmShell => {
|
||||||
|
ctx.hub.update_vm_shell_profile(ctx.os, self.task_id, ctx.signals, ctx.platform)
|
||||||
|
}
|
||||||
|
ProcessKind::NativeShell => ctx.hub.update_native_shell_profile(ctx.os, ctx.platform),
|
||||||
|
ProcessKind::VmGame => {
|
||||||
|
let report = CrashReport::VmPanic {
|
||||||
|
message: format!(
|
||||||
|
"ShellRunningStep expected shell process for {:?}, got VmGame",
|
||||||
|
self.task_id,
|
||||||
|
),
|
||||||
|
pc: None,
|
||||||
|
};
|
||||||
|
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(report) = outcome.crash {
|
if let Some(report) = outcome.crash {
|
||||||
let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report));
|
let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report));
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
use crate::firmware::boot_target::BootTarget;
|
use crate::firmware::boot_target::BootTarget;
|
||||||
use prometeu_hal::{InputSignals, RuntimePlatform};
|
use prometeu_hal::{InputSignals, RuntimePlatform};
|
||||||
use prometeu_system::{PrometeuHub, SystemOS};
|
use prometeu_system::{PrometeuHub, SystemOS};
|
||||||
use prometeu_vm::VirtualMachine;
|
|
||||||
|
|
||||||
pub struct PrometeuContext<'a> {
|
pub struct PrometeuContext<'a> {
|
||||||
pub vm: &'a mut VirtualMachine,
|
|
||||||
pub os: &'a mut SystemOS,
|
pub os: &'a mut SystemOS,
|
||||||
pub hub: &'a mut PrometeuHub,
|
pub hub: &'a mut PrometeuHub,
|
||||||
pub boot_target: &'a BootTarget,
|
pub boot_target: &'a BootTarget,
|
||||||
|
|||||||
@ -40,7 +40,7 @@ pub enum DebugResponse {
|
|||||||
Breakpoints { pcs: Vec<usize> },
|
Breakpoints { pcs: Vec<usize> },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct HandshakeCartridge {
|
pub struct HandshakeCartridge {
|
||||||
pub app_id: u32,
|
pub app_id: u32,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|||||||
@ -4,7 +4,10 @@ mod programs;
|
|||||||
mod services;
|
mod services;
|
||||||
|
|
||||||
pub use crash_report::CrashReport;
|
pub use crash_report::CrashReport;
|
||||||
pub use os::{LifecycleError, LifecycleOperation, SystemOS};
|
pub use os::{
|
||||||
|
DEFAULT_GAME_PAUSE_BUDGET_TICKS, GameLifecycleEvent, GameLifecycleEventKind, LifecycleError,
|
||||||
|
LifecycleOperation, SessionError, SystemOS,
|
||||||
|
};
|
||||||
pub use programs::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate};
|
pub use programs::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate};
|
||||||
pub use prometeu_hal::{RenderWorkerBackend, RenderWorkerFrameSink};
|
pub use prometeu_hal::{RenderWorkerBackend, RenderWorkerFrameSink};
|
||||||
pub use services::async_work::{
|
pub use services::async_work::{
|
||||||
@ -12,6 +15,9 @@ pub use services::async_work::{
|
|||||||
AsyncWorkJobKind, AsyncWorkJobOutcome, AsyncWorkLane, AsyncWorkLaneConfig,
|
AsyncWorkJobKind, AsyncWorkJobOutcome, AsyncWorkLane, AsyncWorkLaneConfig,
|
||||||
AsyncWorkLaneController, AsyncWorkLaneError, AsyncWorkLaneTelemetry, AsyncWorkPriority,
|
AsyncWorkLaneController, AsyncWorkLaneError, AsyncWorkLaneTelemetry, AsyncWorkPriority,
|
||||||
};
|
};
|
||||||
|
pub use services::foreground::{
|
||||||
|
ForegroundOwner, ForegroundStack, ForegroundStackError, ResidentGame, ResidentGameState,
|
||||||
|
};
|
||||||
pub use services::fs;
|
pub use services::fs;
|
||||||
pub use services::game_library::{
|
pub use services::game_library::{
|
||||||
GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, discover_games_root,
|
GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, discover_games_root,
|
||||||
@ -23,4 +29,5 @@ pub use services::vm_runtime::{
|
|||||||
LatestRenderFrameStore, RenderWorkerConfig, RenderWorkerController, RenderWorkerHandoff,
|
LatestRenderFrameStore, RenderWorkerConfig, RenderWorkerController, RenderWorkerHandoff,
|
||||||
RenderWorkerHandoffWait, RenderWorkerOwnership, VirtualMachineRuntime,
|
RenderWorkerHandoffWait, RenderWorkerOwnership, VirtualMachineRuntime,
|
||||||
};
|
};
|
||||||
|
pub use services::vm_session;
|
||||||
pub use services::windows;
|
pub use services::windows;
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
use crate::CrashReport;
|
use crate::CrashReport;
|
||||||
use crate::os::SystemOS;
|
use crate::os::SystemOS;
|
||||||
use crate::os::{LifecycleError, LifecycleOperation};
|
use crate::os::{
|
||||||
use crate::process::{ProcessId, ProcessState};
|
DEFAULT_GAME_PAUSE_BUDGET_TICKS, GameLifecycleEvent, GameLifecycleEventKind, LifecycleError,
|
||||||
use crate::task::{TaskId, TaskState};
|
LifecycleOperation,
|
||||||
|
};
|
||||||
|
use crate::process::{Process, ProcessId, ProcessKind, ProcessState};
|
||||||
|
use crate::services::foreground::{
|
||||||
|
ForegroundOwner, ForegroundStackError, ResidentGame, ResidentGameState,
|
||||||
|
};
|
||||||
|
use crate::task::{TaskId, TaskKind, TaskState};
|
||||||
|
|
||||||
pub struct LifecycleFacade<'a> {
|
pub struct LifecycleFacade<'a> {
|
||||||
pub(in crate::os) os: &'a mut SystemOS,
|
pub(in crate::os) os: &'a mut SystemOS,
|
||||||
@ -14,28 +20,145 @@ impl<'a> LifecycleFacade<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn process_state_for_task(&self, task_id: TaskId) -> Result<ProcessState, LifecycleError> {
|
pub fn process_state_for_task(&self, task_id: TaskId) -> Result<ProcessState, LifecycleError> {
|
||||||
let process_id = process_id_for_task(self.os, task_id)?;
|
process_for_task(self.os, task_id).map(|process| process.state)
|
||||||
self.os
|
}
|
||||||
.process_manager
|
|
||||||
.get(process_id)
|
pub fn process_kind_for_task(&self, task_id: TaskId) -> Result<ProcessKind, LifecycleError> {
|
||||||
.map(|process| process.state)
|
process_for_task(self.os, task_id).map(|process| process.kind)
|
||||||
.ok_or(LifecycleError::ProcessNotFound(process_id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_foreground_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
|
pub fn set_foreground_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
|
||||||
|
match task_kind_for_task(self.os, task_id)? {
|
||||||
|
TaskKind::Game => self.set_game_foreground_task(task_id),
|
||||||
|
TaskKind::Shell => self.set_shell_foreground_task(task_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_game_foreground_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
|
||||||
let process_id = process_id_for_task(self.os, task_id)?;
|
let process_id = process_id_for_task(self.os, task_id)?;
|
||||||
|
|
||||||
|
if task_kind_for_task(self.os, task_id)? != TaskKind::Game {
|
||||||
|
return Err(LifecycleError::InvalidTransition {
|
||||||
|
task_id,
|
||||||
|
from: self.task_state(task_id).unwrap_or(TaskState::Closed),
|
||||||
|
operation: LifecycleOperation::SetForeground,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.os.foreground_stack.set_game_foreground(task_id).map_err(map_foreground_error)?;
|
||||||
self.os.task_manager.set_foreground(task_id);
|
self.os.task_manager.set_foreground(task_id);
|
||||||
self.os.process_manager.mark_running(process_id);
|
self.os.process_manager.mark_running(process_id);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_shell_foreground_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
|
||||||
|
let process_id = process_id_for_task(self.os, task_id)?;
|
||||||
|
|
||||||
|
if task_kind_for_task(self.os, task_id)? != TaskKind::Shell {
|
||||||
|
return Err(LifecycleError::InvalidTransition {
|
||||||
|
task_id,
|
||||||
|
from: self.task_state(task_id).unwrap_or(TaskState::Closed),
|
||||||
|
operation: LifecycleOperation::SetForeground,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(game_task_id) = self.os.foreground_stack.resident_game_task()
|
||||||
|
&& game_task_id != task_id
|
||||||
|
&& self.task_state(game_task_id) != Some(TaskState::Suspended)
|
||||||
|
{
|
||||||
|
let game_process_id = process_id_for_task(self.os, game_task_id)?;
|
||||||
|
self.os.task_manager.mark_suspended(game_task_id);
|
||||||
|
self.os.process_manager.mark_suspended(game_process_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.os.foreground_stack.set_shell_foreground(task_id).map_err(map_foreground_error)?;
|
||||||
|
self.os.task_manager.set_foreground(task_id);
|
||||||
|
self.os.process_manager.mark_running(process_id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn foreground_owner(&self) -> ForegroundOwner {
|
||||||
|
self.os.foreground_stack.owner()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resident_game(&self) -> Option<ResidentGame> {
|
||||||
|
self.os.foreground_stack.resident_game()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resident_game_task(&self) -> Option<TaskId> {
|
||||||
|
self.os.foreground_stack.resident_game_task()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_home_from_game(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
|
||||||
|
let _process_id = process_id_for_task(self.os, task_id)?;
|
||||||
|
let previous = self.os.foreground_stack.resident_game();
|
||||||
|
|
||||||
|
self.os
|
||||||
|
.foreground_stack
|
||||||
|
.request_home_from_game(task_id, DEFAULT_GAME_PAUSE_BUDGET_TICKS)
|
||||||
|
.map_err(map_foreground_error)?;
|
||||||
|
|
||||||
|
if !matches!(
|
||||||
|
previous.map(|game| game.state),
|
||||||
|
Some(ResidentGameState::PauseRequested { .. } | ResidentGameState::PausedSuspended)
|
||||||
|
) {
|
||||||
|
self.os
|
||||||
|
.game_lifecycle_events
|
||||||
|
.push(GameLifecycleEvent { task_id, kind: GameLifecycleEventKind::Pause });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advance_game_pause_budget(&mut self, task_id: TaskId) -> Result<bool, LifecycleError> {
|
||||||
|
let expired =
|
||||||
|
self.os.foreground_stack.advance_pause_budget(task_id).map_err(map_foreground_error)?;
|
||||||
|
|
||||||
|
if expired {
|
||||||
|
let process_id = process_id_for_task(self.os, task_id)?;
|
||||||
|
self.os.task_manager.mark_suspended(task_id);
|
||||||
|
self.os.process_manager.mark_suspended(process_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_game_lifecycle_events(&self) -> &[GameLifecycleEvent] {
|
||||||
|
&self.os.game_lifecycle_events
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_game_lifecycle_events(&mut self) -> Vec<GameLifecycleEvent> {
|
||||||
|
std::mem::take(&mut self.os.game_lifecycle_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn return_to_hub(&mut self) {
|
||||||
|
self.os.foreground_stack.return_to_hub();
|
||||||
|
self.os.task_manager.clear_foreground();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn suspend_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
|
pub fn suspend_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
|
||||||
let process_id = process_id_for_task(self.os, task_id)?;
|
let process_id = process_id_for_task(self.os, task_id)?;
|
||||||
|
|
||||||
self.os.task_manager.mark_suspended(task_id);
|
self.os.task_manager.mark_suspended(task_id);
|
||||||
self.os.process_manager.mark_suspended(process_id);
|
self.os.process_manager.mark_suspended(process_id);
|
||||||
|
match self.os.foreground_stack.owner() {
|
||||||
|
ForegroundOwner::Game(owner) if owner == task_id => {
|
||||||
|
self.os
|
||||||
|
.foreground_stack
|
||||||
|
.request_home_from_game(task_id, DEFAULT_GAME_PAUSE_BUDGET_TICKS)
|
||||||
|
.map_err(map_foreground_error)?;
|
||||||
|
self.os
|
||||||
|
.foreground_stack
|
||||||
|
.advance_pause_budget(task_id)
|
||||||
|
.map_err(map_foreground_error)?;
|
||||||
|
}
|
||||||
|
ForegroundOwner::Shell(owner) if owner == task_id => {
|
||||||
|
self.os.foreground_stack.return_to_hub();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -54,8 +177,32 @@ impl<'a> LifecycleFacade<'a> {
|
|||||||
|
|
||||||
let process_id = process_id_for_task(self.os, task_id)?;
|
let process_id = process_id_for_task(self.os, task_id)?;
|
||||||
|
|
||||||
self.os.task_manager.set_foreground(task_id);
|
match task_kind_for_task(self.os, task_id)? {
|
||||||
self.os.process_manager.mark_running(process_id);
|
TaskKind::Game => {
|
||||||
|
self.os
|
||||||
|
.foreground_stack
|
||||||
|
.request_resume_game(task_id)
|
||||||
|
.map_err(map_foreground_error)?;
|
||||||
|
self.os.task_manager.set_foreground(task_id);
|
||||||
|
self.os.process_manager.mark_running(process_id);
|
||||||
|
self.os.game_lifecycle_events.push(GameLifecycleEvent {
|
||||||
|
task_id,
|
||||||
|
kind: GameLifecycleEventKind::ResumeForeground,
|
||||||
|
});
|
||||||
|
self.os
|
||||||
|
.foreground_stack
|
||||||
|
.complete_resume_game(task_id)
|
||||||
|
.map_err(map_foreground_error)?;
|
||||||
|
}
|
||||||
|
TaskKind::Shell => {
|
||||||
|
self.os
|
||||||
|
.foreground_stack
|
||||||
|
.set_shell_foreground(task_id)
|
||||||
|
.map_err(map_foreground_error)?;
|
||||||
|
self.os.task_manager.set_foreground(task_id);
|
||||||
|
self.os.process_manager.mark_running(process_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -65,6 +212,12 @@ impl<'a> LifecycleFacade<'a> {
|
|||||||
|
|
||||||
self.os.task_manager.close_task(task_id);
|
self.os.task_manager.close_task(task_id);
|
||||||
self.os.process_manager.mark_stopped(process_id);
|
self.os.process_manager.mark_stopped(process_id);
|
||||||
|
if self.os.foreground_stack.resident_game_task() == Some(task_id) {
|
||||||
|
self.os.foreground_stack.clear_resident_game(task_id);
|
||||||
|
} else if matches!(self.os.foreground_stack.owner(), ForegroundOwner::Shell(owner) if owner == task_id)
|
||||||
|
{
|
||||||
|
self.os.foreground_stack.return_to_hub();
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -78,11 +231,22 @@ impl<'a> LifecycleFacade<'a> {
|
|||||||
|
|
||||||
self.os.task_manager.mark_crashed(task_id);
|
self.os.task_manager.mark_crashed(task_id);
|
||||||
self.os.process_manager.mark_crashed(process_id);
|
self.os.process_manager.mark_crashed(process_id);
|
||||||
|
if self.os.foreground_stack.resident_game_task() == Some(task_id) {
|
||||||
|
self.os.foreground_stack.clear_resident_game(task_id);
|
||||||
|
} else if matches!(self.os.foreground_stack.owner(), ForegroundOwner::Shell(owner) if owner == task_id)
|
||||||
|
{
|
||||||
|
self.os.foreground_stack.return_to_hub();
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn task_kind_for_task(os: &SystemOS, task_id: TaskId) -> Result<TaskKind, LifecycleError> {
|
||||||
|
let task = os.task_manager.get(task_id).ok_or(LifecycleError::TaskNotFound(task_id))?;
|
||||||
|
Ok(task.kind)
|
||||||
|
}
|
||||||
|
|
||||||
fn process_id_for_task(os: &SystemOS, task_id: TaskId) -> Result<ProcessId, LifecycleError> {
|
fn process_id_for_task(os: &SystemOS, task_id: TaskId) -> Result<ProcessId, LifecycleError> {
|
||||||
let task = os.task_manager.get(task_id).ok_or(LifecycleError::TaskNotFound(task_id))?;
|
let task = os.task_manager.get(task_id).ok_or(LifecycleError::TaskNotFound(task_id))?;
|
||||||
|
|
||||||
@ -92,3 +256,22 @@ fn process_id_for_task(os: &SystemOS, task_id: TaskId) -> Result<ProcessId, Life
|
|||||||
|
|
||||||
Ok(task.process_id)
|
Ok(task.process_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn process_for_task(os: &SystemOS, task_id: TaskId) -> Result<&Process, LifecycleError> {
|
||||||
|
let process_id = process_id_for_task(os, task_id)?;
|
||||||
|
os.process_manager.get(process_id).ok_or(LifecycleError::ProcessNotFound(process_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_foreground_error(error: ForegroundStackError) -> LifecycleError {
|
||||||
|
match error {
|
||||||
|
ForegroundStackError::ResidentGameAlreadyExists { existing, requested } => {
|
||||||
|
LifecycleError::ResidentGameAlreadyExists { existing, requested }
|
||||||
|
}
|
||||||
|
ForegroundStackError::ForegroundShellAlreadyExists { existing, requested } => {
|
||||||
|
LifecycleError::ForegroundShellAlreadyExists { existing, requested }
|
||||||
|
}
|
||||||
|
ForegroundStackError::TaskIsNotResidentGame(task_id) => {
|
||||||
|
LifecycleError::TaskIsNotResidentGame(task_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,6 @@ mod window;
|
|||||||
|
|
||||||
pub use fs::FsFacade;
|
pub use fs::FsFacade;
|
||||||
pub use lifecycle::LifecycleFacade;
|
pub use lifecycle::LifecycleFacade;
|
||||||
pub use sessions::SessionsFacade;
|
pub use sessions::{SessionError, SessionsFacade};
|
||||||
pub use vm::VmFacade;
|
pub use vm::VmFacade;
|
||||||
pub use window::WindowFacade;
|
pub use window::WindowFacade;
|
||||||
|
|||||||
@ -1,44 +1,209 @@
|
|||||||
|
use crate::CrashReport;
|
||||||
|
use crate::os::LifecycleError;
|
||||||
use crate::os::SystemOS;
|
use crate::os::SystemOS;
|
||||||
|
use crate::process::ProcessId;
|
||||||
use crate::task::TaskId;
|
use crate::task::TaskId;
|
||||||
|
use crate::vm_session::{VmSession, VmSessionError};
|
||||||
|
use prometeu_hal::app_mode::AppMode;
|
||||||
|
use prometeu_hal::cartridge::Cartridge;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct LoadedVmCartridge {
|
||||||
|
pub task_id: TaskId,
|
||||||
|
pub app_mode: AppMode,
|
||||||
|
pub reused_existing_session: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SessionError {
|
||||||
|
Lifecycle(LifecycleError),
|
||||||
|
VmSession(VmSessionError),
|
||||||
|
TaskNotFound(TaskId),
|
||||||
|
ProcessNotFound(ProcessId),
|
||||||
|
MissingVmSession(TaskId),
|
||||||
|
Initialization(CrashReport),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionError {
|
||||||
|
pub fn into_crash_report(self, operation: &str) -> CrashReport {
|
||||||
|
match self {
|
||||||
|
Self::Initialization(report) => report,
|
||||||
|
error => {
|
||||||
|
CrashReport::VmPanic { message: format!("{operation} failed: {error:?}"), pc: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LifecycleError> for SessionError {
|
||||||
|
fn from(error: LifecycleError) -> Self {
|
||||||
|
Self::Lifecycle(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<VmSessionError> for SessionError {
|
||||||
|
fn from(error: VmSessionError) -> Self {
|
||||||
|
Self::VmSession(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SessionsFacade<'a> {
|
pub struct SessionsFacade<'a> {
|
||||||
pub(in crate::os) os: &'a mut SystemOS,
|
pub(in crate::os) os: &'a mut SystemOS,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> SessionsFacade<'a> {
|
impl<'a> SessionsFacade<'a> {
|
||||||
|
#[cfg(test)]
|
||||||
pub fn create_vm_game_task(&mut self, app_id: u32, title: impl Into<String>) -> TaskId {
|
pub fn create_vm_game_task(&mut self, app_id: u32, title: impl Into<String>) -> TaskId {
|
||||||
|
self.try_create_vm_game_task(app_id, title)
|
||||||
|
.expect("test/setup VM game creation should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_create_vm_game_task(
|
||||||
|
&mut self,
|
||||||
|
app_id: u32,
|
||||||
|
title: impl Into<String>,
|
||||||
|
) -> Result<TaskId, SessionError> {
|
||||||
|
if let Some(session) = self.os.vm_sessions.resident_game_for_app(app_id) {
|
||||||
|
return Ok(session.task_id);
|
||||||
|
}
|
||||||
|
|
||||||
let title = title.into();
|
let title = title.into();
|
||||||
let process_id = self.os.process_manager.spawn_vm_game(app_id, title.clone());
|
let process_id = self.os.process_manager.spawn_vm_game(app_id, title.clone());
|
||||||
let task_id = self.os.task_manager.create_game_task(process_id, app_id, title);
|
let task_id = self.os.task_manager.create_game_task(process_id, app_id, title);
|
||||||
self.os
|
self.os.lifecycle().set_game_foreground_task(task_id)?;
|
||||||
.lifecycle()
|
self.try_create_vm_session_for_task(task_id)?;
|
||||||
.set_foreground_task(task_id)
|
|
||||||
.expect("newly created game task should have an associated process");
|
|
||||||
|
|
||||||
task_id
|
Ok(task_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub fn create_vm_shell_task(&mut self, app_id: u32, title: impl Into<String>) -> TaskId {
|
pub fn create_vm_shell_task(&mut self, app_id: u32, title: impl Into<String>) -> TaskId {
|
||||||
|
self.try_create_vm_shell_task(app_id, title)
|
||||||
|
.expect("test/setup VM shell creation should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_create_vm_shell_task(
|
||||||
|
&mut self,
|
||||||
|
app_id: u32,
|
||||||
|
title: impl Into<String>,
|
||||||
|
) -> Result<TaskId, SessionError> {
|
||||||
let title = title.into();
|
let title = title.into();
|
||||||
let process_id = self.os.process_manager.spawn_vm_shell(app_id, title.clone());
|
let process_id = self.os.process_manager.spawn_vm_shell(app_id, title.clone());
|
||||||
let task_id = self.os.task_manager.create_shell_task(process_id, app_id, title);
|
let task_id = self.os.task_manager.create_shell_task(process_id, app_id, title);
|
||||||
self.os
|
self.os.lifecycle().set_shell_foreground_task(task_id)?;
|
||||||
.lifecycle()
|
self.try_create_vm_session_for_task(task_id)?;
|
||||||
.set_foreground_task(task_id)
|
|
||||||
.expect("newly created shell task should have an associated process");
|
|
||||||
|
|
||||||
task_id
|
Ok(task_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub fn create_native_shell_task(&mut self, app_id: u32, title: impl Into<String>) -> TaskId {
|
pub fn create_native_shell_task(&mut self, app_id: u32, title: impl Into<String>) -> TaskId {
|
||||||
|
self.try_create_native_shell_task(app_id, title)
|
||||||
|
.expect("test/setup native shell creation should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_create_native_shell_task(
|
||||||
|
&mut self,
|
||||||
|
app_id: u32,
|
||||||
|
title: impl Into<String>,
|
||||||
|
) -> Result<TaskId, SessionError> {
|
||||||
let title = title.into();
|
let title = title.into();
|
||||||
let process_id = self.os.process_manager.spawn_native_shell(app_id, title.clone());
|
let process_id = self.os.process_manager.spawn_native_shell(app_id, title.clone());
|
||||||
let task_id = self.os.task_manager.create_shell_task(process_id, app_id, title);
|
let task_id = self.os.task_manager.create_shell_task(process_id, app_id, title);
|
||||||
self.os
|
self.os.lifecycle().set_shell_foreground_task(task_id)?;
|
||||||
.lifecycle()
|
|
||||||
.set_foreground_task(task_id)
|
|
||||||
.expect("newly created native shell task should have an associated process");
|
|
||||||
|
|
||||||
task_id
|
Ok(task_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_vm_cartridge(
|
||||||
|
&mut self,
|
||||||
|
cartridge: &Cartridge,
|
||||||
|
) -> Result<LoadedVmCartridge, SessionError> {
|
||||||
|
let existing_game_task = if cartridge.app_mode == AppMode::Game {
|
||||||
|
self.os
|
||||||
|
.vm_sessions
|
||||||
|
.resident_game_for_app(cartridge.app_id)
|
||||||
|
.map(|session| session.task_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let (task_id, reused_existing_session) = if let Some(task_id) = existing_game_task {
|
||||||
|
(task_id, true)
|
||||||
|
} else if cartridge.app_mode == AppMode::Shell {
|
||||||
|
(self.try_create_vm_shell_task(cartridge.app_id, cartridge.title.clone())?, false)
|
||||||
|
} else {
|
||||||
|
let task_id =
|
||||||
|
self.try_create_vm_game_task(cartridge.app_id, cartridge.title.clone())?;
|
||||||
|
(task_id, false)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !reused_existing_session {
|
||||||
|
self.try_initialize_session_cartridge(task_id, cartridge)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(LoadedVmCartridge { task_id, app_mode: cartridge.app_mode, reused_existing_session })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vm_session_for_task(&self, task_id: TaskId) -> Option<&VmSession> {
|
||||||
|
self.os.vm_sessions.for_task(task_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vm_session_for_task_mut(&mut self, task_id: TaskId) -> Option<&mut VmSession> {
|
||||||
|
self.os.vm_sessions.for_task_mut(task_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resident_game_session_for_app(&self, app_id: u32) -> Option<&VmSession> {
|
||||||
|
self.os.vm_sessions.resident_game_for_app(app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vm_session_count(&self) -> usize {
|
||||||
|
self.os.vm_sessions.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_vm_sessions(&mut self) {
|
||||||
|
self.os.vm_sessions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_create_vm_session_for_task(&mut self, task_id: TaskId) -> Result<(), SessionError> {
|
||||||
|
let task =
|
||||||
|
self.os.task_manager.get(task_id).ok_or(SessionError::TaskNotFound(task_id))?.clone();
|
||||||
|
let process = self
|
||||||
|
.os
|
||||||
|
.process_manager
|
||||||
|
.get(task.process_id)
|
||||||
|
.ok_or(SessionError::ProcessNotFound(task.process_id))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let cap_config = self.os.vm_runtime.certifier.config;
|
||||||
|
let logs_count = Arc::clone(&self.os.log_service.logs_count);
|
||||||
|
self.os
|
||||||
|
.vm_sessions
|
||||||
|
.create_for_task(&task, &process, "", Some(cap_config), logs_count)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(SessionError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_initialize_session_cartridge(
|
||||||
|
&mut self,
|
||||||
|
task_id: TaskId,
|
||||||
|
cartridge: &Cartridge,
|
||||||
|
) -> Result<(), SessionError> {
|
||||||
|
let session = self
|
||||||
|
.os
|
||||||
|
.vm_sessions
|
||||||
|
.for_task_mut(task_id)
|
||||||
|
.ok_or(SessionError::MissingVmSession(task_id))?;
|
||||||
|
|
||||||
|
session.app_id = cartridge.app_id;
|
||||||
|
session.title = cartridge.title.clone();
|
||||||
|
session.app_version = cartridge.app_version.clone();
|
||||||
|
session.app_mode = cartridge.app_mode;
|
||||||
|
session.clear_cartridge_service_state();
|
||||||
|
session
|
||||||
|
.runtime
|
||||||
|
.initialize_vm(&mut self.os.log_service, &mut session.vm, cartridge)
|
||||||
|
.map_err(SessionError::Initialization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
use crate::CrashReport;
|
use crate::CrashReport;
|
||||||
use crate::os::SystemOS;
|
use crate::os::{GameLifecycleEvent, SystemOS};
|
||||||
|
use crate::task::TaskId;
|
||||||
|
use crate::vm_session::VmSession;
|
||||||
use crate::{RenderWorkerConfig, RenderWorkerController};
|
use crate::{RenderWorkerConfig, RenderWorkerController};
|
||||||
|
use prometeu_bytecode::Value;
|
||||||
use prometeu_hal::app_mode::AppMode;
|
use prometeu_hal::app_mode::AppMode;
|
||||||
use prometeu_hal::cartridge::Cartridge;
|
|
||||||
use prometeu_hal::telemetry::{CertificationConfig, TelemetryFrame};
|
use prometeu_hal::telemetry::{CertificationConfig, TelemetryFrame};
|
||||||
use prometeu_hal::{
|
use prometeu_hal::{
|
||||||
FrameId, InputSignals, RenderWorkerBackend, RenderWorkerError, RenderWorkerFrameSink,
|
FrameId, InputSignals, RenderOwnership, RenderWorkerBackend, RenderWorkerError,
|
||||||
RuntimePlatform,
|
RenderWorkerFrameSink, RuntimePlatform,
|
||||||
};
|
};
|
||||||
use prometeu_vm::VirtualMachine;
|
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
pub struct VmFacade<'a> {
|
pub struct VmFacade<'a> {
|
||||||
@ -16,88 +17,216 @@ pub struct VmFacade<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> VmFacade<'a> {
|
impl<'a> VmFacade<'a> {
|
||||||
pub fn initialize(
|
pub fn tick_session(
|
||||||
&mut self,
|
&mut self,
|
||||||
vm: &mut VirtualMachine,
|
task_id: TaskId,
|
||||||
cartridge: &Cartridge,
|
|
||||||
) -> Result<(), CrashReport> {
|
|
||||||
self.os.clear_cartridge_service_state();
|
|
||||||
self.os.vm_runtime.initialize_vm(&mut self.os.log_service, vm, cartridge)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn tick(
|
|
||||||
&mut self,
|
|
||||||
vm: &mut VirtualMachine,
|
|
||||||
signals: &InputSignals,
|
signals: &InputSignals,
|
||||||
platform: &mut dyn RuntimePlatform,
|
platform: &mut dyn RuntimePlatform,
|
||||||
) -> Option<CrashReport> {
|
) -> Option<CrashReport> {
|
||||||
self.os.vm_runtime.tick(
|
self.deliver_pending_game_lifecycle_events_to_session(task_id);
|
||||||
|
|
||||||
|
let Some(session) = self.os.vm_sessions.for_task_mut(task_id) else {
|
||||||
|
return Some(CrashReport::VmPanic {
|
||||||
|
message: format!("task {:?} has no VM session", task_id),
|
||||||
|
pc: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
session.runtime.tick(
|
||||||
&mut self.os.log_service,
|
&mut self.os.log_service,
|
||||||
&mut self.os.fs,
|
&mut self.os.fs,
|
||||||
&mut self.os.fs_state,
|
&mut session.fs_state,
|
||||||
&mut self.os.memcard,
|
&mut self.os.memcard,
|
||||||
&mut self.os.open_files,
|
&mut session.open_files,
|
||||||
&mut self.os.next_handle,
|
&mut session.next_handle,
|
||||||
vm,
|
&mut session.vm,
|
||||||
signals,
|
signals,
|
||||||
platform,
|
platform,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset(&mut self, vm: &mut VirtualMachine) {
|
fn deliver_pending_game_lifecycle_events_to_session(&mut self, task_id: TaskId) {
|
||||||
self.os.vm_runtime.reset(vm);
|
let Some(session) = self.os.vm_sessions.for_task(task_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if session.runtime.current_cartridge_app_mode != AppMode::Game {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut retained = Vec::new();
|
||||||
|
let mut delivered = Vec::new();
|
||||||
|
|
||||||
|
for event in self.os.game_lifecycle_events.drain(..) {
|
||||||
|
if event.task_id == task_id {
|
||||||
|
delivered.push(event);
|
||||||
|
} else {
|
||||||
|
retained.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.os.game_lifecycle_events = retained;
|
||||||
|
if let Some(session) = self.os.vm_sessions.for_task_mut(task_id) {
|
||||||
|
session.runtime.deliver_game_lifecycle_events(delivered);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn debug_step_instruction(
|
pub fn reset_global_runtime_state(&mut self) {
|
||||||
|
self.os.clear_cartridge_service_state();
|
||||||
|
self.os.vm_runtime.clear_cartridge_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn debug_step_active_session(
|
||||||
&mut self,
|
&mut self,
|
||||||
vm: &mut VirtualMachine,
|
|
||||||
platform: &mut dyn RuntimePlatform,
|
platform: &mut dyn RuntimePlatform,
|
||||||
) -> Option<CrashReport> {
|
) -> Option<CrashReport> {
|
||||||
self.os.vm_runtime.debug_step_instruction(&mut self.os.log_service, vm, platform)
|
let Some(task_id) = self.active_session_task_id() else {
|
||||||
|
return Some(CrashReport::VmPanic {
|
||||||
|
message: "debug step requested without an active VM session".to_string(),
|
||||||
|
pc: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let Some(session) = self.os.vm_sessions.for_task_mut(task_id) else {
|
||||||
|
return Some(CrashReport::VmPanic {
|
||||||
|
message: format!("active debug task {:?} has no VM session", task_id),
|
||||||
|
pc: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
session.runtime.debug_step_instruction(&mut self.os.log_service, &mut session.vm, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_pc(&self) -> Option<usize> {
|
||||||
|
self.active_session().map(|session| session.vm.pc())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_operand_stack_top(&self, n: usize) -> Vec<Value> {
|
||||||
|
self.active_session().map(|session| session.vm.operand_stack_top(n)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_active_breakpoint(&mut self, pc: usize) -> bool {
|
||||||
|
if let Some(session) = self.active_session_mut() {
|
||||||
|
session.vm.insert_breakpoint(pc);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_active_breakpoint(&mut self, pc: usize) -> bool {
|
||||||
|
if let Some(session) = self.active_session_mut() {
|
||||||
|
session.vm.remove_breakpoint(pc);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_breakpoints_list(&self) -> Vec<usize> {
|
||||||
|
self.active_session().map(|session| session.vm.breakpoints_list()).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn paused(&self) -> bool {
|
pub fn paused(&self) -> bool {
|
||||||
self.os.vm_runtime.paused
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.paused)
|
||||||
|
.unwrap_or(self.os.vm_runtime.paused)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_paused(&mut self, paused: bool) {
|
pub fn set_paused(&mut self, paused: bool) {
|
||||||
|
if let Some(session) = self.active_session_mut() {
|
||||||
|
session.runtime.paused = paused;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self.os.vm_runtime.paused = paused;
|
self.os.vm_runtime.paused = paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_inspection_active(&mut self, active: bool) {
|
pub fn set_inspection_active(&mut self, active: bool) {
|
||||||
|
if let Some(session) = self.active_session_mut() {
|
||||||
|
session.runtime.inspection_active = active;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self.os.vm_runtime.inspection_active = active;
|
self.os.vm_runtime.inspection_active = active;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn request_debug_step(&mut self) {
|
pub fn request_debug_step(&mut self) {
|
||||||
|
if let Some(session) = self.active_session_mut() {
|
||||||
|
session.runtime.debug_step_request = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self.os.vm_runtime.debug_step_request = true;
|
self.os.vm_runtime.debug_step_request = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn debug_step_requested(&self) -> bool {
|
pub fn debug_step_requested(&self) -> bool {
|
||||||
self.os.vm_runtime.debug_step_request
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.debug_step_request)
|
||||||
|
.unwrap_or(self.os.vm_runtime.debug_step_request)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn logical_frame_active(&self) -> bool {
|
pub fn logical_frame_active(&self) -> bool {
|
||||||
self.os.vm_runtime.logical_frame_active
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.logical_frame_active)
|
||||||
|
.unwrap_or(self.os.vm_runtime.logical_frame_active)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delivered_game_lifecycle_events(&self) -> &[GameLifecycleEvent] {
|
||||||
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.delivered_game_lifecycle_events())
|
||||||
|
.unwrap_or_else(|| self.os.vm_runtime.delivered_game_lifecycle_events())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_delivered_game_lifecycle_events(&mut self) -> Vec<GameLifecycleEvent> {
|
||||||
|
if let Some(session) = self.active_session_mut() {
|
||||||
|
return session.runtime.take_delivered_game_lifecycle_events();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.os.vm_runtime.take_delivered_game_lifecycle_events()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tick_index(&self) -> u64 {
|
pub fn tick_index(&self) -> u64 {
|
||||||
self.os.vm_runtime.tick_index
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.tick_index)
|
||||||
|
.unwrap_or(self.os.vm_runtime.tick_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn logical_frame_index(&self) -> u64 {
|
pub fn logical_frame_index(&self) -> u64 {
|
||||||
self.os.vm_runtime.logical_frame_index
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.logical_frame_index)
|
||||||
|
.unwrap_or(self.os.vm_runtime.logical_frame_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn last_frame_cpu_time_us(&self) -> u64 {
|
pub fn last_frame_cpu_time_us(&self) -> u64 {
|
||||||
self.os.vm_runtime.last_frame_cpu_time_us
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.last_frame_cpu_time_us)
|
||||||
|
.unwrap_or(self.os.vm_runtime.last_frame_cpu_time_us)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn completed_logical_frames(&self) -> u64 {
|
pub fn completed_logical_frames(&self) -> u64 {
|
||||||
self.os.vm_runtime.atomic_telemetry.completed_logical_frames.load(Ordering::Relaxed).into()
|
self.active_session()
|
||||||
|
.map(|session| {
|
||||||
|
session
|
||||||
|
.runtime
|
||||||
|
.atomic_telemetry
|
||||||
|
.completed_logical_frames
|
||||||
|
.load(Ordering::Relaxed)
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
self.os
|
||||||
|
.vm_runtime
|
||||||
|
.atomic_telemetry
|
||||||
|
.completed_logical_frames
|
||||||
|
.load(Ordering::Relaxed)
|
||||||
|
.into()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn telemetry_snapshot(&self) -> TelemetryFrame {
|
pub fn telemetry_snapshot(&self) -> TelemetryFrame {
|
||||||
self.os.vm_runtime.atomic_telemetry.snapshot()
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.atomic_telemetry.snapshot())
|
||||||
|
.unwrap_or_else(|| self.os.vm_runtime.atomic_telemetry.snapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_render_worker<B, S>(&mut self, config: RenderWorkerConfig, backend: B, sink: S)
|
pub fn start_render_worker<B, S>(&mut self, config: RenderWorkerConfig, backend: B, sink: S)
|
||||||
@ -120,27 +249,72 @@ impl<'a> VmFacade<'a> {
|
|||||||
self.os.vm_runtime.record_repeated_render_worker_frame(frame_id);
|
self.os.vm_runtime.record_repeated_render_worker_frame(frame_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn active_render_ownership(&self) -> RenderOwnership {
|
||||||
|
self.os.vm_runtime.render_manager.active_ownership()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transition_render_owner(&mut self, app_mode: AppMode, app_id: u32) {
|
||||||
|
self.os.vm_runtime.render_manager.transition_render_owner(app_mode, app_id);
|
||||||
|
self.os.vm_runtime.sync_render_worker_ownership();
|
||||||
|
self.os.vm_runtime.render_manager.sync_telemetry(&self.os.vm_runtime.atomic_telemetry);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cert_config(&self) -> &CertificationConfig {
|
pub fn cert_config(&self) -> &CertificationConfig {
|
||||||
&self.os.vm_runtime.certifier.config
|
&self.os.vm_runtime.certifier.config
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn last_crash_report(&self) -> Option<&CrashReport> {
|
pub fn last_crash_report(&self) -> Option<&CrashReport> {
|
||||||
self.os.vm_runtime.last_crash_report.as_ref()
|
self.active_session()
|
||||||
|
.and_then(|session| session.runtime.last_crash_report.as_ref())
|
||||||
|
.or(self.os.vm_runtime.last_crash_report.as_ref())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_app_id(&self) -> u32 {
|
pub fn current_app_id(&self) -> u32 {
|
||||||
self.os.vm_runtime.current_app_id
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.current_app_id)
|
||||||
|
.unwrap_or(self.os.vm_runtime.current_app_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_cartridge_title(&self) -> String {
|
pub fn current_cartridge_title(&self) -> String {
|
||||||
self.os.vm_runtime.current_cartridge_title.clone()
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.current_cartridge_title.clone())
|
||||||
|
.unwrap_or_else(|| self.os.vm_runtime.current_cartridge_title.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_cartridge_app_version(&self) -> String {
|
pub fn current_cartridge_app_version(&self) -> String {
|
||||||
self.os.vm_runtime.current_cartridge_app_version.clone()
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.current_cartridge_app_version.clone())
|
||||||
|
.unwrap_or_else(|| self.os.vm_runtime.current_cartridge_app_version.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_cartridge_app_mode(&self) -> AppMode {
|
pub fn current_cartridge_app_mode(&self) -> AppMode {
|
||||||
self.os.vm_runtime.current_cartridge_app_mode
|
self.active_session()
|
||||||
|
.map(|session| session.runtime.current_cartridge_app_mode)
|
||||||
|
.unwrap_or(self.os.vm_runtime.current_cartridge_app_mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_session(&self) -> Option<&VmSession> {
|
||||||
|
self.os
|
||||||
|
.task_manager
|
||||||
|
.foreground_task()
|
||||||
|
.and_then(|task_id| self.os.vm_sessions.for_task(task_id))
|
||||||
|
.or_else(|| {
|
||||||
|
self.os
|
||||||
|
.foreground_stack
|
||||||
|
.resident_game_task()
|
||||||
|
.and_then(|task_id| self.os.vm_sessions.for_task(task_id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_session_mut(&mut self) -> Option<&mut VmSession> {
|
||||||
|
let task_id = self.active_session_task_id()?;
|
||||||
|
self.os.vm_sessions.for_task_mut(task_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_session_task_id(&self) -> Option<TaskId> {
|
||||||
|
self.os
|
||||||
|
.task_manager
|
||||||
|
.foreground_task()
|
||||||
|
.or_else(|| self.os.foreground_stack.resident_game_task())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,20 @@
|
|||||||
use crate::process::ProcessId;
|
use crate::process::ProcessId;
|
||||||
use crate::task::{TaskId, TaskState};
|
use crate::task::{TaskId, TaskState};
|
||||||
|
|
||||||
|
pub const DEFAULT_GAME_PAUSE_BUDGET_TICKS: u8 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GameLifecycleEventKind {
|
||||||
|
Pause,
|
||||||
|
ResumeForeground,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct GameLifecycleEvent {
|
||||||
|
pub task_id: TaskId,
|
||||||
|
pub kind: GameLifecycleEventKind,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum LifecycleOperation {
|
pub enum LifecycleOperation {
|
||||||
SetForeground,
|
SetForeground,
|
||||||
@ -15,4 +29,7 @@ pub enum LifecycleError {
|
|||||||
TaskNotFound(TaskId),
|
TaskNotFound(TaskId),
|
||||||
ProcessNotFound(ProcessId),
|
ProcessNotFound(ProcessId),
|
||||||
InvalidTransition { task_id: TaskId, from: TaskState, operation: LifecycleOperation },
|
InvalidTransition { task_id: TaskId, from: TaskState, operation: LifecycleOperation },
|
||||||
|
ResidentGameAlreadyExists { existing: TaskId, requested: TaskId },
|
||||||
|
ForegroundShellAlreadyExists { existing: TaskId, requested: TaskId },
|
||||||
|
TaskIsNotResidentGame(TaskId),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,11 @@ mod facades;
|
|||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod system_os;
|
mod system_os;
|
||||||
|
|
||||||
pub use facades::{FsFacade, LifecycleFacade, SessionsFacade, VmFacade, WindowFacade};
|
pub use facades::{
|
||||||
pub use lifecycle::{LifecycleError, LifecycleOperation};
|
FsFacade, LifecycleFacade, SessionError, SessionsFacade, VmFacade, WindowFacade,
|
||||||
|
};
|
||||||
|
pub use lifecycle::{
|
||||||
|
DEFAULT_GAME_PAUSE_BUDGET_TICKS, GameLifecycleEvent, GameLifecycleEventKind, LifecycleError,
|
||||||
|
LifecycleOperation,
|
||||||
|
};
|
||||||
pub use system_os::SystemOS;
|
pub use system_os::SystemOS;
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
use crate::VirtualMachineRuntime;
|
use crate::VirtualMachineRuntime;
|
||||||
use crate::fs::{FsState, VirtualFS};
|
use crate::fs::{FsState, VirtualFS};
|
||||||
use crate::os::{FsFacade, LifecycleFacade, SessionsFacade, VmFacade, WindowFacade};
|
use crate::os::{
|
||||||
|
FsFacade, GameLifecycleEvent, LifecycleFacade, SessionsFacade, VmFacade, WindowFacade,
|
||||||
|
};
|
||||||
use crate::process::ProcessManager;
|
use crate::process::ProcessManager;
|
||||||
|
use crate::services::foreground::ForegroundStack;
|
||||||
use crate::services::memcard::MemcardService;
|
use crate::services::memcard::MemcardService;
|
||||||
|
use crate::services::vm_session::VmSessionRegistry;
|
||||||
use crate::services::windows::WindowManager;
|
use crate::services::windows::WindowManager;
|
||||||
use crate::task::TaskManager;
|
use crate::task::TaskManager;
|
||||||
use prometeu_hal::log::{LogEvent, LogLevel, LogService, LogSource};
|
use prometeu_hal::log::{LogEvent, LogLevel, LogService, LogSource};
|
||||||
@ -14,6 +18,9 @@ pub struct SystemOS {
|
|||||||
pub(super) vm_runtime: VirtualMachineRuntime,
|
pub(super) vm_runtime: VirtualMachineRuntime,
|
||||||
pub(super) process_manager: ProcessManager,
|
pub(super) process_manager: ProcessManager,
|
||||||
pub(super) task_manager: TaskManager,
|
pub(super) task_manager: TaskManager,
|
||||||
|
pub(super) foreground_stack: ForegroundStack,
|
||||||
|
pub(super) vm_sessions: VmSessionRegistry,
|
||||||
|
pub(super) game_lifecycle_events: Vec<GameLifecycleEvent>,
|
||||||
pub(super) window_manager: WindowManager,
|
pub(super) window_manager: WindowManager,
|
||||||
pub(super) log_service: LogService,
|
pub(super) log_service: LogService,
|
||||||
pub(super) fs: VirtualFS,
|
pub(super) fs: VirtualFS,
|
||||||
@ -33,6 +40,9 @@ impl SystemOS {
|
|||||||
),
|
),
|
||||||
process_manager: ProcessManager::new(),
|
process_manager: ProcessManager::new(),
|
||||||
task_manager: TaskManager::new(),
|
task_manager: TaskManager::new(),
|
||||||
|
foreground_stack: ForegroundStack::new(),
|
||||||
|
vm_sessions: VmSessionRegistry::new(),
|
||||||
|
game_lifecycle_events: Vec::new(),
|
||||||
window_manager: WindowManager::new(),
|
window_manager: WindowManager::new(),
|
||||||
log_service,
|
log_service,
|
||||||
fs: VirtualFS::new(),
|
fs: VirtualFS::new(),
|
||||||
@ -133,15 +143,33 @@ impl SystemOS {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::os::{LifecycleError, LifecycleOperation};
|
use crate::os::{GameLifecycleEventKind, LifecycleError, LifecycleOperation, SessionError};
|
||||||
use crate::process::ProcessState;
|
use crate::process::{ProcessId, ProcessKind, ProcessState};
|
||||||
|
use crate::services::foreground::{ForegroundOwner, ResidentGame, ResidentGameState};
|
||||||
use crate::task::{TaskId, TaskState};
|
use crate::task::{TaskId, TaskState};
|
||||||
|
use prometeu_hal::app_mode::AppMode;
|
||||||
|
use prometeu_hal::cartridge::{AssetsPayloadSource, Cartridge};
|
||||||
|
use prometeu_hal::syscalls::caps;
|
||||||
|
|
||||||
fn process_state_for_task(os: &SystemOS, task_id: TaskId) -> ProcessState {
|
fn process_state_for_task(os: &SystemOS, task_id: TaskId) -> ProcessState {
|
||||||
let task = os.task_manager.get(task_id).expect("task should exist");
|
let task = os.task_manager.get(task_id).expect("task should exist");
|
||||||
os.process_manager.get(task.process_id).expect("process should exist").state
|
os.process_manager.get(task.process_id).expect("process should exist").state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn session_test_cartridge(app_mode: AppMode, app_id: u32, title: &str) -> Cartridge {
|
||||||
|
Cartridge {
|
||||||
|
app_id,
|
||||||
|
title: title.to_string(),
|
||||||
|
app_version: "1.0.0".to_string(),
|
||||||
|
app_mode,
|
||||||
|
capabilities: caps::NONE,
|
||||||
|
program: vec![],
|
||||||
|
assets: AssetsPayloadSource::empty(),
|
||||||
|
asset_table: vec![],
|
||||||
|
preload: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_foreground_task_marks_task_and_process_running() {
|
fn set_foreground_task_marks_task_and_process_running() {
|
||||||
let mut os = SystemOS::new(None);
|
let mut os = SystemOS::new(None);
|
||||||
@ -157,6 +185,358 @@ mod tests {
|
|||||||
assert_eq!(process_state_for_task(&os, task_id), ProcessState::Running);
|
assert_eq!(process_state_for_task(&os, task_id), ProcessState::Running);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_stack_starts_at_hub() {
|
||||||
|
let os = SystemOS::new(None);
|
||||||
|
|
||||||
|
assert_eq!(os.foreground_stack.owner(), ForegroundOwner::Hub);
|
||||||
|
assert_eq!(os.foreground_stack.resident_game(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_session_registers_single_resident_game() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let task_id = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
|
||||||
|
assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Game(task_id));
|
||||||
|
assert_eq!(
|
||||||
|
os.lifecycle().resident_game(),
|
||||||
|
Some(ResidentGame { task_id, state: ResidentGameState::Foreground })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_resident_game_is_rejected() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let first = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
let process_id = os.process_manager.spawn_vm_game(43, "Second");
|
||||||
|
let second = os.task_manager.create_game_task(process_id, 43, "Second");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
os.lifecycle().set_game_foreground_task(second),
|
||||||
|
Err(LifecycleError::ResidentGameAlreadyExists { existing: first, requested: second })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vm_game_task_creates_vm_session() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let task_id = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
|
||||||
|
let sessions = os.sessions();
|
||||||
|
let session = sessions.vm_session_for_task(task_id).expect("VM session should exist");
|
||||||
|
|
||||||
|
assert_eq!(session.task_id, task_id);
|
||||||
|
assert_eq!(session.app_id, 42);
|
||||||
|
assert_eq!(session.title, "Sector Crawl");
|
||||||
|
assert_eq!(session.app_mode, AppMode::Game);
|
||||||
|
assert_eq!(sessions.vm_session_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vm_shell_task_creates_vm_session() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let task_id = os.sessions().create_vm_shell_task(7, "Settings");
|
||||||
|
|
||||||
|
let sessions = os.sessions();
|
||||||
|
let session = sessions.vm_session_for_task(task_id).expect("VM session should exist");
|
||||||
|
|
||||||
|
assert_eq!(session.task_id, task_id);
|
||||||
|
assert_eq!(session.app_id, 7);
|
||||||
|
assert_eq!(session.title, "Settings");
|
||||||
|
assert_eq!(session.app_mode, AppMode::Shell);
|
||||||
|
assert_eq!(sessions.vm_session_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_shell_task_does_not_create_vm_session() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let task_id = os.sessions().create_native_shell_task(7, "Settings");
|
||||||
|
|
||||||
|
let sessions = os.sessions();
|
||||||
|
|
||||||
|
assert!(sessions.vm_session_for_task(task_id).is_none());
|
||||||
|
assert_eq!(sessions.vm_session_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_resident_game_app_reuses_existing_vm_session() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let first = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
let second = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
|
||||||
|
let sessions = os.sessions();
|
||||||
|
|
||||||
|
assert_eq!(second, first);
|
||||||
|
assert_eq!(
|
||||||
|
sessions.resident_game_session_for_app(42).map(|session| session.task_id),
|
||||||
|
Some(first)
|
||||||
|
);
|
||||||
|
assert_eq!(sessions.vm_session_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vm_sessions_hold_independent_runtime_state() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let game = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
os.lifecycle().request_home_from_game(game).expect("home request should succeed");
|
||||||
|
os.lifecycle().advance_game_pause_budget(game).expect("budget should expire");
|
||||||
|
let shell = os.sessions().create_vm_shell_task(7, "Settings");
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut sessions = os.sessions();
|
||||||
|
let game_session =
|
||||||
|
sessions.vm_session_for_task_mut(game).expect("game VM session should exist");
|
||||||
|
game_session.runtime.tick_index = 100;
|
||||||
|
game_session.open_files.insert(1, "/game.dat".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut sessions = os.sessions();
|
||||||
|
let shell_session =
|
||||||
|
sessions.vm_session_for_task_mut(shell).expect("shell VM session should exist");
|
||||||
|
shell_session.runtime.tick_index = 5;
|
||||||
|
shell_session.open_files.insert(1, "/shell.dat".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessions = os.sessions();
|
||||||
|
let game_session =
|
||||||
|
sessions.vm_session_for_task(game).expect("game VM session should exist");
|
||||||
|
let shell_session =
|
||||||
|
sessions.vm_session_for_task(shell).expect("shell VM session should exist");
|
||||||
|
|
||||||
|
assert_eq!(game_session.runtime.tick_index, 100);
|
||||||
|
assert_eq!(shell_session.runtime.tick_index, 5);
|
||||||
|
assert_eq!(game_session.open_files.get(&1).map(String::as_str), Some("/game.dat"));
|
||||||
|
assert_eq!(shell_session.open_files.get(&1).map(String::as_str), Some("/shell.dat"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vm_debug_operations_target_foreground_shell_before_resident_game() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let game = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
os.lifecycle().request_home_from_game(game).expect("home request should succeed");
|
||||||
|
os.lifecycle().advance_game_pause_budget(game).expect("budget should expire");
|
||||||
|
let shell = os.sessions().create_vm_shell_task(7, "Settings");
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut sessions = os.sessions();
|
||||||
|
let game_session =
|
||||||
|
sessions.vm_session_for_task_mut(game).expect("game VM session should exist");
|
||||||
|
game_session.vm.insert_breakpoint(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(os.vm().insert_active_breakpoint(20));
|
||||||
|
assert_eq!(os.vm().active_breakpoints_list(), vec![20]);
|
||||||
|
|
||||||
|
let sessions = os.sessions();
|
||||||
|
assert_eq!(
|
||||||
|
sessions
|
||||||
|
.vm_session_for_task(game)
|
||||||
|
.expect("game VM session should exist")
|
||||||
|
.vm
|
||||||
|
.breakpoints_list(),
|
||||||
|
vec![10]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sessions
|
||||||
|
.vm_session_for_task(shell)
|
||||||
|
.expect("shell VM session should exist")
|
||||||
|
.vm
|
||||||
|
.breakpoints_list(),
|
||||||
|
vec![20]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_request_notifies_game_before_budget_suspends_it() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let task_id = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
|
||||||
|
os.lifecycle().request_home_from_game(task_id).expect("home request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Game(task_id));
|
||||||
|
assert_eq!(
|
||||||
|
os.lifecycle().resident_game(),
|
||||||
|
Some(ResidentGame {
|
||||||
|
task_id,
|
||||||
|
state: ResidentGameState::PauseRequested { remaining_ticks: 1 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
os.lifecycle().pending_game_lifecycle_events()[0].kind,
|
||||||
|
GameLifecycleEventKind::Pause
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
os.task_manager.get(task_id).expect("task should exist").state,
|
||||||
|
TaskState::Foreground
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(os.lifecycle().advance_game_pause_budget(task_id).expect("budget should advance"));
|
||||||
|
assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Hub);
|
||||||
|
assert_eq!(
|
||||||
|
os.lifecycle().resident_game(),
|
||||||
|
Some(ResidentGame { task_id, state: ResidentGameState::PausedSuspended })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
os.task_manager.get(task_id).expect("task should exist").state,
|
||||||
|
TaskState::Suspended
|
||||||
|
);
|
||||||
|
assert_eq!(process_state_for_task(&os, task_id), ProcessState::Suspended);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_foreground_keeps_game_resident_and_marks_it_suspended() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let game = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
os.lifecycle().request_home_from_game(game).expect("home request should succeed");
|
||||||
|
os.lifecycle().advance_game_pause_budget(game).expect("budget should expire");
|
||||||
|
let shell = os.sessions().create_vm_shell_task(7, "Settings");
|
||||||
|
|
||||||
|
assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell));
|
||||||
|
assert_eq!(
|
||||||
|
os.lifecycle().resident_game(),
|
||||||
|
Some(ResidentGame { task_id: game, state: ResidentGameState::PausedSuspended })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vm_shell_foreground_suspends_foreground_resident_game() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let game = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
|
||||||
|
let shell = os.sessions().create_vm_shell_task(7, "Settings");
|
||||||
|
|
||||||
|
assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell));
|
||||||
|
assert_eq!(
|
||||||
|
os.lifecycle().resident_game(),
|
||||||
|
Some(ResidentGame { task_id: game, state: ResidentGameState::PausedSuspended })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
os.task_manager.get(game).expect("game should exist").state,
|
||||||
|
TaskState::Suspended
|
||||||
|
);
|
||||||
|
assert_eq!(process_state_for_task(&os, game), ProcessState::Suspended);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_shell_foreground_suspends_foreground_resident_game() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let game = os.sessions().create_vm_game_task(42, "Sector Crawl");
|
||||||
|
|
||||||
|
let shell = os.sessions().create_native_shell_task(7, "Settings");
|
||||||
|
|
||||||
|
assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Shell(shell));
|
||||||
|
assert_eq!(
|
||||||
|
os.lifecycle().resident_game(),
|
||||||
|
Some(ResidentGame { task_id: game, state: ResidentGameState::PausedSuspended })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
os.task_manager.get(game).expect("game should exist").state,
|
||||||
|
TaskState::Suspended
|
||||||
|
);
|
||||||
|
assert_eq!(process_state_for_task(&os, game), ProcessState::Suspended);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifecycle_exposes_process_kind_for_shell_tasks() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let vm_shell = os.sessions().create_vm_shell_task(7, "Settings");
|
||||||
|
os.lifecycle().close_task(vm_shell).expect("vm shell should close");
|
||||||
|
|
||||||
|
let native_shell = os.sessions().create_native_shell_task(8, "Files");
|
||||||
|
|
||||||
|
assert_eq!(os.lifecycle().process_kind_for_task(vm_shell), Ok(ProcessKind::VmShell));
|
||||||
|
assert_eq!(
|
||||||
|
os.lifecycle().process_kind_for_task(native_shell),
|
||||||
|
Ok(ProcessKind::NativeShell)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_foreground_shell_is_rejected() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let first = os.sessions().create_vm_shell_task(7, "Settings");
|
||||||
|
let process_id = os.process_manager.spawn_vm_shell(8, "Files");
|
||||||
|
let second = os.task_manager.create_shell_task(process_id, 8, "Files");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
os.lifecycle().set_shell_foreground_task(second),
|
||||||
|
Err(LifecycleError::ForegroundShellAlreadyExists {
|
||||||
|
existing: first,
|
||||||
|
requested: second,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vm_shell_load_returns_typed_error_when_foreground_shell_exists() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let first = os.sessions().create_vm_shell_task(7, "Settings");
|
||||||
|
let cartridge = session_test_cartridge(AppMode::Shell, 8, "Files");
|
||||||
|
|
||||||
|
let error = os.sessions().load_vm_cartridge(&cartridge).expect_err("load should fail");
|
||||||
|
|
||||||
|
match error {
|
||||||
|
SessionError::Lifecycle(LifecycleError::ForegroundShellAlreadyExists {
|
||||||
|
existing,
|
||||||
|
requested,
|
||||||
|
}) => {
|
||||||
|
assert_eq!(existing, first);
|
||||||
|
assert_ne!(requested, first);
|
||||||
|
}
|
||||||
|
other => panic!("expected foreground shell lifecycle error, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vm_session_creation_returns_typed_error_for_missing_task() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
os.sessions().try_create_vm_session_for_task(TaskId(999)),
|
||||||
|
Err(SessionError::TaskNotFound(TaskId(999)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vm_session_creation_returns_typed_error_for_missing_process() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let task_id = os.task_manager.create_game_task(ProcessId(999), 42, "Broken");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
os.sessions().try_create_vm_session_for_task(task_id),
|
||||||
|
Err(SessionError::ProcessNotFound(ProcessId(999)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_cartridge_initialization_returns_typed_error_for_missing_session() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let process_id = os.process_manager.spawn_vm_game(42, "Sector Crawl");
|
||||||
|
let task_id = os.task_manager.create_game_task(process_id, 42, "Sector Crawl");
|
||||||
|
let cartridge = session_test_cartridge(AppMode::Game, 42, "Sector Crawl");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
os.sessions().try_initialize_session_cartridge(task_id, &cartridge),
|
||||||
|
Err(SessionError::MissingVmSession(task_id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closing_foreground_shell_returns_to_hub() {
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let shell = os.sessions().create_vm_shell_task(7, "Settings");
|
||||||
|
|
||||||
|
os.lifecycle().close_task(shell).expect("close should succeed");
|
||||||
|
|
||||||
|
assert_eq!(os.lifecycle().foreground_owner(), ForegroundOwner::Hub);
|
||||||
|
assert_eq!(
|
||||||
|
os.task_manager.get(shell).expect("shell should remain").state,
|
||||||
|
TaskState::Closed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn suspend_task_marks_task_and_process_suspended() {
|
fn suspend_task_marks_task_and_process_suspended() {
|
||||||
let mut os = SystemOS::new(None);
|
let mut os = SystemOS::new(None);
|
||||||
@ -184,6 +564,10 @@ mod tests {
|
|||||||
TaskState::Foreground
|
TaskState::Foreground
|
||||||
);
|
);
|
||||||
assert_eq!(process_state_for_task(&os, task_id), ProcessState::Running);
|
assert_eq!(process_state_for_task(&os, task_id), ProcessState::Running);
|
||||||
|
assert_eq!(
|
||||||
|
os.lifecycle().pending_game_lifecycle_events().last().map(|event| event.kind),
|
||||||
|
Some(GameLifecycleEventKind::ResumeForeground)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
use crate::task::TaskId;
|
||||||
use crate::{CrashReport, GameLibrary, SystemOS};
|
use crate::{CrashReport, GameLibrary, SystemOS};
|
||||||
use prometeu_hal::color::Color;
|
use prometeu_hal::color::Color;
|
||||||
use prometeu_hal::primitives::Rect;
|
use prometeu_hal::primitives::Rect;
|
||||||
@ -5,7 +6,6 @@ use prometeu_hal::{
|
|||||||
FrameId, GfxUiCommand, InputPlatform, InputSignals, RenderSubmission, RuntimePlatform,
|
FrameId, GfxUiCommand, InputPlatform, InputSignals, RenderSubmission, RuntimePlatform,
|
||||||
ShellUiFramePacket,
|
ShellUiFramePacket,
|
||||||
};
|
};
|
||||||
use prometeu_vm::VirtualMachine;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
const SHELL_A_BUTTON: Rect = Rect { x: 112, y: 172, w: 112, h: 32 };
|
const SHELL_A_BUTTON: Rect = Rect { x: 112, y: 172, w: 112, h: 32 };
|
||||||
@ -174,7 +174,23 @@ impl PrometeuHub {
|
|||||||
pub fn update_shell_profile(
|
pub fn update_shell_profile(
|
||||||
&mut self,
|
&mut self,
|
||||||
os: &mut SystemOS,
|
os: &mut SystemOS,
|
||||||
vm: &mut VirtualMachine,
|
platform: &mut dyn RuntimePlatform,
|
||||||
|
) -> SystemProfileUpdate {
|
||||||
|
let mut action = self.gui_update(os, platform);
|
||||||
|
|
||||||
|
if os.windows().focused_window().is_some() && platform.input().pad().start().down {
|
||||||
|
action = Some(SystemProfileAction::CloseShell);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.render(os, platform);
|
||||||
|
|
||||||
|
SystemProfileUpdate { crash: None, action }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_vm_shell_profile(
|
||||||
|
&mut self,
|
||||||
|
os: &mut SystemOS,
|
||||||
|
task_id: TaskId,
|
||||||
signals: &InputSignals,
|
signals: &InputSignals,
|
||||||
platform: &mut dyn RuntimePlatform,
|
platform: &mut dyn RuntimePlatform,
|
||||||
) -> SystemProfileUpdate {
|
) -> SystemProfileUpdate {
|
||||||
@ -185,7 +201,7 @@ impl PrometeuHub {
|
|||||||
if platform.input().pad().start().down {
|
if platform.input().pad().start().down {
|
||||||
action = Some(SystemProfileAction::CloseShell);
|
action = Some(SystemProfileAction::CloseShell);
|
||||||
} else if action != Some(SystemProfileAction::CloseShell) {
|
} else if action != Some(SystemProfileAction::CloseShell) {
|
||||||
crash = os.vm().tick(vm, signals, platform);
|
crash = os.vm().tick_session(task_id, signals, platform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,6 +209,22 @@ impl PrometeuHub {
|
|||||||
|
|
||||||
SystemProfileUpdate { crash, action }
|
SystemProfileUpdate { crash, action }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_native_shell_profile(
|
||||||
|
&mut self,
|
||||||
|
os: &mut SystemOS,
|
||||||
|
platform: &mut dyn RuntimePlatform,
|
||||||
|
) -> SystemProfileUpdate {
|
||||||
|
let mut action = self.gui_update(os, platform);
|
||||||
|
|
||||||
|
if os.windows().focused_window().is_some() && platform.input().pad().start().down {
|
||||||
|
action = Some(SystemProfileAction::CloseShell);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.render(os, platform);
|
||||||
|
|
||||||
|
SystemProfileUpdate { crash: None, action }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn action_for_click(
|
fn action_for_click(
|
||||||
@ -398,6 +430,34 @@ fn draw_buffer_text(commands: &mut Vec<GfxUiCommand>, x: i32, y: i32, text: &str
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::task::TaskId;
|
||||||
|
use crate::windows::WindowOwner;
|
||||||
|
use prometeu_drivers::TestPlatform;
|
||||||
|
use prometeu_hal::{InputSignals, RuntimePlatform};
|
||||||
|
|
||||||
|
fn focused_native_shell_fixture(
|
||||||
|
signals: &InputSignals,
|
||||||
|
) -> (PrometeuHub, SystemOS, TestPlatform) {
|
||||||
|
let hub = PrometeuHub::new();
|
||||||
|
let mut os = SystemOS::new(None);
|
||||||
|
let mut platform = TestPlatform::new();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut windows = os.windows();
|
||||||
|
let id = windows.add_window(
|
||||||
|
"ShellA".to_string(),
|
||||||
|
WindowOwner::Task(TaskId(7)),
|
||||||
|
SHELL_FRAME,
|
||||||
|
Color::GREEN,
|
||||||
|
);
|
||||||
|
windows.set_focus(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
platform.input_mut().pad_mut().begin_frame(signals);
|
||||||
|
platform.input_mut().touch_mut().begin_frame(signals);
|
||||||
|
|
||||||
|
(hub, os, platform)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shell_a_button_click_emits_launch_action() {
|
fn shell_a_button_click_emits_launch_action() {
|
||||||
@ -518,4 +578,35 @@ mod tests {
|
|||||||
CLOSE_BUTTON.y + CLOSE_BUTTON.h - 1
|
CLOSE_BUTTON.y + CLOSE_BUTTON.h - 1
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_shell_profile_start_close_does_not_tick_vm() {
|
||||||
|
let signals = InputSignals { start_signal: true, ..Default::default() };
|
||||||
|
let (mut hub, mut os, mut platform) = focused_native_shell_fixture(&signals);
|
||||||
|
|
||||||
|
let tick_index_before_update = os.vm().tick_index();
|
||||||
|
let outcome = hub.update_native_shell_profile(&mut os, &mut platform);
|
||||||
|
|
||||||
|
assert_eq!(outcome.action, Some(SystemProfileAction::CloseShell));
|
||||||
|
assert!(outcome.crash.is_none());
|
||||||
|
assert_eq!(os.vm().tick_index(), tick_index_before_update);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_shell_profile_close_button_does_not_tick_vm() {
|
||||||
|
let signals = InputSignals {
|
||||||
|
f_signal: true,
|
||||||
|
x_pos: CLOSE_BUTTON.x,
|
||||||
|
y_pos: CLOSE_BUTTON.y,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (mut hub, mut os, mut platform) = focused_native_shell_fixture(&signals);
|
||||||
|
|
||||||
|
let tick_index_before_update = os.vm().tick_index();
|
||||||
|
let outcome = hub.update_native_shell_profile(&mut os, &mut platform);
|
||||||
|
|
||||||
|
assert_eq!(outcome.action, Some(SystemProfileAction::CloseShell));
|
||||||
|
assert!(outcome.crash.is_none());
|
||||||
|
assert_eq!(os.vm().tick_index(), tick_index_before_update);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
330
crates/console/prometeu-system/src/services/foreground.rs
Normal file
330
crates/console/prometeu-system/src/services/foreground.rs
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
use crate::task::TaskId;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ForegroundOwner {
|
||||||
|
Hub,
|
||||||
|
Game(TaskId),
|
||||||
|
Shell(TaskId),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ResidentGameState {
|
||||||
|
Foreground,
|
||||||
|
PauseRequested { remaining_ticks: u8 },
|
||||||
|
ResumeRequested,
|
||||||
|
PausedSuspended,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ResidentGame {
|
||||||
|
pub task_id: TaskId,
|
||||||
|
pub state: ResidentGameState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ForegroundStackError {
|
||||||
|
ResidentGameAlreadyExists { existing: TaskId, requested: TaskId },
|
||||||
|
ForegroundShellAlreadyExists { existing: TaskId, requested: TaskId },
|
||||||
|
TaskIsNotResidentGame(TaskId),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ForegroundStack {
|
||||||
|
owner: ForegroundOwner,
|
||||||
|
resident_game: Option<ResidentGame>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ForegroundStack {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { owner: ForegroundOwner::Hub, resident_game: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForegroundStack {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn owner(&self) -> ForegroundOwner {
|
||||||
|
self.owner
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resident_game(&self) -> Option<ResidentGame> {
|
||||||
|
self.resident_game
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resident_game_task(&self) -> Option<TaskId> {
|
||||||
|
self.resident_game.map(|game| game.task_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_game_foreground(&mut self, task_id: TaskId) -> Result<(), ForegroundStackError> {
|
||||||
|
if let Some(game) = self.resident_game
|
||||||
|
&& game.task_id != task_id
|
||||||
|
{
|
||||||
|
return Err(ForegroundStackError::ResidentGameAlreadyExists {
|
||||||
|
existing: game.task_id,
|
||||||
|
requested: task_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.resident_game = Some(ResidentGame { task_id, state: ResidentGameState::Foreground });
|
||||||
|
self.owner = ForegroundOwner::Game(task_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_home_from_game(
|
||||||
|
&mut self,
|
||||||
|
task_id: TaskId,
|
||||||
|
pause_budget_ticks: u8,
|
||||||
|
) -> Result<(), ForegroundStackError> {
|
||||||
|
match self.resident_game {
|
||||||
|
Some(game) if game.task_id == task_id => {
|
||||||
|
if !matches!(
|
||||||
|
game.state,
|
||||||
|
ResidentGameState::PauseRequested { .. } | ResidentGameState::PausedSuspended
|
||||||
|
) {
|
||||||
|
self.resident_game = Some(ResidentGame {
|
||||||
|
task_id,
|
||||||
|
state: ResidentGameState::PauseRequested {
|
||||||
|
remaining_ticks: pause_budget_ticks.max(1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(ForegroundStackError::TaskIsNotResidentGame(task_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advance_pause_budget(&mut self, task_id: TaskId) -> Result<bool, ForegroundStackError> {
|
||||||
|
match self.resident_game {
|
||||||
|
Some(game)
|
||||||
|
if game.task_id == task_id
|
||||||
|
&& matches!(
|
||||||
|
game.state,
|
||||||
|
ResidentGameState::PauseRequested { remaining_ticks: 1 }
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
self.resident_game =
|
||||||
|
Some(ResidentGame { task_id, state: ResidentGameState::PausedSuspended });
|
||||||
|
self.owner = ForegroundOwner::Hub;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Some(game)
|
||||||
|
if game.task_id == task_id
|
||||||
|
&& matches!(game.state, ResidentGameState::PauseRequested { .. }) =>
|
||||||
|
{
|
||||||
|
let ResidentGameState::PauseRequested { remaining_ticks } = game.state else {
|
||||||
|
unreachable!();
|
||||||
|
};
|
||||||
|
self.resident_game = Some(ResidentGame {
|
||||||
|
task_id,
|
||||||
|
state: ResidentGameState::PauseRequested {
|
||||||
|
remaining_ticks: remaining_ticks.saturating_sub(1).max(1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
Some(game) if game.task_id == task_id => Ok(false),
|
||||||
|
_ => Err(ForegroundStackError::TaskIsNotResidentGame(task_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_resume_game(&mut self, task_id: TaskId) -> Result<(), ForegroundStackError> {
|
||||||
|
match self.resident_game {
|
||||||
|
Some(game) if game.task_id == task_id => {
|
||||||
|
self.resident_game =
|
||||||
|
Some(ResidentGame { task_id, state: ResidentGameState::ResumeRequested });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(ForegroundStackError::TaskIsNotResidentGame(task_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn complete_resume_game(&mut self, task_id: TaskId) -> Result<(), ForegroundStackError> {
|
||||||
|
match self.resident_game {
|
||||||
|
Some(game) if game.task_id == task_id => {
|
||||||
|
self.resident_game =
|
||||||
|
Some(ResidentGame { task_id, state: ResidentGameState::Foreground });
|
||||||
|
self.owner = ForegroundOwner::Game(task_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(ForegroundStackError::TaskIsNotResidentGame(task_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_shell_foreground(&mut self, task_id: TaskId) -> Result<(), ForegroundStackError> {
|
||||||
|
if let ForegroundOwner::Shell(existing) = self.owner
|
||||||
|
&& existing != task_id
|
||||||
|
{
|
||||||
|
return Err(ForegroundStackError::ForegroundShellAlreadyExists {
|
||||||
|
existing,
|
||||||
|
requested: task_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(game) = self.resident_game {
|
||||||
|
self.resident_game = Some(ResidentGame {
|
||||||
|
task_id: game.task_id,
|
||||||
|
state: ResidentGameState::PausedSuspended,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.owner = ForegroundOwner::Shell(task_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn return_to_hub(&mut self) {
|
||||||
|
self.owner = ForegroundOwner::Hub;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_resident_game(&mut self, task_id: TaskId) {
|
||||||
|
if self.resident_game_task() == Some(task_id) {
|
||||||
|
self.resident_game = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.owner == ForegroundOwner::Game(task_id) {
|
||||||
|
self.owner = ForegroundOwner::Hub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_stack_starts_at_hub() {
|
||||||
|
let stack = ForegroundStack::new();
|
||||||
|
|
||||||
|
assert_eq!(stack.owner(), ForegroundOwner::Hub);
|
||||||
|
assert_eq!(stack.resident_game(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setting_game_foreground_registers_resident_game() {
|
||||||
|
let mut stack = ForegroundStack::new();
|
||||||
|
let game = TaskId(7);
|
||||||
|
|
||||||
|
stack.set_game_foreground(game).expect("game should become foreground");
|
||||||
|
|
||||||
|
assert_eq!(stack.owner(), ForegroundOwner::Game(game));
|
||||||
|
assert_eq!(
|
||||||
|
stack.resident_game(),
|
||||||
|
Some(ResidentGame { task_id: game, state: ResidentGameState::Foreground })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_resident_game_is_rejected() {
|
||||||
|
let mut stack = ForegroundStack::new();
|
||||||
|
|
||||||
|
stack.set_game_foreground(TaskId(1)).expect("first game should be accepted");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
stack.set_game_foreground(TaskId(2)),
|
||||||
|
Err(ForegroundStackError::ResidentGameAlreadyExists {
|
||||||
|
existing: TaskId(1),
|
||||||
|
requested: TaskId(2),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_request_moves_resident_game_to_pause_requested() {
|
||||||
|
let mut stack = ForegroundStack::new();
|
||||||
|
let game = TaskId(3);
|
||||||
|
|
||||||
|
stack.set_game_foreground(game).expect("game should be foreground");
|
||||||
|
stack.request_home_from_game(game, 2).expect("home request should pause game");
|
||||||
|
|
||||||
|
assert_eq!(stack.owner(), ForegroundOwner::Game(game));
|
||||||
|
assert_eq!(
|
||||||
|
stack.resident_game(),
|
||||||
|
Some(ResidentGame {
|
||||||
|
task_id: game,
|
||||||
|
state: ResidentGameState::PauseRequested { remaining_ticks: 2 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_budget_expiry_suspends_game_and_returns_to_hub() {
|
||||||
|
let mut stack = ForegroundStack::new();
|
||||||
|
let game = TaskId(3);
|
||||||
|
|
||||||
|
stack.set_game_foreground(game).expect("game should be foreground");
|
||||||
|
stack.request_home_from_game(game, 1).expect("home request should pause game");
|
||||||
|
|
||||||
|
assert!(stack.advance_pause_budget(game).expect("budget should advance"));
|
||||||
|
assert_eq!(stack.owner(), ForegroundOwner::Hub);
|
||||||
|
assert_eq!(
|
||||||
|
stack.resident_game(),
|
||||||
|
Some(ResidentGame { task_id: game, state: ResidentGameState::PausedSuspended })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_budget_decrements_before_expiry() {
|
||||||
|
let mut stack = ForegroundStack::new();
|
||||||
|
let game = TaskId(3);
|
||||||
|
|
||||||
|
stack.set_game_foreground(game).expect("game should be foreground");
|
||||||
|
stack.request_home_from_game(game, 2).expect("home request should pause game");
|
||||||
|
|
||||||
|
assert!(!stack.advance_pause_budget(game).expect("budget should advance"));
|
||||||
|
assert_eq!(stack.owner(), ForegroundOwner::Game(game));
|
||||||
|
assert_eq!(
|
||||||
|
stack.resident_game(),
|
||||||
|
Some(ResidentGame {
|
||||||
|
task_id: game,
|
||||||
|
state: ResidentGameState::PauseRequested { remaining_ticks: 1 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(stack.advance_pause_budget(game).expect("budget should expire"));
|
||||||
|
assert_eq!(stack.owner(), ForegroundOwner::Hub);
|
||||||
|
assert_eq!(
|
||||||
|
stack.resident_game(),
|
||||||
|
Some(ResidentGame { task_id: game, state: ResidentGameState::PausedSuspended })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resume_request_precedes_game_foreground_restore() {
|
||||||
|
let mut stack = ForegroundStack::new();
|
||||||
|
let game = TaskId(3);
|
||||||
|
|
||||||
|
stack.set_game_foreground(game).expect("game should be foreground");
|
||||||
|
stack.request_home_from_game(game, 1).expect("home request should pause game");
|
||||||
|
stack.advance_pause_budget(game).expect("budget should expire");
|
||||||
|
|
||||||
|
stack.request_resume_game(game).expect("resume should be requested");
|
||||||
|
assert_eq!(stack.owner(), ForegroundOwner::Hub);
|
||||||
|
assert_eq!(
|
||||||
|
stack.resident_game(),
|
||||||
|
Some(ResidentGame { task_id: game, state: ResidentGameState::ResumeRequested })
|
||||||
|
);
|
||||||
|
|
||||||
|
stack.complete_resume_game(game).expect("resume should complete");
|
||||||
|
assert_eq!(stack.owner(), ForegroundOwner::Game(game));
|
||||||
|
assert_eq!(
|
||||||
|
stack.resident_game(),
|
||||||
|
Some(ResidentGame { task_id: game, state: ResidentGameState::Foreground })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_foreground_keeps_resident_game() {
|
||||||
|
let mut stack = ForegroundStack::new();
|
||||||
|
let game = TaskId(3);
|
||||||
|
let shell = TaskId(4);
|
||||||
|
|
||||||
|
stack.set_game_foreground(game).expect("game should be foreground");
|
||||||
|
stack.request_home_from_game(game, 1).expect("home request should suspend game");
|
||||||
|
stack.advance_pause_budget(game).expect("budget should expire");
|
||||||
|
stack.set_shell_foreground(shell).expect("shell should become foreground");
|
||||||
|
|
||||||
|
assert_eq!(stack.owner(), ForegroundOwner::Shell(shell));
|
||||||
|
assert_eq!(stack.resident_game_task(), Some(game));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
pub mod async_work;
|
pub mod async_work;
|
||||||
|
pub mod foreground;
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
pub mod game_library;
|
pub mod game_library;
|
||||||
pub mod memcard;
|
pub mod memcard;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod vm_runtime;
|
pub mod vm_runtime;
|
||||||
|
pub mod vm_session;
|
||||||
pub mod windows;
|
pub mod windows;
|
||||||
|
|||||||
@ -32,6 +32,7 @@ impl VirtualMachineRuntime {
|
|||||||
gfx2d_commands: Vec::new(),
|
gfx2d_commands: Vec::new(),
|
||||||
gfxui_commands: Vec::new(),
|
gfxui_commands: Vec::new(),
|
||||||
logs_written_this_frame: HashMap::new(),
|
logs_written_this_frame: HashMap::new(),
|
||||||
|
game_lifecycle_events_delivered: Vec::new(),
|
||||||
atomic_telemetry,
|
atomic_telemetry,
|
||||||
last_crash_report: None,
|
last_crash_report: None,
|
||||||
certifier: Certifier::new(cap_config.unwrap_or_default()),
|
certifier: Certifier::new(cap_config.unwrap_or_default()),
|
||||||
@ -135,6 +136,7 @@ impl VirtualMachineRuntime {
|
|||||||
self.gfx2d_commands.clear();
|
self.gfx2d_commands.clear();
|
||||||
self.gfxui_commands.clear();
|
self.gfxui_commands.clear();
|
||||||
self.logs_written_this_frame.clear();
|
self.logs_written_this_frame.clear();
|
||||||
|
self.game_lifecycle_events_delivered.clear();
|
||||||
|
|
||||||
self.last_crash_report = None;
|
self.last_crash_report = None;
|
||||||
|
|
||||||
@ -191,6 +193,21 @@ impl VirtualMachineRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn deliver_game_lifecycle_events<I>(&mut self, events: I)
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = GameLifecycleEvent>,
|
||||||
|
{
|
||||||
|
self.game_lifecycle_events_delivered.extend(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delivered_game_lifecycle_events(&self) -> &[GameLifecycleEvent] {
|
||||||
|
&self.game_lifecycle_events_delivered
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_delivered_game_lifecycle_events(&mut self) -> Vec<GameLifecycleEvent> {
|
||||||
|
std::mem::take(&mut self.game_lifecycle_events_delivered)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn initialize_vm(
|
pub fn initialize_vm(
|
||||||
&mut self,
|
&mut self,
|
||||||
log_service: &mut LogService,
|
log_service: &mut LogService,
|
||||||
@ -224,3 +241,26 @@ impl VirtualMachineRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::os::{GameLifecycleEvent, GameLifecycleEventKind};
|
||||||
|
use crate::task::TaskId;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vm_runtime_records_and_drains_delivered_game_lifecycle_events() {
|
||||||
|
let mut runtime = VirtualMachineRuntime::new(None);
|
||||||
|
let pause = GameLifecycleEvent { task_id: TaskId(7), kind: GameLifecycleEventKind::Pause };
|
||||||
|
let resume = GameLifecycleEvent {
|
||||||
|
task_id: TaskId(7),
|
||||||
|
kind: GameLifecycleEventKind::ResumeForeground,
|
||||||
|
};
|
||||||
|
|
||||||
|
runtime.deliver_game_lifecycle_events([pause, resume]);
|
||||||
|
|
||||||
|
assert_eq!(runtime.delivered_game_lifecycle_events(), &[pause, resume]);
|
||||||
|
assert_eq!(runtime.take_delivered_game_lifecycle_events(), vec![pause, resume]);
|
||||||
|
assert!(runtime.delivered_game_lifecycle_events().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ mod tests;
|
|||||||
mod tick;
|
mod tick;
|
||||||
|
|
||||||
use crate::CrashReport;
|
use crate::CrashReport;
|
||||||
|
use crate::os::GameLifecycleEvent;
|
||||||
pub use frame_scheduler::FrameScheduler;
|
pub use frame_scheduler::FrameScheduler;
|
||||||
use prometeu_bytecode::string_materialization_count;
|
use prometeu_bytecode::string_materialization_count;
|
||||||
use prometeu_hal::app_mode::AppMode;
|
use prometeu_hal::app_mode::AppMode;
|
||||||
@ -49,6 +50,7 @@ pub struct VirtualMachineRuntime {
|
|||||||
pub gfx2d_commands: Vec<Gfx2dCommand>,
|
pub gfx2d_commands: Vec<Gfx2dCommand>,
|
||||||
pub gfxui_commands: Vec<GfxUiCommand>,
|
pub gfxui_commands: Vec<GfxUiCommand>,
|
||||||
pub logs_written_this_frame: HashMap<u32, u32>,
|
pub logs_written_this_frame: HashMap<u32, u32>,
|
||||||
|
pub game_lifecycle_events_delivered: Vec<GameLifecycleEvent>,
|
||||||
pub atomic_telemetry: Arc<AtomicTelemetry>,
|
pub atomic_telemetry: Arc<AtomicTelemetry>,
|
||||||
pub last_crash_report: Option<CrashReport>,
|
pub last_crash_report: Option<CrashReport>,
|
||||||
pub certifier: Certifier,
|
pub certifier: Certifier,
|
||||||
|
|||||||
281
crates/console/prometeu-system/src/services/vm_session.rs
Normal file
281
crates/console/prometeu-system/src/services/vm_session.rs
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
use crate::VirtualMachineRuntime;
|
||||||
|
use crate::fs::FsState;
|
||||||
|
use crate::process::{Process, ProcessId, ProcessKind};
|
||||||
|
use crate::task::{Task, TaskId, TaskKind};
|
||||||
|
use prometeu_hal::app_mode::AppMode;
|
||||||
|
use prometeu_hal::telemetry::CertificationConfig;
|
||||||
|
use prometeu_vm::VirtualMachine;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicU32;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct VmSessionId(pub TaskId);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VmSessionError {
|
||||||
|
SessionAlreadyExists(TaskId),
|
||||||
|
TaskProcessMismatch { task_id: TaskId, task_process: ProcessId, process_id: ProcessId },
|
||||||
|
TaskKindMismatch { task_id: TaskId, task_kind: TaskKind, process_kind: ProcessKind },
|
||||||
|
ProcessIsNotVmBacked(ProcessId),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VmSession {
|
||||||
|
pub id: VmSessionId,
|
||||||
|
pub task_id: TaskId,
|
||||||
|
pub process_id: ProcessId,
|
||||||
|
pub app_id: u32,
|
||||||
|
pub title: String,
|
||||||
|
pub app_version: String,
|
||||||
|
pub app_mode: AppMode,
|
||||||
|
pub vm: VirtualMachine,
|
||||||
|
pub runtime: VirtualMachineRuntime,
|
||||||
|
pub fs_state: FsState,
|
||||||
|
pub open_files: HashMap<u32, String>,
|
||||||
|
pub next_handle: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VmSession {
|
||||||
|
fn new(
|
||||||
|
task: &Task,
|
||||||
|
process: &Process,
|
||||||
|
app_mode: AppMode,
|
||||||
|
app_version: impl Into<String>,
|
||||||
|
cap_config: Option<CertificationConfig>,
|
||||||
|
logs_count: Arc<AtomicU32>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: VmSessionId(task.id),
|
||||||
|
task_id: task.id,
|
||||||
|
process_id: process.id,
|
||||||
|
app_id: task.app_id,
|
||||||
|
title: task.title.clone(),
|
||||||
|
app_version: app_version.into(),
|
||||||
|
app_mode,
|
||||||
|
vm: VirtualMachine::default(),
|
||||||
|
runtime: VirtualMachineRuntime::new_with_log_counter(cap_config, logs_count),
|
||||||
|
fs_state: FsState::Unmounted,
|
||||||
|
open_files: HashMap::new(),
|
||||||
|
next_handle: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_cartridge_service_state(&mut self) {
|
||||||
|
self.open_files.clear();
|
||||||
|
self.next_handle = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct VmSessionRegistry {
|
||||||
|
sessions: HashMap<VmSessionId, VmSession>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VmSessionRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { sessions: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_for_task(
|
||||||
|
&mut self,
|
||||||
|
task: &Task,
|
||||||
|
process: &Process,
|
||||||
|
app_version: impl Into<String>,
|
||||||
|
cap_config: Option<CertificationConfig>,
|
||||||
|
logs_count: Arc<AtomicU32>,
|
||||||
|
) -> Result<VmSessionId, VmSessionError> {
|
||||||
|
if task.process_id != process.id {
|
||||||
|
return Err(VmSessionError::TaskProcessMismatch {
|
||||||
|
task_id: task.id,
|
||||||
|
task_process: task.process_id,
|
||||||
|
process_id: process.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_mode = app_mode_for_task_and_process(task, process)?;
|
||||||
|
let id = VmSessionId(task.id);
|
||||||
|
if self.sessions.contains_key(&id) {
|
||||||
|
return Err(VmSessionError::SessionAlreadyExists(task.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sessions.insert(
|
||||||
|
id,
|
||||||
|
VmSession::new(task, process, app_mode, app_version, cap_config, logs_count),
|
||||||
|
);
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: VmSessionId) -> Option<&VmSession> {
|
||||||
|
self.sessions.get(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mut(&mut self, id: VmSessionId) -> Option<&mut VmSession> {
|
||||||
|
self.sessions.get_mut(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn for_task(&self, task_id: TaskId) -> Option<&VmSession> {
|
||||||
|
self.get(VmSessionId(task_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn for_task_mut(&mut self, task_id: TaskId) -> Option<&mut VmSession> {
|
||||||
|
self.get_mut(VmSessionId(task_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resident_game(&self) -> Option<&VmSession> {
|
||||||
|
self.sessions.values().find(|session| session.app_mode == AppMode::Game)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resident_game_for_app(&self, app_id: u32) -> Option<&VmSession> {
|
||||||
|
self.sessions
|
||||||
|
.values()
|
||||||
|
.find(|session| session.app_mode == AppMode::Game && session.app_id == app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.sessions.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.sessions.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.sessions.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_mode_for_task_and_process(
|
||||||
|
task: &Task,
|
||||||
|
process: &Process,
|
||||||
|
) -> Result<AppMode, VmSessionError> {
|
||||||
|
match (task.kind, process.kind) {
|
||||||
|
(TaskKind::Game, ProcessKind::VmGame) => Ok(AppMode::Game),
|
||||||
|
(TaskKind::Shell, ProcessKind::VmShell) => Ok(AppMode::Shell),
|
||||||
|
(_, ProcessKind::NativeShell) => Err(VmSessionError::ProcessIsNotVmBacked(process.id)),
|
||||||
|
_ => Err(VmSessionError::TaskKindMismatch {
|
||||||
|
task_id: task.id,
|
||||||
|
task_kind: task.kind,
|
||||||
|
process_kind: process.kind,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::process::Process;
|
||||||
|
use crate::task::Task;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creates_vm_game_session_for_vm_game_task() {
|
||||||
|
let process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame);
|
||||||
|
let task = Task::new(TaskId(7), process.id, 42, "Stress", TaskKind::Game);
|
||||||
|
let mut registry = VmSessionRegistry::new();
|
||||||
|
|
||||||
|
let id = registry
|
||||||
|
.create_for_task(&task, &process, "", None, Arc::new(AtomicU32::new(0)))
|
||||||
|
.expect("session should create");
|
||||||
|
let session = registry.get(id).expect("session should exist");
|
||||||
|
|
||||||
|
assert_eq!(session.id, VmSessionId(task.id));
|
||||||
|
assert_eq!(session.task_id, task.id);
|
||||||
|
assert_eq!(session.process_id, process.id);
|
||||||
|
assert_eq!(session.app_id, 42);
|
||||||
|
assert_eq!(session.title, "Stress");
|
||||||
|
assert_eq!(session.app_mode, AppMode::Game);
|
||||||
|
assert_eq!(session.runtime.current_app_id, 0);
|
||||||
|
assert_eq!(session.fs_state, FsState::Unmounted);
|
||||||
|
assert_eq!(session.next_handle, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creates_vm_shell_session_for_vm_shell_task() {
|
||||||
|
let process = Process::new(ProcessId(1), 7, "Shell", ProcessKind::VmShell);
|
||||||
|
let task = Task::new(TaskId(3), process.id, 7, "Shell", TaskKind::Shell);
|
||||||
|
let mut registry = VmSessionRegistry::new();
|
||||||
|
|
||||||
|
let id = registry
|
||||||
|
.create_for_task(&task, &process, "1.0", None, Arc::new(AtomicU32::new(0)))
|
||||||
|
.expect("session should create");
|
||||||
|
let session = registry.get(id).expect("session should exist");
|
||||||
|
|
||||||
|
assert_eq!(session.app_mode, AppMode::Shell);
|
||||||
|
assert_eq!(session.app_version, "1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_native_shell_process() {
|
||||||
|
let process = Process::new(ProcessId(1), 7, "Shell", ProcessKind::NativeShell);
|
||||||
|
let task = Task::new(TaskId(3), process.id, 7, "Shell", TaskKind::Shell);
|
||||||
|
let mut registry = VmSessionRegistry::new();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
registry.create_for_task(&task, &process, "", None, Arc::new(AtomicU32::new(0))),
|
||||||
|
Err(VmSessionError::ProcessIsNotVmBacked(process.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finds_resident_game_by_app_id() {
|
||||||
|
let process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame);
|
||||||
|
let task = Task::new(TaskId(7), process.id, 42, "Stress", TaskKind::Game);
|
||||||
|
let mut registry = VmSessionRegistry::new();
|
||||||
|
registry
|
||||||
|
.create_for_task(&task, &process, "", None, Arc::new(AtomicU32::new(0)))
|
||||||
|
.expect("session should create");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
registry.resident_game_for_app(42).map(|session| session.task_id),
|
||||||
|
Some(task.id)
|
||||||
|
);
|
||||||
|
assert_eq!(registry.resident_game_for_app(43).map(|session| session.task_id), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_runtime_state_is_independent_per_session() {
|
||||||
|
let game_process = Process::new(ProcessId(1), 42, "Stress", ProcessKind::VmGame);
|
||||||
|
let game_task = Task::new(TaskId(7), game_process.id, 42, "Stress", TaskKind::Game);
|
||||||
|
let shell_process = Process::new(ProcessId(2), 9, "Shell", ProcessKind::VmShell);
|
||||||
|
let shell_task = Task::new(TaskId(8), shell_process.id, 9, "Shell", TaskKind::Shell);
|
||||||
|
let mut registry = VmSessionRegistry::new();
|
||||||
|
|
||||||
|
registry
|
||||||
|
.create_for_task(&game_task, &game_process, "", None, Arc::new(AtomicU32::new(0)))
|
||||||
|
.expect("game session should create");
|
||||||
|
registry
|
||||||
|
.create_for_task(&shell_task, &shell_process, "", None, Arc::new(AtomicU32::new(0)))
|
||||||
|
.expect("shell session should create");
|
||||||
|
|
||||||
|
registry
|
||||||
|
.for_task_mut(game_task.id)
|
||||||
|
.expect("game session should exist")
|
||||||
|
.runtime
|
||||||
|
.tick_index = 11;
|
||||||
|
registry
|
||||||
|
.for_task_mut(shell_task.id)
|
||||||
|
.expect("shell session should exist")
|
||||||
|
.runtime
|
||||||
|
.tick_index = 3;
|
||||||
|
registry
|
||||||
|
.for_task_mut(game_task.id)
|
||||||
|
.expect("game session should exist")
|
||||||
|
.open_files
|
||||||
|
.insert(1, "/game.save".to_string());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
registry.for_task(game_task.id).map(|session| session.runtime.tick_index),
|
||||||
|
Some(11)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
registry.for_task(shell_task.id).map(|session| session.runtime.tick_index),
|
||||||
|
Some(3)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.for_task(shell_task.id)
|
||||||
|
.expect("shell session should exist")
|
||||||
|
.open_files
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,6 +28,8 @@ pub struct HostDebugger {
|
|||||||
last_telemetry_frame: u64,
|
last_telemetry_frame: u64,
|
||||||
/// Last fault summary sent to the debugger client.
|
/// Last fault summary sent to the debugger client.
|
||||||
last_fault_summary: Option<String>,
|
last_fault_summary: Option<String>,
|
||||||
|
/// Debug cartridge metadata captured before the VM session exists.
|
||||||
|
debug_cartridge: Option<HandshakeCartridge>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HostDebugger {
|
impl Default for HostDebugger {
|
||||||
@ -46,19 +48,25 @@ impl HostDebugger {
|
|||||||
last_log_seq: 0,
|
last_log_seq: 0,
|
||||||
last_telemetry_frame: 0,
|
last_telemetry_frame: 0,
|
||||||
last_fault_summary: None,
|
last_fault_summary: None,
|
||||||
|
debug_cartridge: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configures the debugger based on the boot target.
|
/// Configures the debugger based on the boot target.
|
||||||
/// If debug mode is enabled, it binds to the specified TCP port.
|
/// If debug mode is enabled, it binds to the specified TCP port.
|
||||||
pub fn setup_boot_target(&mut self, boot_target: &BootTarget, firmware: &mut Firmware) {
|
pub fn setup_boot_target(&mut self, boot_target: &BootTarget, _firmware: &mut Firmware) {
|
||||||
if let BootTarget::Cartridge { path, debug: true, debug_port } = boot_target {
|
if let BootTarget::Cartridge { path, debug: true, debug_port } = boot_target {
|
||||||
self.waiting_for_start = true;
|
self.waiting_for_start = true;
|
||||||
|
|
||||||
// Pre-load cartridge metadata so the Handshake message can contain
|
// Pre-load cartridge metadata so the Handshake message can contain
|
||||||
// valid information about the App being debugged.
|
// valid information before the VM session is created.
|
||||||
if let Ok(cartridge) = CartridgeLoader::load(path) {
|
if let Ok(cartridge) = CartridgeLoader::load(path) {
|
||||||
let _ = firmware.os.vm().initialize(&mut firmware.vm, &cartridge);
|
self.debug_cartridge = Some(HandshakeCartridge {
|
||||||
|
app_id: cartridge.app_id,
|
||||||
|
title: cartridge.title,
|
||||||
|
app_version: cartridge.app_version,
|
||||||
|
app_mode: cartridge.app_mode,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
match TcpListener::bind(format!("127.0.0.1:{}", debug_port)) {
|
match TcpListener::bind(format!("127.0.0.1:{}", debug_port)) {
|
||||||
@ -116,12 +124,7 @@ impl HostDebugger {
|
|||||||
let handshake = DebugResponse::Handshake {
|
let handshake = DebugResponse::Handshake {
|
||||||
protocol_version: DEVTOOLS_PROTOCOL_VERSION,
|
protocol_version: DEVTOOLS_PROTOCOL_VERSION,
|
||||||
runtime_version: "0.1".to_string(),
|
runtime_version: "0.1".to_string(),
|
||||||
cartridge: HandshakeCartridge {
|
cartridge: self.handshake_cartridge(firmware),
|
||||||
app_id: firmware.os.vm().current_app_id(),
|
|
||||||
title: firmware.os.vm().current_cartridge_title(),
|
|
||||||
app_version: firmware.os.vm().current_cartridge_app_version(),
|
|
||||||
app_mode: firmware.os.vm().current_cartridge_app_mode(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
self.send_response(handshake);
|
self.send_response(handshake);
|
||||||
} else {
|
} else {
|
||||||
@ -201,7 +204,7 @@ impl HostDebugger {
|
|||||||
DebugCommand::Step => {
|
DebugCommand::Step => {
|
||||||
// Execute exactly one instruction and keep paused.
|
// Execute exactly one instruction and keep paused.
|
||||||
firmware.os.vm().set_paused(true);
|
firmware.os.vm().set_paused(true);
|
||||||
let _ = firmware.os.vm().debug_step_instruction(&mut firmware.vm, platform);
|
let _ = firmware.os.vm().debug_step_active_session(platform);
|
||||||
}
|
}
|
||||||
DebugCommand::StepFrame => {
|
DebugCommand::StepFrame => {
|
||||||
// Execute until the end of the current logical frame.
|
// Execute until the end of the current logical frame.
|
||||||
@ -210,10 +213,10 @@ impl HostDebugger {
|
|||||||
}
|
}
|
||||||
DebugCommand::GetState => {
|
DebugCommand::GetState => {
|
||||||
// Return detailed VM register and stack state.
|
// Return detailed VM register and stack state.
|
||||||
let stack_top = firmware.vm.operand_stack_top(10);
|
let stack_top = firmware.os.vm().active_operand_stack_top(10);
|
||||||
|
|
||||||
let resp = DebugResponse::GetState {
|
let resp = DebugResponse::GetState {
|
||||||
pc: firmware.vm.pc(),
|
pc: firmware.os.vm().active_pc().unwrap_or(0),
|
||||||
stack_top,
|
stack_top,
|
||||||
frame_index: firmware.os.vm().logical_frame_index(),
|
frame_index: firmware.os.vm().logical_frame_index(),
|
||||||
app_id: firmware.os.vm().current_app_id(),
|
app_id: firmware.os.vm().current_app_id(),
|
||||||
@ -221,18 +224,37 @@ impl HostDebugger {
|
|||||||
self.send_response(resp);
|
self.send_response(resp);
|
||||||
}
|
}
|
||||||
DebugCommand::SetBreakpoint { pc } => {
|
DebugCommand::SetBreakpoint { pc } => {
|
||||||
firmware.vm.insert_breakpoint(pc);
|
let _ = firmware.os.vm().insert_active_breakpoint(pc);
|
||||||
}
|
}
|
||||||
DebugCommand::ListBreakpoints => {
|
DebugCommand::ListBreakpoints => {
|
||||||
let pcs = firmware.vm.breakpoints_list();
|
let pcs = firmware.os.vm().active_breakpoints_list();
|
||||||
self.send_response(DebugResponse::Breakpoints { pcs });
|
self.send_response(DebugResponse::Breakpoints { pcs });
|
||||||
}
|
}
|
||||||
DebugCommand::ClearBreakpoint { pc } => {
|
DebugCommand::ClearBreakpoint { pc } => {
|
||||||
firmware.vm.remove_breakpoint(pc);
|
let _ = firmware.os.vm().remove_active_breakpoint(pc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handshake_cartridge(&self, firmware: &mut Firmware) -> HandshakeCartridge {
|
||||||
|
let app_id = firmware.os.vm().current_app_id();
|
||||||
|
if app_id != 0 {
|
||||||
|
return HandshakeCartridge {
|
||||||
|
app_id,
|
||||||
|
title: firmware.os.vm().current_cartridge_title(),
|
||||||
|
app_version: firmware.os.vm().current_cartridge_app_version(),
|
||||||
|
app_mode: firmware.os.vm().current_cartridge_app_mode(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
self.debug_cartridge.clone().unwrap_or_else(|| HandshakeCartridge {
|
||||||
|
app_id,
|
||||||
|
title: firmware.os.vm().current_cartridge_title(),
|
||||||
|
app_version: firmware.os.vm().current_cartridge_app_version(),
|
||||||
|
app_mode: firmware.os.vm().current_cartridge_app_mode(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn cert_event_from_snapshot(
|
pub(crate) fn cert_event_from_snapshot(
|
||||||
tag: u16,
|
tag: u16,
|
||||||
telemetry: TelemetryFrame,
|
telemetry: TelemetryFrame,
|
||||||
@ -315,7 +337,7 @@ impl HostDebugger {
|
|||||||
// Map specific internal log tags to protocol events.
|
// Map specific internal log tags to protocol events.
|
||||||
if event.tag == 0xDEB1 {
|
if event.tag == 0xDEB1 {
|
||||||
self.send_event(DebugEvent::BreakpointHit {
|
self.send_event(DebugEvent::BreakpointHit {
|
||||||
pc: firmware.vm.pc(),
|
pc: firmware.os.vm().active_pc().unwrap_or(0),
|
||||||
frame_index: firmware.os.vm().logical_frame_index(),
|
frame_index: firmware.os.vm().logical_frame_index(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -409,6 +431,52 @@ mod tests {
|
|||||||
assert!(firmware.os.vm().debug_step_requested());
|
assert!(firmware.os.vm().debug_step_requested());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breakpoint_commands_mutate_active_vm_session() {
|
||||||
|
let mut debugger = HostDebugger::new();
|
||||||
|
let mut firmware = Firmware::new(None);
|
||||||
|
let mut platform = TestPlatform::new();
|
||||||
|
let task_id = firmware
|
||||||
|
.os
|
||||||
|
.sessions()
|
||||||
|
.try_create_vm_game_task(42, "Sector Crawl")
|
||||||
|
.expect("debugger test should create a game session");
|
||||||
|
|
||||||
|
debugger.handle_command(
|
||||||
|
DebugCommand::SetBreakpoint { pc: 123 },
|
||||||
|
&mut firmware,
|
||||||
|
&mut platform,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
firmware
|
||||||
|
.os
|
||||||
|
.sessions()
|
||||||
|
.vm_session_for_task(task_id)
|
||||||
|
.expect("game VM session should exist")
|
||||||
|
.vm
|
||||||
|
.breakpoints_list(),
|
||||||
|
vec![123]
|
||||||
|
);
|
||||||
|
|
||||||
|
debugger.handle_command(
|
||||||
|
DebugCommand::ClearBreakpoint { pc: 123 },
|
||||||
|
&mut firmware,
|
||||||
|
&mut platform,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
firmware
|
||||||
|
.os
|
||||||
|
.sessions()
|
||||||
|
.vm_session_for_task(task_id)
|
||||||
|
.expect("game VM session should exist")
|
||||||
|
.vm
|
||||||
|
.breakpoints_list()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handle_command_start_leaves_waiting_for_start_mode() {
|
fn handle_command_start_leaves_waiting_for_start_mode() {
|
||||||
let mut debugger = HostDebugger::new();
|
let mut debugger = HostDebugger::new();
|
||||||
|
|||||||
@ -43,9 +43,27 @@ impl FbViewport {
|
|||||||
|
|
||||||
pub struct HostInputHandler {
|
pub struct HostInputHandler {
|
||||||
pub signals: InputSignals,
|
pub signals: InputSignals,
|
||||||
|
system_controls: HostSystemControls,
|
||||||
display_size: (usize, usize),
|
display_size: (usize, usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct HostSystemControls {
|
||||||
|
home_requested: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostSystemControls {
|
||||||
|
pub fn request_home(&mut self) {
|
||||||
|
self.home_requested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_home_requested(&mut self) -> bool {
|
||||||
|
let requested = self.home_requested;
|
||||||
|
self.home_requested = false;
|
||||||
|
requested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for HostInputHandler {
|
impl Default for HostInputHandler {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new((480, 270))
|
Self::new((480, 270))
|
||||||
@ -54,35 +72,51 @@ impl Default for HostInputHandler {
|
|||||||
|
|
||||||
impl HostInputHandler {
|
impl HostInputHandler {
|
||||||
pub fn new(display_size: (usize, usize)) -> Self {
|
pub fn new(display_size: (usize, usize)) -> Self {
|
||||||
Self { signals: InputSignals::default(), display_size }
|
Self {
|
||||||
|
signals: InputSignals::default(),
|
||||||
|
system_controls: HostSystemControls::default(),
|
||||||
|
display_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_home_requested(&mut self) -> bool {
|
||||||
|
self.system_controls.take_home_requested()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_guest_signals(&mut self) {
|
||||||
|
self.signals = InputSignals::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key_code(&mut self, code: KeyCode, state: ElementState) {
|
||||||
|
let is_down = state == ElementState::Pressed;
|
||||||
|
|
||||||
|
match code {
|
||||||
|
KeyCode::Escape | KeyCode::Home if is_down => self.system_controls.request_home(),
|
||||||
|
|
||||||
|
KeyCode::ArrowUp => self.signals.up_signal = is_down,
|
||||||
|
KeyCode::ArrowDown => self.signals.down_signal = is_down,
|
||||||
|
KeyCode::ArrowLeft => self.signals.left_signal = is_down,
|
||||||
|
KeyCode::ArrowRight => self.signals.right_signal = is_down,
|
||||||
|
|
||||||
|
KeyCode::KeyA => self.signals.a_signal = is_down,
|
||||||
|
KeyCode::KeyD => self.signals.b_signal = is_down,
|
||||||
|
KeyCode::KeyW => self.signals.x_signal = is_down,
|
||||||
|
KeyCode::KeyS => self.signals.y_signal = is_down,
|
||||||
|
KeyCode::KeyQ => self.signals.l_signal = is_down,
|
||||||
|
KeyCode::KeyE => self.signals.r_signal = is_down,
|
||||||
|
|
||||||
|
KeyCode::KeyZ => self.signals.start_signal = is_down,
|
||||||
|
KeyCode::ShiftLeft | KeyCode::ShiftRight => self.signals.select_signal = is_down,
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_event(&mut self, event: &WindowEvent, window: &Window) {
|
pub fn handle_event(&mut self, event: &WindowEvent, window: &Window) {
|
||||||
match event {
|
match event {
|
||||||
WindowEvent::KeyboardInput { event, .. } => {
|
WindowEvent::KeyboardInput { event, .. } => {
|
||||||
if let PhysicalKey::Code(code) = event.physical_key {
|
if let PhysicalKey::Code(code) = event.physical_key {
|
||||||
let is_down = event.state == ElementState::Pressed;
|
self.handle_key_code(code, event.state);
|
||||||
|
|
||||||
match code {
|
|
||||||
KeyCode::ArrowUp => self.signals.up_signal = is_down,
|
|
||||||
KeyCode::ArrowDown => self.signals.down_signal = is_down,
|
|
||||||
KeyCode::ArrowLeft => self.signals.left_signal = is_down,
|
|
||||||
KeyCode::ArrowRight => self.signals.right_signal = is_down,
|
|
||||||
|
|
||||||
KeyCode::KeyA => self.signals.a_signal = is_down,
|
|
||||||
KeyCode::KeyD => self.signals.b_signal = is_down,
|
|
||||||
KeyCode::KeyW => self.signals.x_signal = is_down,
|
|
||||||
KeyCode::KeyS => self.signals.y_signal = is_down,
|
|
||||||
KeyCode::KeyQ => self.signals.l_signal = is_down,
|
|
||||||
KeyCode::KeyE => self.signals.r_signal = is_down,
|
|
||||||
|
|
||||||
KeyCode::KeyZ => self.signals.start_signal = is_down,
|
|
||||||
KeyCode::ShiftLeft | KeyCode::ShiftRight => {
|
|
||||||
self.signals.select_signal = is_down
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,3 +168,81 @@ impl HostInputHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn assert_no_pad_signal(signals: InputSignals) {
|
||||||
|
assert!(!signals.up_signal);
|
||||||
|
assert!(!signals.down_signal);
|
||||||
|
assert!(!signals.left_signal);
|
||||||
|
assert!(!signals.right_signal);
|
||||||
|
assert!(!signals.a_signal);
|
||||||
|
assert!(!signals.b_signal);
|
||||||
|
assert!(!signals.x_signal);
|
||||||
|
assert!(!signals.y_signal);
|
||||||
|
assert!(!signals.l_signal);
|
||||||
|
assert!(!signals.r_signal);
|
||||||
|
assert!(!signals.start_signal);
|
||||||
|
assert!(!signals.select_signal);
|
||||||
|
assert!(!signals.f_signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_requests_home_without_mutating_guest_pad() {
|
||||||
|
let mut input = HostInputHandler::default();
|
||||||
|
|
||||||
|
input.handle_key_code(KeyCode::Escape, ElementState::Pressed);
|
||||||
|
|
||||||
|
assert!(input.take_home_requested());
|
||||||
|
assert!(!input.take_home_requested());
|
||||||
|
assert_no_pad_signal(input.signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn physical_home_requests_home_without_mutating_guest_pad() {
|
||||||
|
let mut input = HostInputHandler::default();
|
||||||
|
|
||||||
|
input.handle_key_code(KeyCode::Home, ElementState::Pressed);
|
||||||
|
|
||||||
|
assert!(input.take_home_requested());
|
||||||
|
assert_no_pad_signal(input.signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_release_does_not_emit_a_new_home_request() {
|
||||||
|
let mut input = HostInputHandler::default();
|
||||||
|
|
||||||
|
input.handle_key_code(KeyCode::Home, ElementState::Released);
|
||||||
|
|
||||||
|
assert!(!input.take_home_requested());
|
||||||
|
assert_no_pad_signal(input.signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_keyboard_pad_mapping_is_preserved() {
|
||||||
|
let mut input = HostInputHandler::default();
|
||||||
|
|
||||||
|
input.handle_key_code(KeyCode::KeyZ, ElementState::Pressed);
|
||||||
|
input.handle_key_code(KeyCode::ShiftLeft, ElementState::Pressed);
|
||||||
|
input.handle_key_code(KeyCode::ArrowUp, ElementState::Pressed);
|
||||||
|
|
||||||
|
assert!(input.signals.start_signal);
|
||||||
|
assert!(input.signals.select_signal);
|
||||||
|
assert!(input.signals.up_signal);
|
||||||
|
assert!(!input.take_home_requested());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_guest_signals_preserves_system_home_control() {
|
||||||
|
let mut input = HostInputHandler::default();
|
||||||
|
|
||||||
|
input.handle_key_code(KeyCode::ArrowUp, ElementState::Pressed);
|
||||||
|
input.handle_key_code(KeyCode::Escape, ElementState::Pressed);
|
||||||
|
input.clear_guest_signals();
|
||||||
|
|
||||||
|
assert_no_pad_signal(input.signals);
|
||||||
|
assert!(input.take_home_requested());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -10,8 +10,8 @@ use pixels::{Pixels, PixelsBuilder, SurfaceTexture};
|
|||||||
use prometeu_drivers::hardware::Hardware;
|
use prometeu_drivers::hardware::Hardware;
|
||||||
use prometeu_drivers::{AudioCommand, LocalFramebufferRenderBackend, MemoryBanks};
|
use prometeu_drivers::{AudioCommand, LocalFramebufferRenderBackend, MemoryBanks};
|
||||||
use prometeu_firmware::{BootTarget, Firmware, FirmwareState};
|
use prometeu_firmware::{BootTarget, Firmware, FirmwareState};
|
||||||
use prometeu_hal::RuntimePlatform;
|
|
||||||
use prometeu_hal::telemetry::CertificationConfig;
|
use prometeu_hal::telemetry::CertificationConfig;
|
||||||
|
use prometeu_hal::{OwnedRgba8888Frame, RenderOwnership, RuntimePlatform};
|
||||||
use prometeu_system::{LatestRenderFrameStore, RenderWorkerConfig, discover_games_root};
|
use prometeu_system::{LatestRenderFrameStore, RenderWorkerConfig, discover_games_root};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@ -88,8 +88,12 @@ fn desired_control_flow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_present_worker_frame(state: &FirmwareState) -> bool {
|
fn should_present_worker_frame(
|
||||||
matches!(state, FirmwareState::GameRunning(_))
|
state: &FirmwareState,
|
||||||
|
frame: &OwnedRgba8888Frame,
|
||||||
|
active_ownership: RenderOwnership,
|
||||||
|
) -> bool {
|
||||||
|
matches!(state, FirmwareState::GameRunning(_)) && frame.ownership == active_ownership
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Desktop implementation of the PROMETEU Runtime.
|
/// The Desktop implementation of the PROMETEU Runtime.
|
||||||
@ -312,8 +316,13 @@ impl ApplicationHandler for HostRunner {
|
|||||||
{
|
{
|
||||||
let frame = pixels.frame_mut();
|
let frame = pixels.frame_mut();
|
||||||
|
|
||||||
if should_present_worker_frame(&self.firmware.state)
|
let active_ownership = self.firmware.os.vm().active_render_ownership();
|
||||||
&& let Some(worker_frame) = self.render_frame_store.latest_frame()
|
if let Some(worker_frame) = self.render_frame_store.latest_frame()
|
||||||
|
&& should_present_worker_frame(
|
||||||
|
&self.firmware.state,
|
||||||
|
&worker_frame,
|
||||||
|
active_ownership,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
self.firmware
|
self.firmware
|
||||||
.os
|
.os
|
||||||
@ -372,6 +381,13 @@ impl ApplicationHandler for HostRunner {
|
|||||||
self.firmware.os.fs().mount(Box::new(backend));
|
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).
|
// 3. Timing Management (The heart of determinism).
|
||||||
// We measure the elapsed time since the last iteration and add it to an
|
// 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. We then execute exactly as many 60Hz slices as the
|
||||||
@ -399,7 +415,10 @@ impl ApplicationHandler for HostRunner {
|
|||||||
// Sync pause state with audio.
|
// Sync pause state with audio.
|
||||||
// We do this AFTER firmware.tick to avoid MasterPause/Resume commands
|
// 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.
|
// being cleared by the OS if a new logical frame starts in this tick.
|
||||||
let is_paused = self.firmware.os.vm().paused() || self.debugger.waiting_for_start;
|
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 {
|
if is_paused != self.last_paused_state {
|
||||||
self.last_paused_state = is_paused;
|
self.last_paused_state = is_paused;
|
||||||
let cmd =
|
let cmd =
|
||||||
@ -414,8 +433,9 @@ impl ApplicationHandler for HostRunner {
|
|||||||
self.stats.record_frame();
|
self.stats.record_frame();
|
||||||
}
|
}
|
||||||
|
|
||||||
if should_present_worker_frame(&self.firmware.state)
|
let active_ownership = self.firmware.os.vm().active_render_ownership();
|
||||||
&& let Some(worker_frame) = self.render_frame_store.latest_frame()
|
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.frame_id.get());
|
self.presentation.note_published_frame(worker_frame.frame_id.get());
|
||||||
} else {
|
} else {
|
||||||
@ -536,14 +556,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_frame_presentation_is_game_only() {
|
fn worker_frame_presentation_requires_game_state_and_current_ownership() {
|
||||||
assert!(should_present_worker_frame(&FirmwareState::GameRunning(GameRunningStep::new(
|
let active = RenderOwnership::new(3, AppMode::Game, 1);
|
||||||
TaskId(1),
|
let current = OwnedRgba8888Frame::packed(FrameId::new(9), active, 1, 1, vec![0xFF00FFFF])
|
||||||
))));
|
.expect("test frame");
|
||||||
assert!(!should_present_worker_frame(&FirmwareState::ShellRunning(ShellRunningStep::new(
|
let stale = OwnedRgba8888Frame::packed(
|
||||||
TaskId(2)
|
FrameId::new(8),
|
||||||
),)));
|
RenderOwnership::new(2, AppMode::Game, 1),
|
||||||
assert!(!should_present_worker_frame(&FirmwareState::HubHome(HubHomeStep)));
|
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]
|
#[test]
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
{"type":"meta","next_id":{"DSC":44,"AGD":46,"DEC":37,"PLN":136,"LSN":51,"CLSN":1}}
|
{"type":"meta","next_id":{"DSC":44,"AGD":47,"DEC":39,"PLN":157,"LSN":53,"CLSN":1}}
|
||||||
{"type":"discussion","id":"DSC-0043","status":"open","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-03","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[{"id":"AGD-0044","file":"AGD-0044-systemos-cartridge-switch-orchestrator.md","status":"open","created_at":"2026-07-03","updated_at":"2026-07-03"}],"decisions":[],"plans":[],"lessons":[]}
|
{"type":"discussion","id":"DSC-0043","status":"open","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-03","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[{"id":"AGD-0044","file":"AGD-0044-systemos-cartridge-switch-orchestrator.md","status":"open","created_at":"2026-07-03","updated_at":"2026-07-03"}],"decisions":[],"plans":[],"lessons":[]}
|
||||||
{"type":"discussion","id":"DSC-0039","status":"abandoned","ticket":"render-pipeline-family-and-future-3d","title":"Render Pipeline Family and Future 3D","created_at":"2026-06-04","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","architecture","pipeline"],"agendas":[{"id":"AGD-0039","file":"AGD-0039-render-pipeline-family-and-future-3d.md","status":"abandoned","created_at":"2026-06-04","updated_at":"2026-06-04","_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."}
|
{"type":"discussion","id":"DSC-0039","status":"abandoned","ticket":"render-pipeline-family-and-future-3d","title":"Render Pipeline Family and Future 3D","created_at":"2026-06-04","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","architecture","pipeline"],"agendas":[{"id":"AGD-0039","file":"AGD-0039-render-pipeline-family-and-future-3d.md","status":"abandoned","created_at":"2026-06-04","updated_at":"2026-06-04","_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."}
|
||||||
{"type":"discussion","id":"DSC-0040","status":"done","ticket":"vm-render-parallel-execution-boundary","title":"VM and Render Parallel Execution Boundary","created_at":"2026-06-04","updated_at":"2026-06-06","tags":["runtime","renderer","vm","concurrency","architecture","perf"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0048","file":"discussion/lessons/DSC-0040-vm-render-parallel-execution-boundary/LSN-0048-render-workers-need-a-closed-packet-contract.md","status":"done","created_at":"2026-06-06","updated_at":"2026-06-06"}]}
|
{"type":"discussion","id":"DSC-0040","status":"done","ticket":"vm-render-parallel-execution-boundary","title":"VM and Render Parallel Execution Boundary","created_at":"2026-06-04","updated_at":"2026-06-06","tags":["runtime","renderer","vm","concurrency","architecture","perf"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0048","file":"discussion/lessons/DSC-0040-vm-render-parallel-execution-boundary/LSN-0048-render-workers-need-a-closed-packet-contract.md","status":"done","created_at":"2026-06-06","updated_at":"2026-06-06"}]}
|
||||||
{"type":"discussion","id":"DSC-0041","status":"open","ticket":"foreground-stack-game-pause-shell-vm-backed","title":"Foreground Stack, Game Pause, and VM-Backed Shell Coexistence","created_at":"2026-06-05","updated_at":"2026-06-05","tags":["runtime","os","lifecycle","shell","game","vm","foreground","architecture"],"agendas":[{"id":"AGD-0041","file":"AGD-0041-foreground-stack-game-pause-shell-vm-backed.md","status":"open","created_at":"2026-06-05","updated_at":"2026-06-05"}],"decisions":[],"plans":[],"lessons":[]}
|
{"type":"discussion","id":"DSC-0041","status":"done","ticket":"foreground-stack-game-pause-shell-vm-backed","title":"Foreground Stack, Game Pause, and VM-Backed Shell Coexistence","created_at":"2026-06-05","updated_at":"2026-07-05","tags":["runtime","os","lifecycle","shell","game","vm","foreground","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0052","file":"discussion/lessons/DSC-0041-foreground-stack-game-pause-shell-vm-backed/LSN-0052-foreground-ownership-and-vm-session-ownership-are-separate.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05"}]}
|
||||||
{"type":"discussion","id":"DSC-0042","status":"done","ticket":"real-render-worker-establishment","title":"Real Render Worker Establishment","created_at":"2026-06-06","updated_at":"2026-06-20","tags":["runtime","renderer","worker","concurrency","host","hal","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0049","file":"discussion/lessons/DSC-0042-real-render-worker-establishment/LSN-0049-render-worker-publication-boundary.md","status":"done","created_at":"2026-06-20","updated_at":"2026-06-20"}]}
|
{"type":"discussion","id":"DSC-0042","status":"done","ticket":"real-render-worker-establishment","title":"Real Render Worker Establishment","created_at":"2026-06-06","updated_at":"2026-06-20","tags":["runtime","renderer","worker","concurrency","host","hal","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0049","file":"discussion/lessons/DSC-0042-real-render-worker-establishment/LSN-0049-render-worker-publication-boundary.md","status":"done","created_at":"2026-06-20","updated_at":"2026-06-20"}]}
|
||||||
{"type":"discussion","id":"DSC-0038","status":"done","ticket":"render-frame-packet-boundary","title":"Logical Render Pipelines and Command Packets","created_at":"2026-05-25","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","frame-composer","architecture","ui","pipeline"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0047","file":"discussion/lessons/DSC-0038-render-frame-packet-boundary/LSN-0047-typed-render-submissions-preserve-domain-boundaries.md","status":"done","created_at":"2026-06-04","updated_at":"2026-06-04"}]}
|
{"type":"discussion","id":"DSC-0038","status":"done","ticket":"render-frame-packet-boundary","title":"Logical Render Pipelines and Command Packets","created_at":"2026-05-25","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","frame-composer","architecture","ui","pipeline"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0047","file":"discussion/lessons/DSC-0038-render-frame-packet-boundary/LSN-0047-typed-render-submissions-preserve-domain-boundaries.md","status":"done","created_at":"2026-06-04","updated_at":"2026-06-04"}]}
|
||||||
{"type":"discussion","id":"DSC-0035","status":"done","ticket":"task-owned-shell-windows","title":"Agenda - Task-Owned Shell Windows","created_at":"2026-05-15","updated_at":"2026-05-15","tags":["runtime","os","task","window-manager","shell","lifecycle"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0044","file":"discussion/lessons/DSC-0035-task-owned-shell-windows/LSN-0044-task-window-liveness-belongs-to-the-task.md","status":"done","created_at":"2026-05-15","updated_at":"2026-05-15"}]}
|
{"type":"discussion","id":"DSC-0035","status":"done","ticket":"task-owned-shell-windows","title":"Agenda - Task-Owned Shell Windows","created_at":"2026-05-15","updated_at":"2026-05-15","tags":["runtime","os","task","window-manager","shell","lifecycle"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0044","file":"discussion/lessons/DSC-0035-task-owned-shell-windows/LSN-0044-task-window-liveness-belongs-to-the-task.md","status":"done","created_at":"2026-05-15","updated_at":"2026-05-15"}]}
|
||||||
@ -14,7 +14,7 @@
|
|||||||
{"type":"discussion","id":"DSC-0001","status":"done","ticket":"legacy-runtime-learn-import","title":"Import legacy runtime learn into discussion lessons","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["migration","tech-debt"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0001-prometeu-learn-index.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0002","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0002-historical-asset-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0003","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0003-historical-audio-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0004","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0004-historical-cartridge-boot-protocol-and-manifest-authority.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0005","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0005-historical-game-memcard-slots-surface-and-semantics.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0006","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0006-historical-gfx-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0007","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0007-historical-retired-fault-and-input-decisions.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0008","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0008-historical-vm-core-and-assets.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0009","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0009-mental-model-asset-management.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0010","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0010-mental-model-audio.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0011","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0011-mental-model-gfx.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0012","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0012-mental-model-input.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0013","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0013-mental-model-observability-and-debugging.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0014","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0014-mental-model-portability-and-cross-platform.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0015","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0015-mental-model-save-memory-and-memcard.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0016","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0016-mental-model-status-first-and-fault-thinking.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0017","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0017-mental-model-time-and-cycles.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0018","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0018-mental-model-touch.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
|
{"type":"discussion","id":"DSC-0001","status":"done","ticket":"legacy-runtime-learn-import","title":"Import legacy runtime learn into discussion lessons","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["migration","tech-debt"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0001-prometeu-learn-index.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0002","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0002-historical-asset-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0003","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0003-historical-audio-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0004","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0004-historical-cartridge-boot-protocol-and-manifest-authority.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0005","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0005-historical-game-memcard-slots-surface-and-semantics.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0006","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0006-historical-gfx-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0007","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0007-historical-retired-fault-and-input-decisions.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0008","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0008-historical-vm-core-and-assets.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0009","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0009-mental-model-asset-management.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0010","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0010-mental-model-audio.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0011","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0011-mental-model-gfx.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0012","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0012-mental-model-input.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0013","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0013-mental-model-observability-and-debugging.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0014","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0014-mental-model-portability-and-cross-platform.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0015","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0015-mental-model-save-memory-and-memcard.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0016","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0016-mental-model-status-first-and-fault-thinking.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0017","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0017-mental-model-time-and-cycles.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0018","file":"discussion/lessons/DSC-0001-runtime-learn-legacy-import/LSN-0018-mental-model-touch.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
|
||||||
{"type":"discussion","id":"DSC-0002","status":"done","ticket":"runtime-edge-test-plan","title":"Agenda - Runtime Edge Test Plan","created_at":"2026-03-27","updated_at":"2026-04-21","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0037","file":"discussion/lessons/DSC-0002-runtime-edge-test-plan/LSN-0037-domain-gates-must-be-owned-by-the-repository.md","status":"done","created_at":"2026-04-21","updated_at":"2026-04-21"}]}
|
{"type":"discussion","id":"DSC-0002","status":"done","ticket":"runtime-edge-test-plan","title":"Agenda - Runtime Edge Test Plan","created_at":"2026-03-27","updated_at":"2026-04-21","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0037","file":"discussion/lessons/DSC-0002-runtime-edge-test-plan/LSN-0037-domain-gates-must-be-owned-by-the-repository.md","status":"done","created_at":"2026-04-21","updated_at":"2026-04-21"}]}
|
||||||
{"type":"discussion","id":"DSC-0003","status":"open","ticket":"packed-cartridge-loader-pmc","title":"Agenda - Packed Cartridge Loader PMC","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0002","file":"AGD-0002-packed-cartridge-loader-pmc.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
|
{"type":"discussion","id":"DSC-0003","status":"open","ticket":"packed-cartridge-loader-pmc","title":"Agenda - Packed Cartridge Loader PMC","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0002","file":"AGD-0002-packed-cartridge-loader-pmc.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
|
||||||
{"type":"discussion","id":"DSC-0004","status":"in_progress","ticket":"system-run-cart","title":"Agenda - System Run Cart","created_at":"2026-03-27","updated_at":"2026-07-03","tags":[],"agendas":[{"id":"AGD-0003","file":"AGD-0003-system-run-cart.md","status":"accepted","created_at":"2026-03-27","updated_at":"2026-07-03"},{"id":"AGD-0045","file":"AGD-0045-systemos-library-root-home-game-launch.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03"}],"decisions":[{"id":"DEC-0035","file":"DEC-0035-remove-userland-system-run-cart-and-preserve-direct-boot.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03","ref_agenda":"AGD-0003"},{"id":"DEC-0036","file":"DEC-0036-systemos-games-root-and-home-game-launch.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-03","ref_agenda":"AGD-0045"}],"plans":[{"id":"PLN-0129","file":"PLN-0129-remove-userland-run-cart-abi-surface.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0130","file":"PLN-0130-remove-runtime-run-cart-stub-dispatch.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0131","file":"PLN-0131-preserve-direct-cartridge-boot-coverage.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0035"]},{"id":"PLN-0132","file":"PLN-0132-specify-games-root-home-launch-contract.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0133","file":"PLN-0133-implement-games-root-library-discovery.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0134","file":"PLN-0134-wire-home-game-list-launch-flow.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]},{"id":"PLN-0135","file":"PLN-0135-validate-games-root-launch-end-to-end.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03","ref_decisions":["DEC-0036"]}],"lessons":[]}
|
{"type":"discussion","id":"DSC-0004","status":"done","ticket":"system-run-cart","title":"Agenda - System Run Cart","created_at":"2026-03-27","updated_at":"2026-07-03","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0051","file":"discussion/lessons/DSC-0004-system-run-cart/LSN-0051-launch-authority-belongs-to-systemos.md","status":"done","created_at":"2026-07-03","updated_at":"2026-07-03"}]}
|
||||||
{"type":"discussion","id":"DSC-0005","status":"open","ticket":"system-fault-semantics-and-control-surface","title":"Agenda - System Fault Semantics and Control Surface","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0004","file":"AGD-0004-system-fault-semantics-and-control-surface.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
|
{"type":"discussion","id":"DSC-0005","status":"open","ticket":"system-fault-semantics-and-control-surface","title":"Agenda - System Fault Semantics and Control Surface","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0004","file":"AGD-0004-system-fault-semantics-and-control-surface.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
|
||||||
{"type":"discussion","id":"DSC-0006","status":"open","ticket":"vm-owned-random-service","title":"Agenda - VM-Owned Random Service","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0005","file":"AGD-0005-vm-owned-random-service.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
|
{"type":"discussion","id":"DSC-0006","status":"open","ticket":"vm-owned-random-service","title":"Agenda - VM-Owned Random Service","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0005","file":"AGD-0005-vm-owned-random-service.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
|
||||||
{"type":"discussion","id":"DSC-0007","status":"open","ticket":"app-home-filesystem-surface-and-semantics","title":"Agenda - App Home Filesystem Surface and Semantics","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0006","file":"AGD-0006-app-home-filesystem-surface-and-semantics.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
|
{"type":"discussion","id":"DSC-0007","status":"open","ticket":"app-home-filesystem-surface-and-semantics","title":"Agenda - App Home Filesystem Surface and Semantics","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0006","file":"AGD-0006-app-home-filesystem-surface-and-semantics.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
|
||||||
|
|||||||
@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
id: LSN-0051
|
||||||
|
ticket: system-run-cart
|
||||||
|
title: Launch Authority Belongs to SystemOS
|
||||||
|
created: 2026-07-03
|
||||||
|
tags: [runtime, systemos, cartridge, launch, abi, home]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The `system.run_cart` workflow resolved two related questions: whether a guest
|
||||||
|
should have an app-callable cartridge launch syscall, and how Home should start
|
||||||
|
games from a local library.
|
||||||
|
|
||||||
|
The final model removes `system.run_cart` from the userland ABI while preserving
|
||||||
|
direct host-controlled cartridge boot. Home launch is restored as a
|
||||||
|
system-owned path: the host may provide `--games-root <dir>`, SystemOS/Home
|
||||||
|
discovers valid Game directory cartridges, and selecting an entry routes through
|
||||||
|
firmware into `LoadCartridge`.
|
||||||
|
|
||||||
|
The important distinction is authority. A launch can be a valid product action
|
||||||
|
without being a guest capability.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
### Remove Guest Cartridge Launch
|
||||||
|
|
||||||
|
**What:** Prometeu removed `system.run_cart` from the public userland syscall
|
||||||
|
surface, generated guest metadata, and runtime dispatch.
|
||||||
|
|
||||||
|
**Why:** A guest syscall made cartridge launch look like app-owned navigation.
|
||||||
|
That gave userland implied authority over target resolution, lifecycle cleanup,
|
||||||
|
failure policy, and system navigation.
|
||||||
|
|
||||||
|
**Trade-offs:** Obsolete guest code cannot keep a compatibility stub. The
|
||||||
|
runtime instead keeps direct boot as a host/system entrypoint and lets stale
|
||||||
|
guest attempts fail through the invalid syscall path.
|
||||||
|
|
||||||
|
### Preserve Direct Boot Outside The Guest ABI
|
||||||
|
|
||||||
|
**What:** Host, CLI, debugger, tests, and single-game flows continue to boot a
|
||||||
|
selected cartridge directly.
|
||||||
|
|
||||||
|
**Why:** Direct boot is still essential for development, automation, and future
|
||||||
|
single-game distribution. Those flows start with a host-selected target before
|
||||||
|
VM execution, so they do not need a guest syscall.
|
||||||
|
|
||||||
|
**Trade-offs:** The loader remains reusable, but it is not lifecycle
|
||||||
|
orchestration authority. Higher-level transitions must be owned by firmware or
|
||||||
|
SystemOS.
|
||||||
|
|
||||||
|
### Model Home Launch As A System Action
|
||||||
|
|
||||||
|
**What:** `--games-root <dir>` configures a local games library for Home. V1
|
||||||
|
discovery scans immediate child directories, keeps only valid Game cartridges,
|
||||||
|
and exposes enough internal metadata to launch a selected entry.
|
||||||
|
|
||||||
|
**Why:** Home needs a product path for "choose a game and start it", but that
|
||||||
|
action belongs to the system profile. SystemOS can present the library and ask
|
||||||
|
firmware to enter `LoadCartridge` without reintroducing guest-controlled launch.
|
||||||
|
|
||||||
|
**Trade-offs:** V1 intentionally excludes recursive discovery, `.pmc`, non-game
|
||||||
|
apps, rich catalog metadata, return-to-Home, and game-to-game switching. Those
|
||||||
|
need separate lifecycle and orchestration decisions.
|
||||||
|
|
||||||
|
## Patterns and Algorithms
|
||||||
|
|
||||||
|
### Authority-Based API Classification
|
||||||
|
|
||||||
|
Before exposing an operation to guest/userland code, classify what authority it
|
||||||
|
implies. If the operation selects another executable target, changes process
|
||||||
|
lifecycle, or controls navigation between system-owned modes, it is probably a
|
||||||
|
SystemOS or firmware action rather than an app syscall.
|
||||||
|
|
||||||
|
### Separate Selection From Loading
|
||||||
|
|
||||||
|
Home owns user selection and presentation. SystemOS owns the launch request.
|
||||||
|
Firmware owns the transition into `LoadCartridge`. The cartridge loader remains
|
||||||
|
the mechanism that validates and materializes a cartridge, not the authority
|
||||||
|
that decides when navigation is allowed.
|
||||||
|
|
||||||
|
### Expandable Internal Catalog Records
|
||||||
|
|
||||||
|
Home can render a small v1 list while the internal entry retains manifest data,
|
||||||
|
title, app id, app version, cartridge path, and discovery metadata. This keeps
|
||||||
|
the UI simple without reducing the model to a display-only DTO.
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
### Compatibility Stubs Preserve False Contracts
|
||||||
|
|
||||||
|
A syscall that returns success without doing real lifecycle work is worse than
|
||||||
|
an absent syscall. It teaches callers that the platform supports app-driven
|
||||||
|
launch while hiding that no complete transition happened.
|
||||||
|
|
||||||
|
### A Product Flow Is Not Automatically A Guest API
|
||||||
|
|
||||||
|
"Home can launch a game" and "a guest can launch a cartridge" are different
|
||||||
|
claims. Product behavior should be mapped to the component with the correct
|
||||||
|
authority instead of mirrored into the nearest callable ABI.
|
||||||
|
|
||||||
|
### Generic Library Roots Prematurely Expand Scope
|
||||||
|
|
||||||
|
The first launcher path is a games library, not a marketplace or full app
|
||||||
|
catalog. Shell apps, System apps, packages, icons, search, and recursive layouts
|
||||||
|
carry separate contracts and should not be smuggled into the v1 games-root path.
|
||||||
|
|
||||||
|
## Takeaways
|
||||||
|
|
||||||
|
- Cartridge launch is system navigation, not a userland syscall.
|
||||||
|
- Direct boot can remain host-controlled without appearing in guest metadata.
|
||||||
|
- Home launch should emit a system action and let firmware enter
|
||||||
|
`LoadCartridge`.
|
||||||
|
- Loader reuse does not imply loader ownership of lifecycle policy.
|
||||||
|
- Keep v1 discovery narrow: immediate child directory cartridges with
|
||||||
|
`app_mode: Game`.
|
||||||
|
- Preserve internal catalog metadata even when the Home UI renders only a small
|
||||||
|
subset.
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
id: LSN-0052
|
||||||
|
ticket: foreground-stack-game-pause-shell-vm-backed
|
||||||
|
title: Foreground Ownership and VM Session Ownership Are Separate
|
||||||
|
created: 2026-07-05
|
||||||
|
tags: [runtime, os, lifecycle, shell, game, vm, foreground, architecture]
|
||||||
|
---
|
||||||
|
|
||||||
|
# LSN-0052: Foreground Ownership and VM Session Ownership Are Separate
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
DSC-0041 closed the `Game -> Home/Shell -> same Game` lifecycle for the first
|
||||||
|
runtime implementation that supports both native Shell apps and VM-backed Shell
|
||||||
|
apps. The important architectural shift was not only pausing the Game. The
|
||||||
|
runtime also had to stop treating one firmware-owned VM as the place where all
|
||||||
|
guest execution state lives.
|
||||||
|
|
||||||
|
The final model separates two questions:
|
||||||
|
|
||||||
|
- Who owns the foreground visual/input authority right now?
|
||||||
|
- Which VM-backed process owns a mutable VM execution context?
|
||||||
|
|
||||||
|
Foreground ownership is global SystemOS policy. VM execution state belongs to a
|
||||||
|
session associated with a task/process. A suspended resident Game is not a saved
|
||||||
|
snapshot and not a special global VM. It is a live VM session that is temporarily
|
||||||
|
ineligible for normal foreground ticks, input, frame pacing, and render
|
||||||
|
publication.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
### Foreground Stack and Game Pause Contract
|
||||||
|
|
||||||
|
**What:** PROMETEU uses a single foreground owner in v1. Hub/Home is the root
|
||||||
|
Shell owner, a Game may remain resident, and one foreground Shell app may run in
|
||||||
|
front of the resident Game. Pressing desktop `Esc` is a host/SystemOS Home
|
||||||
|
request, not guest input.
|
||||||
|
|
||||||
|
**Why:** This preserves console-like navigation without turning the runtime into
|
||||||
|
a general multitasking scheduler. It lets the system pause and suspend a Game,
|
||||||
|
run Hub or Shell, then resume the same Game deterministically.
|
||||||
|
|
||||||
|
**Trade-offs:** The model intentionally rejects multiple resident Games, direct
|
||||||
|
Shell-to-Game return, background execution, and Game-to-Game switching in v1.
|
||||||
|
Those features remain future work, but the lifecycle structure now has a place
|
||||||
|
to add them.
|
||||||
|
|
||||||
|
### VM Session Ownership for VM-Backed Processes
|
||||||
|
|
||||||
|
**What:** VM-backed mutable execution state is owned by VM sessions. A
|
||||||
|
VM-backed Game and a VM-backed Shell never share the same VM, stack, heap, PC,
|
||||||
|
open handles, cartridge identity, or VM-scoped runtime state.
|
||||||
|
|
||||||
|
**Why:** A single firmware-owned VM cannot represent a suspended resident Game
|
||||||
|
and a foreground VM Shell at the same time. Loading the Shell into that global
|
||||||
|
VM would overwrite the Game context that the lifecycle model says is resident.
|
||||||
|
|
||||||
|
**Trade-offs:** Session ownership is more explicit than one global VM, but it
|
||||||
|
keeps the model simpler than snapshot/restore. It also prepares the runtime for
|
||||||
|
future background-capable sessions without committing to background scheduling
|
||||||
|
yet.
|
||||||
|
|
||||||
|
## Patterns and Algorithms
|
||||||
|
|
||||||
|
### Pause Is Cooperative, Suspension Is OS Authority
|
||||||
|
|
||||||
|
The Game receives lifecycle events such as pause and resume, but the OS owns the
|
||||||
|
actual scheduling state. If the Game does not cooperate within the bounded pause
|
||||||
|
budget, SystemOS can suspend it anyway. On resume, the Game receives a foreground
|
||||||
|
restore event and may still keep its internal pause screen while it
|
||||||
|
synchronizes.
|
||||||
|
|
||||||
|
This distinction prevents userland from owning system navigation. The Game may
|
||||||
|
observe and react. It cannot veto Home.
|
||||||
|
|
||||||
|
### Foreground Owner Is Not the Same as Tick Eligibility
|
||||||
|
|
||||||
|
Foreground ownership controls input, visual authority, render ownership, and
|
||||||
|
normal frame pacing. Tick eligibility controls whether a VM session may execute.
|
||||||
|
In v1, only the foreground VM session receives normal ticks, and the resident
|
||||||
|
Game does not tick while Shell or Hub is foreground.
|
||||||
|
|
||||||
|
The important design detail is that the data model does not assume this must be
|
||||||
|
true forever. Sessions can later become background-eligible without changing who
|
||||||
|
owns render/input foreground.
|
||||||
|
|
||||||
|
### VM Sessions Are the Unit of Mutable Guest State
|
||||||
|
|
||||||
|
Each VM-backed task/process maps to a `VmSession`. The session owns the VM,
|
||||||
|
runtime state, session-scoped filesystem state, open file handles, next handle
|
||||||
|
allocation, cartridge identity, lifecycle delivery state, and debug state.
|
||||||
|
|
||||||
|
Cartridge loading creates or reuses a VM session through SystemOS session
|
||||||
|
services. Firmware orchestrates macro states such as `LoadCartridge`,
|
||||||
|
`GameRunning`, `HubHome`, and `ShellRunning`, but it no longer owns a canonical
|
||||||
|
guest VM.
|
||||||
|
|
||||||
|
### Debugger and Host Inspection Must Follow the Active Session
|
||||||
|
|
||||||
|
Host debugging must inspect and mutate the active session VM, not a transitional
|
||||||
|
global VM. PC, operand stack, breakpoints, debug-step execution, cartridge
|
||||||
|
identity, and breakpoint-hit events all need to resolve through active session
|
||||||
|
state.
|
||||||
|
|
||||||
|
This keeps debugger behavior aligned with actual execution. A debugger that
|
||||||
|
observes a different VM than the scheduler ticks is worse than no debugger: it
|
||||||
|
creates false confidence and hides lifecycle bugs.
|
||||||
|
|
||||||
|
### Operational Failures Should Cross Facades as Typed Errors
|
||||||
|
|
||||||
|
Session creation and loading are operational boundaries. Missing task, missing
|
||||||
|
process, duplicate VM session, missing VM session during initialization, and
|
||||||
|
foreground Shell rejection should be typed errors. Firmware can then choose a
|
||||||
|
controlled crash report, a launch rejection, or a logged no-transition outcome.
|
||||||
|
|
||||||
|
Panics are reserved for test/setup wrappers or impossible internal invariants
|
||||||
|
that have already been proven by prior checks.
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
- Do not use a global VM as a compatibility bridge after session ownership is
|
||||||
|
introduced. It will eventually become stale or contradictory.
|
||||||
|
- Do not let Home/SystemOS be guest input. It changes OS authority, so it must
|
||||||
|
bypass guest pad state.
|
||||||
|
- Do not present old frames after foreground ownership changes. Render ownership
|
||||||
|
and epoch validation are part of lifecycle correctness, not renderer polish.
|
||||||
|
- Do not model pause and suspension as the same state. Pause is visible to the
|
||||||
|
Game; suspension is the scheduler mechanism.
|
||||||
|
- Do not encode v1 limits such as one resident Game or one foreground Shell by
|
||||||
|
collapsing storage into one VM. Express those limits as lifecycle/session
|
||||||
|
policy.
|
||||||
|
- Do not let debugger state drift from scheduler state. The debugger must use
|
||||||
|
the same active session as normal execution.
|
||||||
|
|
||||||
|
## Takeaways
|
||||||
|
|
||||||
|
- Foreground ownership is global policy; VM execution state is session-owned.
|
||||||
|
- A suspended Game is a preserved session, not a serialized VM and not a global
|
||||||
|
VM waiting to be reused.
|
||||||
|
- Shell apps can be native or VM-backed, but only VM-backed processes need VM
|
||||||
|
sessions.
|
||||||
|
- Session ownership is the foundation for future background-capable processes,
|
||||||
|
even before background execution exists.
|
||||||
|
- Typed session/lifecycle errors make launch and recovery behavior explicit at
|
||||||
|
firmware boundaries.
|
||||||
@ -1,202 +0,0 @@
|
|||||||
---
|
|
||||||
id: AGD-0003
|
|
||||||
ticket: system-run-cart
|
|
||||||
title: Agenda - System Run Cart
|
|
||||||
status: accepted
|
|
||||||
created: 2026-03-27
|
|
||||||
resolved:
|
|
||||||
decision:
|
|
||||||
tags: []
|
|
||||||
---
|
|
||||||
|
|
||||||
# Agenda - System Run Cart
|
|
||||||
|
|
||||||
## Problema
|
|
||||||
|
|
||||||
`system.run_cart` existe na ABI pública, mas hoje não produz efeito real no runtime.
|
|
||||||
Isso expõe duas questões: a superfície atual é um placebo e, mesmo quando houver
|
|
||||||
fluxo real, rodar cartuchos não deve ser uma operação userland. A autoridade
|
|
||||||
para iniciar cartuchos deve pertencer ao sistema/firmware.
|
|
||||||
|
|
||||||
Isso cria uma falsa promessa de plataforma:
|
|
||||||
|
|
||||||
- o programa consegue declarar e chamar uma operação que aparenta iniciar cartuchos;
|
|
||||||
- o loader aceita sua existência;
|
|
||||||
- o runtime retorna sucesso vazio;
|
|
||||||
- nenhuma troca de cartucho, transição de firmware, validação de alvo ou política de segurança ocorre.
|
|
||||||
|
|
||||||
## Dor
|
|
||||||
|
|
||||||
- A ABI expõe uma capability de sistema que, na prática, nao existe.
|
|
||||||
- Mesmo se implementada, a operação não deve ser uma syscall userland comum:
|
|
||||||
apps não devem iniciar cartuchos diretamente.
|
|
||||||
- O guest pode acreditar que solicitou uma troca de app quando nada aconteceu.
|
|
||||||
- Isso corrói a confiabilidade da plataforma: a interface pública deixa de ser contrato e vira placebo.
|
|
||||||
- Qualquer ferramenta, SDK ou documentação construída sobre essa syscall passa a modelar um comportamento inexistente.
|
|
||||||
|
|
||||||
## Alvo da Discussao
|
|
||||||
|
|
||||||
Definir o contrato real da troca de cartucho iniciada pelo sistema do ponto de vista:
|
|
||||||
|
|
||||||
- do guest;
|
|
||||||
- do runtime;
|
|
||||||
- do firmware;
|
|
||||||
- do host/cartridge loader.
|
|
||||||
|
|
||||||
Ao final da discussão, deve ficar claro se a superfície atual:
|
|
||||||
|
|
||||||
- permanece apenas como mecanismo interno do sistema e sai da ABI userland; ou
|
|
||||||
- permanece na ABI somente como superfície privilegiada impossível de chamar por apps comuns; ou
|
|
||||||
- sai temporariamente da ABI até existir fluxo fechado.
|
|
||||||
|
|
||||||
Esta discussao nao deve depender do fechamento previo do formato `.pmc`.
|
|
||||||
Para o primeiro fluxo fechado, `system.run_cart` pode resolver um alvo por
|
|
||||||
um loader ja disponivel, incluindo cartucho em diretorio ou fixture de teste.
|
|
||||||
O suporte ao artefato empacotado `.pmc` permanece responsabilidade da
|
|
||||||
`DSC-0003 / packed-cartridge-loader-pmc` e nao deve bloquear a prova inicial
|
|
||||||
de troca `Shell/Hub -> Game`.
|
|
||||||
|
|
||||||
Tambem precisa ficar claro que `run_cart`, se existir, nao e o orquestrador da
|
|
||||||
troca. No boot direto por linha de comando, carregar um cartucho e simples
|
|
||||||
porque o runtime nasce ja apontado para aquele jogo. Com o SO em execucao, o
|
|
||||||
caso real e diferente: pode haver um Game residente/rodando, o usuario volta
|
|
||||||
para Home, e o sistema decide iniciar outro Game. Nesse caso o SO precisa
|
|
||||||
orquestrar fechamento/suspensao descartada do app atual, limpeza de estado,
|
|
||||||
resolucao do novo alvo, carregamento e transicao para o proximo estado de
|
|
||||||
firmware. O carregador de cartucho e apenas uma etapa desse fluxo.
|
|
||||||
|
|
||||||
A direcao preferida e que a orquestracao de troca de cartucho viva no
|
|
||||||
SystemOS. Porem, essa agenda nao deve capturar o caso `Game -> Home -> mesmo
|
|
||||||
Game`: esse e o escopo da `AGD-0041`, que precisa fechar foreground,
|
|
||||||
pausa/suspensao e retorno ao mesmo Game. A troca `Home -> outro Game`, com
|
|
||||||
fechamento do jogo atual e carregamento de outro cartucho, pode merecer uma
|
|
||||||
agenda propria depois da `AGD-0041`, derivada do contrato de foreground que ela
|
|
||||||
fechar.
|
|
||||||
|
|
||||||
## O Que Precisa Ser Definido
|
|
||||||
|
|
||||||
1. Semântica da operação.
|
|
||||||
O que significa "run cart" quando existe SO: trocar imediatamente,
|
|
||||||
agendar para o próximo frame, iniciar uma transicao orquestrada pelo
|
|
||||||
sistema, ou apenas carregar bytes de cartucho como etapa interna?
|
|
||||||
|
|
||||||
2. Origem do alvo.
|
|
||||||
Como o guest identifica o cartucho alvo: id numérico, nome canônico, path virtual, handle, manifesto, ou nenhum argumento nesta primeira versão?
|
|
||||||
Para v1, uma opcao aceitavel e limitar a resolucao a alvos conhecidos pelo host/test harness e carregaveis pelo loader de diretorio, deixando `.pmc` fora do caminho critico.
|
|
||||||
|
|
||||||
3. Modelo de segurança.
|
|
||||||
Nenhum app/userland deve chamar `run_cart`. O sistema/firmware e o caminho
|
|
||||||
host/CLI/debug devem ser as unicas autoridades para iniciar cartuchos.
|
|
||||||
Se apps precisarem solicitar navegacao no futuro, isso deve ser uma
|
|
||||||
superficie nova e mediada pelo sistema.
|
|
||||||
|
|
||||||
4. Ponto de integração.
|
|
||||||
Onde a transição vive: `VirtualMachineRuntime`, firmware, host, SystemOS,
|
|
||||||
ou combinação dos três? Para esta agenda, a decisao minima e separar o boot
|
|
||||||
direto host/CLI/debug da ABI guest. A orquestracao `Home -> outro Game`
|
|
||||||
pertence a `DSC-0043 / AGD-0044`.
|
|
||||||
|
|
||||||
5. Efeitos observáveis.
|
|
||||||
O que acontece quando um guest tenta chamar a superficie removida, e como
|
|
||||||
ferramentas/boot direto continuam carregando cartuchos sem depender de uma
|
|
||||||
syscall userland?
|
|
||||||
|
|
||||||
6. Contrato de erro.
|
|
||||||
Remocao da ABI significa erro de build/declaracao, syscall desconhecida,
|
|
||||||
ou bloqueio em registro/capability ate a limpeza completa das declaracoes?
|
|
||||||
|
|
||||||
## O Que Necessita Para Resolver
|
|
||||||
|
|
||||||
- remocao de `system.run_cart` da ABI userland;
|
|
||||||
- definicao de que boot direto continua por caminho host/CLI/debug, nao por
|
|
||||||
syscall guest;
|
|
||||||
- separacao explicita entre boot direto, loader interno e orquestracao
|
|
||||||
`Home -> outro Game` da `DSC-0043 / AGD-0044`;
|
|
||||||
- testes garantindo que guest/userland nao pode declarar/chamar `run_cart`;
|
|
||||||
- testes garantindo que boot direto de cartucho continua funcional.
|
|
||||||
|
|
||||||
## Fora de Escopo
|
|
||||||
|
|
||||||
- catálogo completo de apps instalados;
|
|
||||||
- UX final do launcher/hub;
|
|
||||||
- resolução remota ou download de cartuchos;
|
|
||||||
- política de marketplace/distribuição.
|
|
||||||
- obrigatoriedade de boot a partir de `.pmc` no primeiro fluxo funcional.
|
|
||||||
|
|
||||||
## Sugestao / Recomendacao
|
|
||||||
|
|
||||||
Recomendo remover `system.run_cart` da ABI userland para v1. Nao ha necessidade
|
|
||||||
de compatibilidade com a superficie atual: ela nao deve permanecer como stub,
|
|
||||||
syscall reservada chamavel por guest, nem sucesso vazio.
|
|
||||||
|
|
||||||
Rodar cartuchos deve ser uma operacao interna do SystemOS/firmware ou do
|
|
||||||
caminho host/CLI/debug, nunca uma syscall de app. O boot direto deve continuar
|
|
||||||
existindo porque e o fluxo essencial para testar cartuchos em desenvolvimento e
|
|
||||||
tambem representa o modo futuro de executar um jogo unico, como em uma
|
|
||||||
integracao tipo Steam.
|
|
||||||
|
|
||||||
Recomendo tambem separar dois conceitos:
|
|
||||||
|
|
||||||
- **boot direto:** usado por linha de comando/debug, inicia o runtime ja com um
|
|
||||||
cartucho alvo e pode continuar simples;
|
|
||||||
- **troca via SO:** fluxo orquestrado pelo SystemOS, que fecha o app
|
|
||||||
atual quando necessario, limpa estado cartridge-scoped, resolve o proximo
|
|
||||||
alvo e so entao chama o carregador.
|
|
||||||
|
|
||||||
Essa agenda nao deve implementar o fluxo `Game -> Home -> mesmo Game`; esse
|
|
||||||
trabalho pertence a `AGD-0041`. Depois dela, a troca `Home -> outro Game` deve
|
|
||||||
ser aberta ou refinada como discussao propria, porque adiciona fechamento do
|
|
||||||
jogo atual, limpeza cartridge-scoped, resolucao de novo alvo e falhas de load.
|
|
||||||
|
|
||||||
Se a plataforma precisar de uma ação solicitável por apps no futuro, ela deve
|
|
||||||
ser modelada separadamente como pedido de navegação mediado pelo sistema, com
|
|
||||||
nome e erros que não prometam boot direto.
|
|
||||||
|
|
||||||
## Respostas Atuais
|
|
||||||
|
|
||||||
- `system.run_cart` deve ser removido da ABI userland.
|
|
||||||
- Nao precisamos manter compatibilidade com a superficie atual.
|
|
||||||
- Nao deve existir syscall publica para app iniciar cartucho.
|
|
||||||
- Boot direto por linha de comando, host, debug ou fluxo equivalente de jogo
|
|
||||||
unico deve permanecer.
|
|
||||||
- O carregamento interno pode continuar usando loader ja disponivel, incluindo
|
|
||||||
cartucho em diretorio/fixture.
|
|
||||||
- `.pmc`, catalogo, UX final de launcher e troca `Game A -> Home -> Game B`
|
|
||||||
ficam fora desta agenda.
|
|
||||||
- Se no futuro apps precisarem pedir navegacao, isso deve ser uma nova
|
|
||||||
superficie mediada pelo sistema, nao a volta de `run_cart` userland.
|
|
||||||
|
|
||||||
## Critério de Saida Desta Agenda
|
|
||||||
|
|
||||||
Esta agenda só pode virar PR de implementação quando existir decisão escrita para:
|
|
||||||
|
|
||||||
- retirada da superfície userland atual;
|
|
||||||
- preservacao explicita do boot direto host/CLI/debug;
|
|
||||||
- comportamento esperado para declaracao/chamada guest obsoleta;
|
|
||||||
- fronteira com `AGD-0041` e `AGD-0044`;
|
|
||||||
- cobertura minima de testes para remocao userland e boot direto.
|
|
||||||
|
|
||||||
## Discussao
|
|
||||||
|
|
||||||
- 2026-07-03: A agenda deve ser aberta antes da `AGD-0041` como habilitadora
|
|
||||||
de validacao do ciclo `Shell/Hub -> Game`. Ficou definido como premissa de
|
|
||||||
agenda que o primeiro `system.run_cart` testavel nao depende de `.pmc`; ele
|
|
||||||
pode usar cartucho em diretorio ou fixture resolvida pelo host/test harness.
|
|
||||||
- 2026-07-03: Direcao ajustada: `run_cart` nao deve ser uma operacao userland.
|
|
||||||
A autoridade de iniciar cartuchos deve pertencer ao sistema/firmware; apps
|
|
||||||
podem, no maximo, solicitar navegacao por uma superficie separada e mediada.
|
|
||||||
- 2026-07-03: Direcao refinada: `run_cart` simples nao resolve o caso com SO.
|
|
||||||
Boot direto por linha de comando continua sendo um caminho simples, mas
|
|
||||||
iniciar outro jogo a partir do Home exige um orquestrador do SO/firmware que
|
|
||||||
fecha o app atual, limpa estado e so entao carrega o novo cartucho.
|
|
||||||
- 2026-07-03: Escopo refinado: `Game -> Home -> mesmo Game` pertence a
|
|
||||||
`AGD-0041`. A agenda atual nao deve absorver esse trabalho. Depois da
|
|
||||||
`AGD-0041`, a troca `Home -> outro Game` pode ser aberta ou refinada como
|
|
||||||
agenda propria para o orquestrador de troca de cartucho no SystemOS.
|
|
||||||
- 2026-07-03: A troca `Game A -> Home -> Game B` foi separada para
|
|
||||||
`DSC-0043 / AGD-0044`, mantendo esta agenda focada na superficie
|
|
||||||
`run_cart`/loader e sua remocao ou restricao como operacao userland.
|
|
||||||
- 2026-07-03: Direcao aceita para esta agenda: remover `system.run_cart` da ABI
|
|
||||||
userland sem necessidade de compatibilidade, manter boot direto para
|
|
||||||
CLI/debug/teste de cartuchos e futuro modo de jogo unico, e deixar navegacao
|
|
||||||
mediada por apps para uma superficie futura separada se ela vier a existir.
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
---
|
|
||||||
id: AGD-0041
|
|
||||||
ticket: foreground-stack-game-pause-shell-vm-backed
|
|
||||||
title: Foreground Stack, Game Pause, and VM-Backed Shell Coexistence
|
|
||||||
status: open
|
|
||||||
created: 2026-06-05
|
|
||||||
resolved:
|
|
||||||
decision:
|
|
||||||
tags: [runtime, os, lifecycle, shell, game, vm, foreground, architecture]
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contexto
|
|
||||||
|
|
||||||
Durante a discussao da `DSC-0040` sobre separar VM e render worker, surgiu um caso que nao pertence somente ao render: `Game -> Shell -> Game`.
|
|
||||||
|
|
||||||
O caso minimo e:
|
|
||||||
|
|
||||||
- um Game esta rodando;
|
|
||||||
- o usuario aperta Home;
|
|
||||||
- o Game deve sair do foreground sem ser encerrado;
|
|
||||||
- o Hub/Shell assume a tela;
|
|
||||||
- o usuario pode abrir um app Shell, inclusive VM-backed;
|
|
||||||
- depois o usuario retorna ao mesmo Game.
|
|
||||||
|
|
||||||
O render worker precisa saber qual visual owner pode apresentar frames, mas a decisao principal e de lifecycle: quais VMs existem, quais podem executar, o que significa pausar um Game, e como Shell apps coexistem com um Game pausado.
|
|
||||||
|
|
||||||
Material relacionado:
|
|
||||||
|
|
||||||
- `DSC-0031`: separacao entre perfis Game e System/Shell.
|
|
||||||
- `DSC-0032`: SystemOS como autoridade de lifecycle/process/task.
|
|
||||||
- `DSC-0035`: janelas Shell pertencem a tasks e definem liveness do app.
|
|
||||||
- `DSC-0036`: Hub/Shell prova fatias de lifecycle.
|
|
||||||
- `DSC-0040`: render ownership/epoch durante transicoes e paralelismo VM/render.
|
|
||||||
|
|
||||||
## Problema
|
|
||||||
|
|
||||||
Precisamos definir o contrato de foreground para impedir ambiguidades como:
|
|
||||||
|
|
||||||
- Game pausado continuando a executar frames logicos enquanto Shell esta no foreground;
|
|
||||||
- Shell VM-backed competindo com Game por pacing, input, superficie ou render ownership;
|
|
||||||
- multiplos Shells foreground ou multiplos Games ativos sem uma politica explicita;
|
|
||||||
- retorno ao Game sem clareza sobre estado pausado, input, render invalidation e retomada da VM.
|
|
||||||
|
|
||||||
Sem esse contrato, a separacao VM/render da `DSC-0040` pode ficar tecnicamente correta, mas o SystemOS ainda pode permitir estados incoerentes.
|
|
||||||
|
|
||||||
## Pontos Criticos
|
|
||||||
|
|
||||||
- Existe apenas um foreground visual owner por vez.
|
|
||||||
- Background execution e outro assunto e deve ficar fora da primeira decisao, salvo se for necessario definir uma proibicao explicita.
|
|
||||||
- Shell VM-backed nao deve herdar politica de game loop so por usar VM.
|
|
||||||
- Game pausado provavelmente deve reter estado, mas nao deve consumir frames logicos.
|
|
||||||
- O Hub/Shell precisa ser capaz de rodar enquanto o Game esta suspenso.
|
|
||||||
- A transicao deve invalidar render submissions antigas para impedir present de frames obsoletos.
|
|
||||||
- O modelo precisa caber no SystemOS/lifecycle existente, nao diretamente no render worker.
|
|
||||||
|
|
||||||
## Opcoes
|
|
||||||
|
|
||||||
### Opcao A - Foreground unico, Game suspenso, Shell unico
|
|
||||||
|
|
||||||
- **Abordagem:** permitir no maximo um Game carregado e no maximo um Shell foreground; ao apertar Home, o Game entra em estado pausado/suspenso e Shell/Hub assume o foreground. Shell VM-backed pode executar apenas enquanto for o foreground Shell.
|
|
||||||
- **Pro:** modelo simples, previsivel e alinhado com console; reduz concorrencia real entre VMs; combina com a regra de render ownership unico.
|
|
||||||
- **Contra:** nao cobre multitarefa/background; futuras features de overlay ou apps persistentes precisarao expandir o contrato.
|
|
||||||
- **Manutenibilidade:** alta; SystemOS pode modelar uma pilha pequena de foreground/suspended sem virar scheduler generico.
|
|
||||||
|
|
||||||
### Opcao B - Multiplas VMs residentes, somente foreground executa
|
|
||||||
|
|
||||||
- **Abordagem:** permitir que Game e Shell VM-backed coexistam como VMs residentes, mas somente a entidade foreground executa. O Game pausado fica residente, sem tick; Shell VM-backed roda sob lifecycle Shell.
|
|
||||||
- **Pro:** preserva estado e permite retorno rapido; nao exige background execution.
|
|
||||||
- **Contra:** precisa contrato claro de memoria, ownership de input, foco, cancelamento e fechamento.
|
|
||||||
- **Manutenibilidade:** boa se os estados forem explicitos; ruim se a residencia for confundida com execucao em background.
|
|
||||||
|
|
||||||
### Opcao C - Encerrar Game ao entrar no Shell VM-backed
|
|
||||||
|
|
||||||
- **Abordagem:** Home fecha/salva o Game antes de abrir Shell VM-backed, evitando coexistencia.
|
|
||||||
- **Pro:** simplifica o runtime.
|
|
||||||
- **Contra:** quebra a expectativa de console moderno; torna `Game -> Shell -> Game` um reload, nao uma pausa.
|
|
||||||
- **Manutenibilidade:** simples no curto prazo, mas provavelmente gera mais excecoes e UX ruim depois.
|
|
||||||
|
|
||||||
### Opcao D - Background real desde ja
|
|
||||||
|
|
||||||
- **Abordagem:** permitir Game pausado ou Shell apps rodando em background com politicas de prioridade.
|
|
||||||
- **Pro:** cobre casos futuros ricos.
|
|
||||||
- **Contra:** amplia demais escopo: scheduler, budget, energia, audio, input, IO e memoria.
|
|
||||||
- **Manutenibilidade:** baixa para v1; mistura a decisao de foreground com multitarefa real.
|
|
||||||
|
|
||||||
## Sugestao / Recomendacao
|
|
||||||
|
|
||||||
Recomendo seguir com a combinacao das opcoes A e B:
|
|
||||||
|
|
||||||
- somente um Game residente por vez;
|
|
||||||
- somente um Shell foreground por vez;
|
|
||||||
- Game pode ficar pausado/suspenso enquanto Shell esta foreground;
|
|
||||||
- Shell VM-backed pode existir junto com Game pausado, mas segue lifecycle Shell;
|
|
||||||
- somente o owner foreground executa VM/tick/event loop conforme sua politica;
|
|
||||||
- background execution fica explicitamente fora de escopo;
|
|
||||||
- render ownership muda junto com foreground owner e deve invalidar submissions antigas via epoch/generation, conforme `DSC-0040`.
|
|
||||||
|
|
||||||
Isso preserva o ciclo `Game -> Home/Shell -> Game` sem transformar o runtime em multitarefa geral agora.
|
|
||||||
|
|
||||||
## Perguntas em Aberto
|
|
||||||
|
|
||||||
- [ ] Qual e o nome canonico dos estados de Game fora do foreground: `Paused`, `Suspended`, ambos, ou outro?
|
|
||||||
- [ ] Ao apertar Home, o Game deve receber um evento/interrupt/trap de pausa antes de parar de executar?
|
|
||||||
- [ ] O Hub e um Shell especial sempre presente ou uma task Shell comum?
|
|
||||||
- [ ] Um app Shell VM-backed pode substituir o Hub no foreground ou o Hub permanece como owner raiz?
|
|
||||||
- [ ] Quando o Shell VM-backed fecha, o retorno vai sempre para Hub ou pode voltar diretamente ao Game?
|
|
||||||
- [ ] O input do Game e drenado/limpo ao pausar e ao retomar?
|
|
||||||
- [ ] Audio do Game pausa junto com a VM ou tem politica propria?
|
|
||||||
- [ ] Existe limite de memoria/residencia para manter Game pausado enquanto Shell app roda?
|
|
||||||
- [ ] O retorno ao Game exige novo frame antes de trocar a superficie, ou a tela pode manter Hub/Shell ate a primeira submission valida?
|
|
||||||
- [ ] Quais eventos de lifecycle precisam ser visiveis para firmware, OS, VM e host?
|
|
||||||
|
|
||||||
## Criterio para Encerrar
|
|
||||||
|
|
||||||
A agenda pode ser encerrada quando tivermos uma decisao clara sobre:
|
|
||||||
|
|
||||||
- cardinalidade de Games e Shells residentes/foreground;
|
|
||||||
- semantica de Game pausado/suspenso;
|
|
||||||
- politica de execucao para Shell VM-backed durante Game pausado;
|
|
||||||
- regra de foreground visual owner e render invalidation;
|
|
||||||
- comportamento minimo do ciclo `Game -> Shell -> Game`;
|
|
||||||
- itens explicitamente fora de escopo, especialmente background execution real.
|
|
||||||
|
|
||||||
## Discussao
|
|
||||||
|
|
||||||
- 2026-06-05: Agenda criada a partir da `DSC-0040`, quando o caso de Shell VM-backed aberto sobre um Game pausado mostrou que a discussao pertence ao lifecycle/SystemOS, nao apenas ao render worker.
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
---
|
|
||||||
id: AGD-0045
|
|
||||||
ticket: system-run-cart
|
|
||||||
title: SystemOS Library Root and Home Game Launch
|
|
||||||
status: accepted
|
|
||||||
created: 2026-07-03
|
|
||||||
resolved:
|
|
||||||
decision:
|
|
||||||
tags: [runtime, os, hub, cartridge, launcher, library, game, architecture]
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contexto
|
|
||||||
|
|
||||||
A `DEC-0035` fechou que `system.run_cart` nao deve existir como syscall
|
|
||||||
userland e que boot direto por host/CLI/debug deve permanecer.
|
|
||||||
|
|
||||||
Isso resolveu a autoridade negativa, mas ainda nao resolveu a experiencia
|
|
||||||
desejada para o SO: apontar o runtime para um diretorio raiz de biblioteca,
|
|
||||||
fazer a Home listar os cartuchos encontrados ali e permitir que o usuario clique
|
|
||||||
em um jogo para carrega-lo.
|
|
||||||
|
|
||||||
O objetivo pratico e um "Switch feio": uma lista simples na Home, sem UX final,
|
|
||||||
sem marketplace e sem catalogo rico, mas capaz de provar que o SO, e nao o
|
|
||||||
guest, escolhe e carrega um cartucho a partir de uma biblioteca local.
|
|
||||||
|
|
||||||
## Problema
|
|
||||||
|
|
||||||
Hoje temos dois caminhos separados:
|
|
||||||
|
|
||||||
- boot direto por `--run <cart>` funciona e carrega um cartucho unico;
|
|
||||||
- Hub/Home existe, mas mostra acoes fixas de Shell nativo e nao lista jogos de
|
|
||||||
uma biblioteca real.
|
|
||||||
|
|
||||||
Sem uma biblioteca de cartuchos visivel para o SO, o usuario nao consegue
|
|
||||||
iniciar jogos a partir da Home. Isso deixa a agenda `system-run-cart` incompleta
|
|
||||||
do ponto de vista de produto: removemos o `run_cart` userland, mas ainda falta a
|
|
||||||
alternativa correta, que e o SO iniciar cartuchos por acao de usuario.
|
|
||||||
|
|
||||||
## Pontos Criticos
|
|
||||||
|
|
||||||
- O root da biblioteca deve ser fornecido pelo host como `--games-root <dir>`.
|
|
||||||
- O scan inicial deve cobrir subdiretorios imediatos com `manifest.json`.
|
|
||||||
- O loader de diretorio existente deve continuar sendo a autoridade para validar
|
|
||||||
cartucho completo; a biblioteca pode ler metadados minimos para listagem.
|
|
||||||
- A Home pode mostrar uma lista feia/clicavel; polimento visual fica fora.
|
|
||||||
- O clique em um Game deve iniciar `LoadCartridge` pelo firmware/SystemOS, nao
|
|
||||||
por syscall guest.
|
|
||||||
- V1 nao precisa tratar retorno de um Game para Home, porque isso pertence a
|
|
||||||
`AGD-0041`.
|
|
||||||
- `.pmc`, outros tipos de app, catalogo remoto, marketplace, icones, busca e
|
|
||||||
metadados ricos ficam fora.
|
|
||||||
|
|
||||||
## Opcoes
|
|
||||||
|
|
||||||
### Opcao A - Biblioteca apenas para listagem
|
|
||||||
|
|
||||||
- **Abordagem:** adicionar `--games-root`, escanear manifestos e mostrar jogos
|
|
||||||
na Home, mas sem permitir launch ainda.
|
|
||||||
- **Pro:** baixa complexidade; prova descoberta/catalogo local.
|
|
||||||
- **Contra:** nao entrega o objetivo principal de carregar jogo pela Home.
|
|
||||||
- **Manutenibilidade:** boa, mas valor funcional limitado.
|
|
||||||
|
|
||||||
### Opcao B - Home lista biblioteca e carrega Game quando nao ha Game ativo
|
|
||||||
|
|
||||||
- **Abordagem:** adicionar `--games-root`, escanear cartuchos locais, renderizar
|
|
||||||
lista simples na Home e permitir clique para carregar um Game via
|
|
||||||
`LoadCartridge`. A primeira versao cobre Home inicial/sem Game residente.
|
|
||||||
- **Pro:** entrega o fluxo desejado sem abrir troca completa entre jogos.
|
|
||||||
- **Contra:** exige passar biblioteca/acao selecionada entre host, Hub,
|
|
||||||
firmware e loader.
|
|
||||||
- **Manutenibilidade:** boa se a fronteira ficar clara: biblioteca descobre,
|
|
||||||
Hub escolhe, firmware carrega.
|
|
||||||
|
|
||||||
### Opcao C - Biblioteca mais troca completa entre jogos
|
|
||||||
|
|
||||||
- **Abordagem:** Home lista biblioteca e tambem permite `Game A -> Home ->
|
|
||||||
Game B` ja no primeiro passo.
|
|
||||||
- **Pro:** cobre a experiencia final.
|
|
||||||
- **Contra:** mistura biblioteca com pausa, fechamento, cleanup e falha de load;
|
|
||||||
depende de `AGD-0041` e `AGD-0044`.
|
|
||||||
- **Manutenibilidade:** baixa para v1.
|
|
||||||
|
|
||||||
## Sugestao / Recomendacao
|
|
||||||
|
|
||||||
Recomendo a Opcao B.
|
|
||||||
|
|
||||||
O v1 deve permitir:
|
|
||||||
|
|
||||||
- host recebe `--games-root <dir>`;
|
|
||||||
- o SO/Hub recebe uma lista de cartuchos descobertos;
|
|
||||||
- a Home mostra uma lista simples/clicavel de jogos;
|
|
||||||
- clicar em um Game carrega o cartucho via firmware `LoadCartridge`;
|
|
||||||
- boot direto por `--run <cart>` continua separado e intacto;
|
|
||||||
- nenhum guest/app chama `run_cart`;
|
|
||||||
- o escopo v1 acontece na Home inicial; retorno de Game para Home fica para
|
|
||||||
`AGD-0041`, e troca `Game A -> Home -> Game B` fica para `AGD-0044`.
|
|
||||||
|
|
||||||
Detalhes aceitos para v1:
|
|
||||||
|
|
||||||
- scan apenas de subdiretorios imediatos;
|
|
||||||
- somente cartuchos `app_mode: Game`;
|
|
||||||
- `.pmc` fica para futuro;
|
|
||||||
- cartuchos invalidos sao logados e omitidos da lista;
|
|
||||||
- o modelo interno deve ser uma entry de biblioteca expansivel, nao apenas um
|
|
||||||
summary de UI. A entry pode manter o manifest carregado em memoria, path
|
|
||||||
interno necessario para launch e metadados operacionais como instante de
|
|
||||||
descoberta/instalacao. A UI v1 mostra apenas `title`, `app_id` e
|
|
||||||
`app_version`, mas futuros metadados ingeridos pela biblioteca poderao ser
|
|
||||||
usados pelo catalogo e pela propria UI;
|
|
||||||
- clique carrega imediatamente, sem confirmacao;
|
|
||||||
- falha de load volta para Home e registra log/erro.
|
|
||||||
|
|
||||||
Essa agenda deve produzir a alternativa correta ao `run_cart` userland: uma
|
|
||||||
acao de sistema, iniciada pela Home, que carrega cartucho escolhido pelo usuario
|
|
||||||
a partir de uma biblioteca local.
|
|
||||||
|
|
||||||
## Perguntas em Aberto
|
|
||||||
|
|
||||||
- [x] O argumento deve se chamar `--games-root`.
|
|
||||||
- [x] A biblioteca deve escanear apenas subdiretorios imediatos.
|
|
||||||
- [x] A listagem v1 deve incluir apenas `app_mode: Game`.
|
|
||||||
- [x] O scan deve logar cartuchos invalidos e omiti-los da lista.
|
|
||||||
- [x] O Hub/SystemOS deve receber uma entry de biblioteca expansivel, capaz de
|
|
||||||
manter o manifest carregado, path interno para launch e metadados
|
|
||||||
operacionais como timestamp de descoberta/instalacao. A UI v1 so precisa
|
|
||||||
mostrar `title`, `app_id` e `app_version`.
|
|
||||||
- [x] O clique deve carregar imediatamente, sem confirmacao.
|
|
||||||
- [x] Se um cartucho falha ao carregar, o sistema volta para Home e registra
|
|
||||||
erro/log.
|
|
||||||
- [x] Retorno de Game para Home nao pertence a esta agenda; fica para
|
|
||||||
`AGD-0041`.
|
|
||||||
- [x] O teste minimo deve provar o fluxo `games-root -> Home list -> click ->
|
|
||||||
LoadCartridge -> GameRunning`?
|
|
||||||
|
|
||||||
## Criterio para Encerrar
|
|
||||||
|
|
||||||
A agenda pode virar decisao quando estiver claro:
|
|
||||||
|
|
||||||
- nome e semantica do argumento de host para root da biblioteca;
|
|
||||||
- escopo do scan e metadados minimos;
|
|
||||||
- fronteira entre biblioteca, Hub, firmware e loader;
|
|
||||||
- politica v1 para cartuchos invalidos;
|
|
||||||
- regra v1 para launch quando nao ha Game ativo;
|
|
||||||
- fronteira explicita com `AGD-0041` e `AGD-0044`;
|
|
||||||
- testes minimos para discovery e launch pela Home.
|
|
||||||
|
|
||||||
## Discussao
|
|
||||||
|
|
||||||
- 2026-07-03: Agenda criada como continuacao da `DSC-0004` apos a `DEC-0035`.
|
|
||||||
A decisao anterior removeu `run_cart` userland; esta agenda define o caminho
|
|
||||||
correto para o SO/Home carregar jogos a partir de uma biblioteca local.
|
|
||||||
- 2026-07-03: Respostas aceitas: usar `--games-root`, escanear apenas
|
|
||||||
subdiretorios imediatos, listar somente Games, deixar `.pmc` e outros apps
|
|
||||||
para depois, logar/omitir invalidos, usar summary com `title`, `app_id` e
|
|
||||||
`app_version`, carregar imediatamente no clique, e deixar retorno para Home
|
|
||||||
sob responsabilidade da `AGD-0041`.
|
|
||||||
- 2026-07-03: Modelo de dados refinado: a biblioteca deve produzir entries
|
|
||||||
expansíveis, nao somente summaries de UI. Cada entry pode manter manifest em
|
|
||||||
memoria, path interno de launch e metadados operacionais como timestamp de
|
|
||||||
descoberta/instalacao; a UI v1 escolhe quais campos mostrar, mas o modelo
|
|
||||||
deve permitir que metadados futuros sejam ingeridos e usados pelo catalogo/UI.
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
---
|
|
||||||
id: DEC-0035
|
|
||||||
ticket: system-run-cart
|
|
||||||
title: Remove Userland system.run_cart and Preserve Direct Boot
|
|
||||||
status: accepted
|
|
||||||
created: 2026-07-03
|
|
||||||
ref_agenda: AGD-0003
|
|
||||||
tags: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted.
|
|
||||||
|
|
||||||
## Contexto
|
|
||||||
|
|
||||||
`system.run_cart` existe na ABI publica, mas nao possui comportamento real de
|
|
||||||
troca de cartucho. Isso cria uma falsa promessa: um guest pode declarar/chamar
|
|
||||||
uma operacao que aparenta iniciar outro cartucho, enquanto o runtime retorna
|
|
||||||
sucesso vazio ou nao executa uma transicao completa.
|
|
||||||
|
|
||||||
A discussao fechou tres separacoes:
|
|
||||||
|
|
||||||
- boot direto por host/CLI/debug e necessario e deve permanecer;
|
|
||||||
- apps/userland nao devem iniciar cartuchos diretamente;
|
|
||||||
- troca `Game A -> Home -> Game B` exige um orquestrador de SystemOS proprio,
|
|
||||||
separado em `DSC-0043 / AGD-0044`.
|
|
||||||
|
|
||||||
`AGD-0041` permanece responsavel por `Game -> Home -> mesmo Game`, foreground,
|
|
||||||
pausa/suspensao e retorno ao mesmo Game.
|
|
||||||
|
|
||||||
## Decisao
|
|
||||||
|
|
||||||
`system.run_cart` MUST be removed from the userland guest ABI.
|
|
||||||
|
|
||||||
No app, cartridge, Shell app, or other userland guest code MAY directly request
|
|
||||||
cartridge boot through `system.run_cart`.
|
|
||||||
|
|
||||||
The platform MUST NOT preserve compatibility for the current userland
|
|
||||||
`system.run_cart` surface. It MUST NOT remain as a callable stub, reserved
|
|
||||||
guest syscall that returns success, or capability-gated userland escape hatch.
|
|
||||||
|
|
||||||
Direct boot MUST remain supported through host/CLI/debug or equivalent
|
|
||||||
single-game launch paths. This direct boot path is a host/system entrypoint,
|
|
||||||
not a guest syscall.
|
|
||||||
|
|
||||||
Internal cartridge loading MAY continue to use the existing loader path,
|
|
||||||
including directory cartridges and test fixtures. `.pmc` support is not required
|
|
||||||
by this decision.
|
|
||||||
|
|
||||||
If apps ever need to request navigation in the future, that MUST be designed as
|
|
||||||
a separate SystemOS-mediated surface with different naming and semantics. It
|
|
||||||
MUST NOT reintroduce userland `run_cart` as direct cartridge boot.
|
|
||||||
|
|
||||||
## Rationale
|
|
||||||
|
|
||||||
Direct cartridge boot is essential for development, debugging, automated tests,
|
|
||||||
and future distribution modes where the runtime launches as a single game. That
|
|
||||||
use case does not require a guest-visible syscall.
|
|
||||||
|
|
||||||
Userland cartridge boot would give app code authority over system navigation,
|
|
||||||
lifecycle, cleanup, target resolution, and failure policy. Those concerns belong
|
|
||||||
to SystemOS/firmware and later to the cartridge switch orchestrator, not to a VM
|
|
||||||
guest syscall.
|
|
||||||
|
|
||||||
Keeping a stub or compatibility surface would preserve the current ambiguity.
|
|
||||||
The public ABI should not advertise an operation that either does nothing or
|
|
||||||
looks like direct app-controlled navigation.
|
|
||||||
|
|
||||||
## Invariantes / Contrato
|
|
||||||
|
|
||||||
- Guest/userland MUST NOT expose or call `system.run_cart`.
|
|
||||||
- The public syscall registry, declarations, generated bindings, and docs MUST
|
|
||||||
stop presenting `system.run_cart` as an app-callable operation.
|
|
||||||
- Booting a cartridge directly MUST remain available from host/CLI/debug flows.
|
|
||||||
- Direct boot MUST enter the runtime with a selected cartridge target; it MUST
|
|
||||||
NOT depend on a guest syscall issued after VM startup.
|
|
||||||
- Loader code MAY remain reusable by host/firmware/SystemOS internals.
|
|
||||||
- Loader code MUST NOT become lifecycle orchestration authority.
|
|
||||||
- `Game -> Home -> same Game` remains owned by `AGD-0041`.
|
|
||||||
- `Game A -> Home -> Game B` remains owned by `DSC-0043 / AGD-0044`.
|
|
||||||
- `.pmc`, app catalog, launcher UX, marketplace/distribution, and full cartridge
|
|
||||||
replacement under Home are outside this decision.
|
|
||||||
|
|
||||||
## Impactos
|
|
||||||
|
|
||||||
- **Spec / ABI:** remove or retire `system.run_cart` from the userland syscall
|
|
||||||
surface. Any syscall table, PBS declarations, generated metadata, or docs
|
|
||||||
must stop advertising it to apps.
|
|
||||||
- **Runtime / VM:** guest dispatch must no longer treat `system.run_cart` as a
|
|
||||||
valid userland operation. If obsolete bytecode or declarations still reach the
|
|
||||||
runtime during migration, they must fail explicitly rather than report success.
|
|
||||||
- **Firmware / Host:** direct boot remains supported for command-line,
|
|
||||||
debugger, test harness, and future single-game launch flows.
|
|
||||||
- **SystemOS:** no new cart-switch orchestrator is required by this decision.
|
|
||||||
That work is deferred to `DSC-0043 / AGD-0044`.
|
|
||||||
- **Tooling:** tools must continue to support direct cartridge launch for
|
|
||||||
development and test workflows, without relying on a guest syscall.
|
|
||||||
- **Tests:** coverage must prove both sides: userland cannot declare/call
|
|
||||||
`system.run_cart`, and direct boot of a cartridge still works.
|
|
||||||
|
|
||||||
## Alternativas Descartadas
|
|
||||||
|
|
||||||
- **Implement `system.run_cart` as a normal syscall:** rejected because it gives
|
|
||||||
userland direct navigation authority and mixes guest execution with system
|
|
||||||
lifecycle.
|
|
||||||
- **Keep a compatibility stub:** rejected because no compatibility is required
|
|
||||||
and a stub preserves the false contract.
|
|
||||||
- **Make `system.run_cart` privileged but still guest-visible:** rejected for v1
|
|
||||||
because it keeps the ambiguous idea that some guest profile may directly boot
|
|
||||||
cartuchos.
|
|
||||||
- **Implement full `Home -> another Game` now:** rejected for this decision; it
|
|
||||||
requires SystemOS orchestration after the `AGD-0041` foreground contract.
|
|
||||||
|
|
||||||
## Referencias
|
|
||||||
|
|
||||||
- Agenda: `AGD-0003`
|
|
||||||
- Foreground/pause scope: `AGD-0041`
|
|
||||||
- Future cartridge switch orchestrator: `DSC-0043 / AGD-0044`
|
|
||||||
- Related lessons: `LSN-0041` SystemOS lifecycle authority, `LSN-0042`
|
|
||||||
SystemOS service ownership boundary, `LSN-0043` SystemOS domain facades.
|
|
||||||
|
|
||||||
## Propagacao Necessaria
|
|
||||||
|
|
||||||
- Remove `SystemRunCart` from userland ABI declarations and generated syscall
|
|
||||||
metadata.
|
|
||||||
- Remove or rewrite runtime dispatch paths that return success for
|
|
||||||
`system.run_cart`.
|
|
||||||
- Preserve host/CLI/debug direct boot paths and add regression tests for them.
|
|
||||||
- Add negative tests for guest/userland declaration or call attempts.
|
|
||||||
- Update any documentation/spec text that describes `system.run_cart` as
|
|
||||||
app-callable.
|
|
||||||
|
|
||||||
## Revision Log
|
|
||||||
|
|
||||||
- 2026-07-03: Initial draft from `AGD-0003`.
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
---
|
|
||||||
id: DEC-0036
|
|
||||||
ticket: system-run-cart
|
|
||||||
title: SystemOS Games Root and Home Game Launch
|
|
||||||
status: accepted
|
|
||||||
created: 2026-07-03
|
|
||||||
ref_agenda: AGD-0045
|
|
||||||
tags: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted.
|
|
||||||
|
|
||||||
## Contexto
|
|
||||||
|
|
||||||
`DEC-0035` removed `system.run_cart` from the userland guest ABI and preserved
|
|
||||||
direct host/CLI/debug cartridge boot. That decision intentionally did not
|
|
||||||
deliver the Home/SO product flow: pointing PROMETEU at a local games directory,
|
|
||||||
listing available games in Home, and launching a selected game by user action.
|
|
||||||
|
|
||||||
This decision defines the first system-owned replacement for userland
|
|
||||||
`run_cart`: a local games-root library discovered by the host/SystemOS and
|
|
||||||
exposed through a simple Home UI.
|
|
||||||
|
|
||||||
## Decisao
|
|
||||||
|
|
||||||
PROMETEU MUST support a host-provided games library root through
|
|
||||||
`--games-root <dir>`.
|
|
||||||
|
|
||||||
The games root is a directory containing game cartridge directories as immediate
|
|
||||||
children. V1 MUST scan only immediate child directories. Recursive discovery is
|
|
||||||
out of scope.
|
|
||||||
|
|
||||||
The v1 games library MUST include only cartridges whose manifest declares
|
|
||||||
`app_mode: Game`. Shell/System apps, `.pmc` packages, remote catalogs,
|
|
||||||
marketplace/distribution metadata, rich icons, search, and non-game apps are out
|
|
||||||
of scope.
|
|
||||||
|
|
||||||
The library MUST produce expandable internal entries, not UI-only summaries.
|
|
||||||
Each entry MUST retain enough internal data to launch the cartridge and to grow
|
|
||||||
as a catalog model. At minimum, each entry MUST include:
|
|
||||||
|
|
||||||
- loaded manifest data;
|
|
||||||
- `title`;
|
|
||||||
- `app_id`;
|
|
||||||
- `app_version`;
|
|
||||||
- internal cartridge path;
|
|
||||||
- operational discovery metadata, including a timestamp or equivalent discovery
|
|
||||||
marker.
|
|
||||||
|
|
||||||
The Home UI v1 SHOULD display only `title`, `app_id`, and `app_version`. It MAY
|
|
||||||
hide path and operational metadata.
|
|
||||||
|
|
||||||
Invalid cartridge candidates MUST be logged and omitted from the Home game list.
|
|
||||||
|
|
||||||
Selecting a listed Game in Home MUST immediately request a system-owned launch
|
|
||||||
through firmware/SystemOS, resulting in `LoadCartridge` for that cartridge. This
|
|
||||||
MUST NOT use a guest syscall and MUST NOT reintroduce userland `run_cart`.
|
|
||||||
|
|
||||||
If launch fails, the machine MUST return to or remain in Home and record an
|
|
||||||
error/log. A polished error UI is out of scope.
|
|
||||||
|
|
||||||
Booting directly with `--run <cart>` MUST remain separate and unchanged.
|
|
||||||
|
|
||||||
Returning from a running Game to Home is outside this decision and belongs to
|
|
||||||
`AGD-0041`. Switching from `Game A -> Home -> Game B` after a game is already
|
|
||||||
active belongs to `DSC-0043 / AGD-0044`.
|
|
||||||
|
|
||||||
## Rationale
|
|
||||||
|
|
||||||
The previous `run_cart` surface was wrong because it made cartridge launch look
|
|
||||||
like an app/userland capability. The desired behavior is still valid, but the
|
|
||||||
authority must live in the system: Home presents available games and the
|
|
||||||
firmware/SystemOS loads the selected cartridge.
|
|
||||||
|
|
||||||
Using `--games-root` keeps the v1 product model honest: this is a local game
|
|
||||||
library, not a general app root. Other app types can be introduced later without
|
|
||||||
forcing the first launcher model to pretend it is a full marketplace.
|
|
||||||
|
|
||||||
Immediate child scanning keeps discovery deterministic and testable. `.pmc` and
|
|
||||||
recursive layouts can be added after the directory-cartridge path is proven.
|
|
||||||
|
|
||||||
An expandable entry model avoids prematurely freezing the UI shape as the data
|
|
||||||
model. The UI is intentionally simple, but the catalog model needs room for
|
|
||||||
future metadata such as package origin, installation/discovery timestamps, rich
|
|
||||||
presentation fields, and packaged cartridge details.
|
|
||||||
|
|
||||||
## Invariantes / Contrato
|
|
||||||
|
|
||||||
- `--games-root <dir>` is the v1 host argument for a local games library.
|
|
||||||
- The games root is Game-only in v1.
|
|
||||||
- Discovery scans immediate child directories only.
|
|
||||||
- Directory candidates without valid Game cartridge metadata are logged and
|
|
||||||
omitted.
|
|
||||||
- Library entries are internal catalog records, not direct UI DTOs.
|
|
||||||
- The UI v1 displays a simple clickable list and may show only `title`,
|
|
||||||
`app_id`, and `app_version`.
|
|
||||||
- The internal entry retains cartridge path and loaded manifest data for launch.
|
|
||||||
- Home selection is a system action that enters firmware/SystemOS loading. It is
|
|
||||||
not a guest syscall.
|
|
||||||
- `--run <cart>` direct boot remains independent from `--games-root`.
|
|
||||||
- Return-to-Home and game-to-game switching are not implemented by this
|
|
||||||
decision.
|
|
||||||
|
|
||||||
## Impactos
|
|
||||||
|
|
||||||
- **Host:** add a `--games-root <dir>` argument and pass the resolved root into
|
|
||||||
runtime/firmware/SystemOS initialization.
|
|
||||||
- **SystemOS / Hub:** add a local game library model and expose entries to Home.
|
|
||||||
Home must render a minimal clickable list and emit a system-owned launch
|
|
||||||
action for a selected entry.
|
|
||||||
- **Firmware:** consume the Home launch action and transition to
|
|
||||||
`LoadCartridge` with the selected cartridge path.
|
|
||||||
- **Loader:** continue to validate complete directory cartridges. Discovery may
|
|
||||||
read manifest metadata, but full launch still uses the existing loader.
|
|
||||||
- **Runtime / VM:** no userland ABI is added. No guest syscall participates in
|
|
||||||
launch.
|
|
||||||
- **Specs / Docs:** document `--games-root`, Game-only v1 discovery, and the
|
|
||||||
distinction between direct boot, games library launch, and future game
|
|
||||||
switching.
|
|
||||||
- **Tests:** cover discovery, invalid omission/logging, Home action emission,
|
|
||||||
and the end-to-end path from games root selection to `GameRunning`.
|
|
||||||
|
|
||||||
## Alternativas Descartadas
|
|
||||||
|
|
||||||
- **List only, no launch:** rejected because it does not satisfy the product
|
|
||||||
goal of starting games from Home.
|
|
||||||
- **General app root:** rejected for v1 because Shell/System apps need separate
|
|
||||||
lifecycle and UI contracts.
|
|
||||||
- **Recursive discovery:** rejected for v1 to keep semantics deterministic.
|
|
||||||
- **`.pmc` discovery:** rejected for v1 because packed cartridge loading is a
|
|
||||||
separate open agenda.
|
|
||||||
- **Game-to-game switch now:** rejected because it depends on foreground and
|
|
||||||
cartridge-switch orchestration work in `AGD-0041` and `AGD-0044`.
|
|
||||||
|
|
||||||
## Referencias
|
|
||||||
|
|
||||||
- Source agenda: `AGD-0045`
|
|
||||||
- Userland run-cart removal: `DEC-0035`
|
|
||||||
- Foreground/Home return scope: `AGD-0041`
|
|
||||||
- Future cartridge switch orchestration: `DSC-0043 / AGD-0044`
|
|
||||||
|
|
||||||
## Propagacao Necessaria
|
|
||||||
|
|
||||||
- Add or update runtime specs for games-root discovery and Home launch.
|
|
||||||
- Add host CLI argument plumbing for `--games-root`.
|
|
||||||
- Add a catalog/library entry model.
|
|
||||||
- Add Hub/Home list rendering and click handling.
|
|
||||||
- Add firmware/SystemOS action routing to `LoadCartridge`.
|
|
||||||
- Add tests for discovery, invalid candidates, action emission, and
|
|
||||||
`games-root -> Home click -> GameRunning`.
|
|
||||||
|
|
||||||
## Revision Log
|
|
||||||
|
|
||||||
- 2026-07-03: Initial draft from `AGD-0045`.
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
---
|
|
||||||
id: PLN-0129
|
|
||||||
ticket: system-run-cart
|
|
||||||
title: Remove Userland Run Cart ABI Surface
|
|
||||||
status: done
|
|
||||||
created: 2026-07-03
|
|
||||||
ref_decisions: [DEC-0035]
|
|
||||||
tags: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Briefing
|
|
||||||
|
|
||||||
`DEC-0035` removes `system.run_cart` from the userland guest ABI. This plan
|
|
||||||
covers the public ABI, syscall registry/declaration surface, generated metadata,
|
|
||||||
and documentation/spec propagation. It does not change runtime dispatch behavior
|
|
||||||
or direct boot tests; those are covered by `PLN-0130` and `PLN-0131`.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Remove `system.run_cart` from every app-callable/userland ABI surface while
|
|
||||||
preserving non-guest direct boot entrypoints.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Source decision: `DEC-0035`.
|
|
||||||
- Must stay scoped away from `AGD-0041` and `AGD-0044`.
|
|
||||||
- Coordinate with `PLN-0130` so runtime dispatch does not retain a callable
|
|
||||||
success path after the declaration is removed.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Remove or retire `Syscall::SystemRunCart` from userland-visible syscall
|
|
||||||
declarations in `crates/console/prometeu-hal/src/syscalls.rs`.
|
|
||||||
- Remove the `system.run_cart` registry entry from
|
|
||||||
`crates/console/prometeu-hal/src/syscalls/domains/system.rs` and
|
|
||||||
`crates/console/prometeu-hal/src/syscalls/registry.rs`, unless implementation
|
|
||||||
discovers an internal-only registry distinct from guest ABI.
|
|
||||||
- Remove generated/static bytecode or VM-facing declarations that expose
|
|
||||||
`system.run_cart`, including known references in
|
|
||||||
`crates/console/prometeu-bytecode/src/model.rs` and
|
|
||||||
`crates/console/prometeu-vm/src/virtual_machine.rs` if they include it.
|
|
||||||
- Update docs/specs that mention `system.run_cart` as app-callable.
|
|
||||||
- Update `AGD-0004` references only if they become factually misleading after
|
|
||||||
removal; do not close or reinterpret that agenda.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Do not implement `Home -> another Game`.
|
|
||||||
- Do not add a replacement app navigation syscall.
|
|
||||||
- Do not implement `.pmc`.
|
|
||||||
- Do not change direct host/CLI/debug boot behavior.
|
|
||||||
- Do not add compatibility stubs for obsolete guest code.
|
|
||||||
|
|
||||||
## Execution Method
|
|
||||||
|
|
||||||
1. Search for `SystemRunCart`, `run_cart`, and `"system", "run_cart"` across
|
|
||||||
`crates/`, `docs/`, and `discussion/`.
|
|
||||||
2. Remove `system.run_cart` from userland syscall domain declarations and
|
|
||||||
lookup/name registries.
|
|
||||||
3. Remove generated/static guest-facing metadata entries that advertise
|
|
||||||
`system.run_cart`.
|
|
||||||
4. Update docs/specs to state that cartridges are booted by host/system paths,
|
|
||||||
not by guest syscall.
|
|
||||||
5. Compile the workspace or targeted crates to find stale references.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- No userland ABI declaration advertises `system.run_cart`.
|
|
||||||
- No generated guest metadata exposes `run_cart` as callable.
|
|
||||||
- Documentation/spec text no longer describes app-callable cartridge boot.
|
|
||||||
- Any retained code path is clearly internal-only and is not reachable through
|
|
||||||
guest syscall lookup.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- Add or update syscall registry tests to assert `system.run_cart` is absent
|
|
||||||
from the public/userland registry.
|
|
||||||
- Run targeted tests for `prometeu-hal`, `prometeu-bytecode`, and
|
|
||||||
`prometeu-vm` if available.
|
|
||||||
- Run repository validation commands used for syscall declaration consistency.
|
|
||||||
|
|
||||||
## Affected Artifacts
|
|
||||||
|
|
||||||
- `crates/console/prometeu-hal/src/syscalls.rs`
|
|
||||||
- `crates/console/prometeu-hal/src/syscalls/domains/system.rs`
|
|
||||||
- `crates/console/prometeu-hal/src/syscalls/registry.rs`
|
|
||||||
- `crates/console/prometeu-bytecode/src/model.rs`
|
|
||||||
- `crates/console/prometeu-vm/src/virtual_machine.rs`
|
|
||||||
- `docs/specs/runtime/`
|
|
||||||
- `discussion/workflow/agendas/AGD-0004-system-fault-semantics-and-control-surface.md`
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
---
|
|
||||||
id: PLN-0130
|
|
||||||
ticket: system-run-cart
|
|
||||||
title: Remove Runtime Run Cart Stub Dispatch
|
|
||||||
status: done
|
|
||||||
created: 2026-07-03
|
|
||||||
ref_decisions: [DEC-0035]
|
|
||||||
tags: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Briefing
|
|
||||||
|
|
||||||
`DEC-0035` forbids a callable userland `system.run_cart` stub. This plan covers
|
|
||||||
runtime and VM dispatch cleanup so obsolete calls cannot silently succeed.
|
|
||||||
Public ABI removal is handled by `PLN-0129`; direct boot preservation is handled
|
|
||||||
by `PLN-0131`.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Remove the runtime no-op dispatch path for `SystemRunCart` and ensure stale or
|
|
||||||
obsolete userland attempts fail explicitly rather than returning success.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Source decision: `DEC-0035`.
|
|
||||||
- Depends on the ABI direction in `PLN-0129`, but can be implemented in the same
|
|
||||||
PR if the public enum/registry removal and dispatch cleanup move together.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Remove the `Syscall::SystemRunCart => return Ok(())` path from
|
|
||||||
`crates/console/prometeu-system/src/services/vm_runtime/dispatch.rs`.
|
|
||||||
- Update any match arms made unreachable or obsolete by removing
|
|
||||||
`SystemRunCart`.
|
|
||||||
- Ensure stale numeric syscall ids or stale bytecode declarations fail with the
|
|
||||||
repository's existing unknown/invalid syscall behavior.
|
|
||||||
- Add negative coverage for userland attempts to invoke the removed operation,
|
|
||||||
using the closest existing VM runtime syscall tests.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Do not implement a replacement navigation operation.
|
|
||||||
- Do not implement the `Home -> another Game` orchestrator.
|
|
||||||
- Do not alter direct host/CLI/debug cartridge boot paths.
|
|
||||||
- Do not change `system.has_cart` behavior unless required by compile fallout.
|
|
||||||
|
|
||||||
## Execution Method
|
|
||||||
|
|
||||||
1. Inspect `dispatch.rs` for all `SystemRunCart` match arms.
|
|
||||||
2. Remove the no-op success path and any unreachable arms tied only to
|
|
||||||
`SystemRunCart`.
|
|
||||||
3. If the public enum variant is removed by `PLN-0129`, update exhaustiveness
|
|
||||||
and registry mapping accordingly.
|
|
||||||
4. Add or update VM runtime tests that demonstrate `system.run_cart` is not
|
|
||||||
callable from guest/userland.
|
|
||||||
5. Run targeted runtime tests and compile checks.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- There is no runtime path where a guest `system.run_cart` call returns `Ok(())`.
|
|
||||||
- Stale/obsolete attempts fail explicitly through existing invalid syscall or
|
|
||||||
declaration validation behavior.
|
|
||||||
- `system.has_cart` and unrelated system syscalls keep their current behavior.
|
|
||||||
- Runtime dispatch remains exhaustive and clear after removing `SystemRunCart`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- Add a negative test in `crates/console/prometeu-system/src/services/vm_runtime`
|
|
||||||
for stale userland `run_cart` attempts, if the test harness can express it.
|
|
||||||
- Run `cargo test` for `prometeu-system` and any impacted syscall crates.
|
|
||||||
- Run a workspace compile/test command if targeted tests reveal cross-crate enum
|
|
||||||
fallout.
|
|
||||||
|
|
||||||
## Affected Artifacts
|
|
||||||
|
|
||||||
- `crates/console/prometeu-system/src/services/vm_runtime/dispatch.rs`
|
|
||||||
- `crates/console/prometeu-system/src/services/vm_runtime/tests.rs`
|
|
||||||
- `crates/console/prometeu-hal/src/syscalls.rs`
|
|
||||||
- `crates/console/prometeu-hal/src/syscalls/registry.rs`
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
---
|
|
||||||
id: PLN-0131
|
|
||||||
ticket: system-run-cart
|
|
||||||
title: Preserve Direct Cartridge Boot Coverage
|
|
||||||
status: done
|
|
||||||
created: 2026-07-03
|
|
||||||
ref_decisions: [DEC-0035]
|
|
||||||
tags: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Briefing
|
|
||||||
|
|
||||||
`DEC-0035` removes userland `system.run_cart`, but direct cartridge boot remains
|
|
||||||
mandatory for development, debugging, automated tests, and future single-game
|
|
||||||
launch scenarios. This plan protects that path while the guest syscall is
|
|
||||||
removed.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Preserve and test host/CLI/debug direct cartridge boot without relying on a
|
|
||||||
guest syscall.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Source decision: `DEC-0035`.
|
|
||||||
- Can be implemented after or alongside `PLN-0129` and `PLN-0130`.
|
|
||||||
- Must not depend on `.pmc`, app catalog, launcher UX, `AGD-0041`, or
|
|
||||||
`AGD-0044`.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Identify direct boot entrypoints in `crates/tools/prometeu-cli/src/main.rs`,
|
|
||||||
`crates/host/prometeu-host-desktop-winit/src/lib.rs`,
|
|
||||||
`crates/host/prometeu-host-desktop-winit/src/debugger.rs`, and firmware
|
|
||||||
cartridge loading code.
|
|
||||||
- Add or preserve tests showing a cartridge can be loaded directly into
|
|
||||||
firmware/runtime from host or CLI paths.
|
|
||||||
- Ensure these paths do not call or require `system.run_cart`.
|
|
||||||
- Document the distinction between direct boot and guest navigation where
|
|
||||||
runtime specs describe boot behavior.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Do not implement `Home -> another Game`.
|
|
||||||
- Do not add app-callable navigation.
|
|
||||||
- Do not require `.pmc`.
|
|
||||||
- Do not redesign cartridge loader internals beyond what is required to keep
|
|
||||||
direct boot working.
|
|
||||||
|
|
||||||
## Execution Method
|
|
||||||
|
|
||||||
1. Trace the CLI/debug direct boot path from cartridge path argument through
|
|
||||||
loader, firmware `load_cartridge`, and runtime initialization.
|
|
||||||
2. Add regression tests at the lowest practical layer proving direct boot still
|
|
||||||
reaches `GameRunning` for a valid game cartridge.
|
|
||||||
3. Add a guard or assertion that direct boot does not depend on `system.run_cart`
|
|
||||||
metadata.
|
|
||||||
4. Update docs/specs to describe direct boot as host/system-controlled.
|
|
||||||
5. Run targeted CLI/firmware/host tests.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- A valid game cartridge can still boot directly from host/CLI/debug paths.
|
|
||||||
- Direct boot does not depend on `system.run_cart` being in the guest syscall
|
|
||||||
registry.
|
|
||||||
- Existing debugger/handshake metadata that reports cartridge info still works.
|
|
||||||
- The direct boot behavior is documented as host/system entry, not guest ABI.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- Add or preserve firmware tests around `load_cartridge` and `GameRunning`.
|
|
||||||
- Add or preserve host/CLI tests for cartridge path boot when practical.
|
|
||||||
- Run `cargo test` for `prometeu-firmware`, `prometeu-host-desktop-winit`, and
|
|
||||||
`prometeu-cli` where available.
|
|
||||||
|
|
||||||
## Affected Artifacts
|
|
||||||
|
|
||||||
- `crates/tools/prometeu-cli/src/main.rs`
|
|
||||||
- `crates/host/prometeu-host-desktop-winit/src/lib.rs`
|
|
||||||
- `crates/host/prometeu-host-desktop-winit/src/debugger.rs`
|
|
||||||
- `crates/console/prometeu-firmware/src/firmware/firmware.rs`
|
|
||||||
- `crates/console/prometeu-firmware/src/firmware/firmware_step_load_cartridge.rs`
|
|
||||||
- `docs/specs/runtime/`
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
---
|
|
||||||
id: PLN-0132
|
|
||||||
ticket: system-run-cart
|
|
||||||
title: Specify Games Root Home Launch Contract
|
|
||||||
status: done
|
|
||||||
created: 2026-07-03
|
|
||||||
ref_decisions: [DEC-0036]
|
|
||||||
tags: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Briefing
|
|
||||||
|
|
||||||
`DEC-0036` establishes a new system-owned game library launch path:
|
|
||||||
the host receives `--games-root <dir>`, SystemOS/Home lists valid Game
|
|
||||||
directory cartridges from that root, and selecting one starts the cartridge
|
|
||||||
through firmware loading rather than a guest syscall.
|
|
||||||
|
|
||||||
This plan updates the normative specs before code is changed.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Document the v1 games-root and Home launch contract in the canonical runtime
|
|
||||||
specs so implementation plans cannot drift back into userland `run_cart` or
|
|
||||||
general app-library semantics.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Source decision: `DEC-0036`.
|
|
||||||
- Prior decision: `DEC-0035`, which removed userland `system.run_cart` and
|
|
||||||
preserved direct `--run` boot.
|
|
||||||
- This plan must be completed before `PLN-0133`, `PLN-0134`, and `PLN-0135`.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Update the runtime specs to define `--games-root <dir>` as the v1 local games
|
|
||||||
library root.
|
|
||||||
- Specify that discovery scans only immediate child directories.
|
|
||||||
- Specify that v1 library discovery includes only valid directory cartridges
|
|
||||||
with `app_mode: Game`.
|
|
||||||
- Specify that `.pmc`, recursive discovery, Shell/System apps, marketplace
|
|
||||||
metadata, rich presentation, search, and non-game apps are out of scope.
|
|
||||||
- Specify the internal library entry contract: loaded manifest data, `title`,
|
|
||||||
`app_id`, `app_version`, internal cartridge path, and discovery metadata.
|
|
||||||
- Specify that Home v1 may display only `title`, `app_id`, and `app_version`.
|
|
||||||
- Specify that selecting a Home game emits a system-owned launch request that
|
|
||||||
transitions through firmware/SystemOS into `LoadCartridge`.
|
|
||||||
- Specify that invalid candidates are logged and omitted.
|
|
||||||
- Specify that launch failure remains or returns to Home and logs the failure.
|
|
||||||
- Reaffirm that direct `--run <cart>` boot remains independent and unchanged.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- No Rust implementation.
|
|
||||||
- No Home UI changes.
|
|
||||||
- No `.pmc` discovery or packaged-cartridge spec changes.
|
|
||||||
- No recursive library layout.
|
|
||||||
- No return-to-Home behavior for a running Game.
|
|
||||||
- No Game A -> Home -> Game B orchestration.
|
|
||||||
- No guest syscall or userland ABI addition.
|
|
||||||
|
|
||||||
## Execution Method
|
|
||||||
|
|
||||||
1. Update [docs/specs/runtime/14-boot-profiles.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/docs/specs/runtime/14-boot-profiles.md) with:
|
|
||||||
- direct `--run` boot remaining a single-cartridge boot profile;
|
|
||||||
- `--games-root <dir>` as a SystemOS/Home library profile;
|
|
||||||
- the distinction between direct boot, Home launch, and future game-switch
|
|
||||||
orchestration.
|
|
||||||
2. Update [docs/specs/runtime/12-firmware-pos-and-prometeuhub.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md) with:
|
|
||||||
- Home's responsibility to display the local games library;
|
|
||||||
- the system-owned launch action boundary;
|
|
||||||
- failure handling that leaves the machine in Home and records an error.
|
|
||||||
3. Update [docs/specs/runtime/13-cartridge.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/docs/specs/runtime/13-cartridge.md) with:
|
|
||||||
- directory-cartridge discovery requirements for library candidates;
|
|
||||||
- Game-only filtering by manifest `app_mode`;
|
|
||||||
- invalid candidate omission and diagnostics.
|
|
||||||
4. Update [docs/specs/runtime/16-host-abi-and-syscalls.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/docs/specs/runtime/16-host-abi-and-syscalls.md) only if needed to reinforce that no guest syscall owns cartridge launch.
|
|
||||||
5. Keep all spec text in English.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- The specs define `--games-root <dir>` and do not describe it as a generic app
|
|
||||||
root.
|
|
||||||
- The specs state that v1 scans immediate child directories only.
|
|
||||||
- The specs state that only valid Game directory cartridges enter the Home list.
|
|
||||||
- The specs state that Home launch is system-owned and enters `LoadCartridge`.
|
|
||||||
- The specs preserve `--run <cart>` as a separate direct boot path.
|
|
||||||
- The specs explicitly exclude `.pmc`, recursive discovery, return-to-Home, and
|
|
||||||
game-switch orchestration from this implementation slice.
|
|
||||||
- No spec reintroduces userland `run_cart`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- Run `discussion validate`.
|
|
||||||
- Run targeted text checks for `--games-root`, `LoadCartridge`, and absence of
|
|
||||||
`system.run_cart` as an active public launch mechanism in edited specs.
|
|
||||||
|
|
||||||
## Affected Artifacts
|
|
||||||
|
|
||||||
- `docs/specs/runtime/12-firmware-pos-and-prometeuhub.md`
|
|
||||||
- `docs/specs/runtime/13-cartridge.md`
|
|
||||||
- `docs/specs/runtime/14-boot-profiles.md`
|
|
||||||
- `docs/specs/runtime/16-host-abi-and-syscalls.md`
|
|
||||||
- `discussion/workflow/plans/PLN-0132-specify-games-root-home-launch-contract.md`
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
---
|
|
||||||
id: PLN-0133
|
|
||||||
ticket: system-run-cart
|
|
||||||
title: Implement Games Root Library Discovery
|
|
||||||
status: done
|
|
||||||
created: 2026-07-03
|
|
||||||
ref_decisions: [DEC-0036]
|
|
||||||
tags: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Briefing
|
|
||||||
|
|
||||||
`DEC-0036` requires a host-provided games root and an expandable internal
|
|
||||||
library model before Home can render or launch games. This plan implements the
|
|
||||||
library discovery and host plumbing only; UI launch wiring is handled by
|
|
||||||
`PLN-0134`.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Add a tested games-root discovery model that scans immediate child directory
|
|
||||||
cartridges, filters to valid Game manifests, records diagnostics for invalid
|
|
||||||
candidates, and carries enough metadata for Home rendering and later launch.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Source decision: `DEC-0036`.
|
|
||||||
- `PLN-0132` should be completed first so code follows the documented contract.
|
|
||||||
- Existing cartridge manifest and directory loader behavior in the console
|
|
||||||
crates.
|
|
||||||
- Existing host CLI and `HostRunner` boot setup.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add `--games-root <PATH>` to the desktop host CLI.
|
|
||||||
- Pass the optional games root into the host runner / firmware initialization
|
|
||||||
path without changing direct `--run` or `--debug` boot behavior.
|
|
||||||
- Add a SystemOS-owned games library model for directory cartridge discovery.
|
|
||||||
- Scan only immediate child directories of the root.
|
|
||||||
- Read and retain manifest data for each valid candidate.
|
|
||||||
- Include only candidates whose manifest declares `app_mode: Game`.
|
|
||||||
- Retain internal cartridge path and operational discovery metadata on each
|
|
||||||
entry.
|
|
||||||
- Emit diagnostics for invalid, unreadable, or non-Game candidates and omit
|
|
||||||
them from the returned entries.
|
|
||||||
- Keep discovery independent from full cartridge launch: discovery reads
|
|
||||||
metadata; launch still uses the existing loader in `PLN-0134`.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- No Home list rendering.
|
|
||||||
- No click handling or `LoadCartridge` transition.
|
|
||||||
- No `.pmc` discovery.
|
|
||||||
- No recursive scan.
|
|
||||||
- No Shell/System app catalog.
|
|
||||||
- No marketplace metadata, icons, search, sorting UI, or polished errors.
|
|
||||||
- No new guest syscall.
|
|
||||||
|
|
||||||
## Execution Method
|
|
||||||
|
|
||||||
1. Introduce a library/discovery module in the SystemOS side, preferably under
|
|
||||||
`crates/console/prometeu-system/src/services/` unless the existing module
|
|
||||||
layout has a more local owner.
|
|
||||||
2. Define an internal entry type with at least:
|
|
||||||
- loaded cartridge manifest data;
|
|
||||||
- `title`;
|
|
||||||
- `app_id`;
|
|
||||||
- `app_version`;
|
|
||||||
- internal cartridge path;
|
|
||||||
- discovery metadata, using `SystemTime` or another explicit discovery
|
|
||||||
marker.
|
|
||||||
3. Define discovery diagnostics that can be logged by the caller and asserted in
|
|
||||||
tests.
|
|
||||||
4. Implement a scanner that:
|
|
||||||
- accepts an optional root path;
|
|
||||||
- returns an empty library when no root is configured;
|
|
||||||
- iterates only immediate child directories;
|
|
||||||
- parses `manifest.json` using structured manifest types, not ad hoc string
|
|
||||||
matching;
|
|
||||||
- filters to `app_mode: Game`;
|
|
||||||
- sorts entries deterministically for stable Home presentation and tests.
|
|
||||||
5. Add `--games-root <PATH>` to
|
|
||||||
[crates/host/prometeu-host-desktop-winit/src/lib.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/host/prometeu-host-desktop-winit/src/lib.rs)
|
|
||||||
and pass the path into
|
|
||||||
[crates/host/prometeu-host-desktop-winit/src/runner.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/host/prometeu-host-desktop-winit/src/runner.rs).
|
|
||||||
6. Store or expose the discovered library to firmware/SystemOS in a way that
|
|
||||||
`PLN-0134` can consume without re-scanning inside the Home click path.
|
|
||||||
7. Add focused unit tests using temporary cartridge roots with:
|
|
||||||
- one valid Game directory cartridge;
|
|
||||||
- one valid non-Game cartridge;
|
|
||||||
- one invalid directory;
|
|
||||||
- one nested directory that must not be discovered.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- `--games-root <PATH>` is accepted by the desktop host CLI.
|
|
||||||
- Absence of `--games-root` leaves existing Hub boot behavior unchanged.
|
|
||||||
- Direct `--run <cart>` and `--debug <cart>` behavior remains unchanged.
|
|
||||||
- Discovery returns only immediate valid Game directory cartridges.
|
|
||||||
- Each library entry retains manifest data, path, title, app id, app version,
|
|
||||||
and discovery metadata.
|
|
||||||
- Invalid candidates are represented in diagnostics and excluded from entries.
|
|
||||||
- Discovery does not fully load or boot cartridge programs.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- Add and run focused games-library discovery tests in the owning console crate.
|
|
||||||
- Run `cargo test -p prometeu-system` if the library lives in
|
|
||||||
`prometeu-system`.
|
|
||||||
- Run `cargo test -p prometeu-host-desktop-winit` for CLI/runner plumbing.
|
|
||||||
- Run `discussion validate`.
|
|
||||||
|
|
||||||
## Affected Artifacts
|
|
||||||
|
|
||||||
- `crates/console/prometeu-system/src/services/`
|
|
||||||
- `crates/console/prometeu-system/src/lib.rs` or module exports as needed
|
|
||||||
- `crates/host/prometeu-host-desktop-winit/src/lib.rs`
|
|
||||||
- `crates/host/prometeu-host-desktop-winit/src/runner.rs`
|
|
||||||
- `discussion/workflow/plans/PLN-0133-implement-games-root-library-discovery.md`
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
---
|
|
||||||
id: PLN-0134
|
|
||||||
ticket: system-run-cart
|
|
||||||
title: Wire Home Game List Launch Flow
|
|
||||||
status: done
|
|
||||||
created: 2026-07-03
|
|
||||||
ref_decisions: [DEC-0036]
|
|
||||||
tags: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Briefing
|
|
||||||
|
|
||||||
After `PLN-0133`, SystemOS can discover a local Game library but Home still
|
|
||||||
does not present or launch those entries. This plan wires the discovered
|
|
||||||
library into Prometeu Hub/Home and routes a selected game to firmware
|
|
||||||
`LoadCartridge`.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make Home display a minimal clickable games list and launch the selected Game
|
|
||||||
through a system-owned firmware/SystemOS path, without reintroducing userland
|
|
||||||
`run_cart`.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Source decision: `DEC-0036`.
|
|
||||||
- `PLN-0132` for documented contract.
|
|
||||||
- `PLN-0133` for `--games-root` plumbing and the internal library entry model.
|
|
||||||
- Existing Prometeu Hub Home profile and firmware state transitions.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Provide the discovered games library to Prometeu Hub/Home.
|
|
||||||
- Render a minimal Home list showing `title`, `app_id`, and `app_version`.
|
|
||||||
- Add Home hit-testing/click handling for game entries.
|
|
||||||
- Extend the system profile action model with a system-owned game launch action.
|
|
||||||
- Handle that action in firmware/SystemOS by loading the selected cartridge via
|
|
||||||
the existing cartridge loader and entering `LoadCartridge`.
|
|
||||||
- On load failure, log/record the error and keep or return the machine to Home.
|
|
||||||
- Preserve existing native shell launch behavior.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- No polished launcher UI.
|
|
||||||
- No icons, screenshots, search, filtering, or marketplace metadata.
|
|
||||||
- No return-to-Home while a Game is running.
|
|
||||||
- No Game A -> Home -> Game B foreground orchestration.
|
|
||||||
- No `.pmc` launch support.
|
|
||||||
- No guest syscall.
|
|
||||||
- No direct-boot behavior changes.
|
|
||||||
|
|
||||||
## Execution Method
|
|
||||||
|
|
||||||
1. Extend the SystemOS/Hub action type currently represented by
|
|
||||||
`SystemProfileAction::LaunchNativeShell` with a game-launch action that
|
|
||||||
carries a stable reference to the selected library entry or cartridge path.
|
|
||||||
2. Update the Prometeu Hub state in
|
|
||||||
[crates/console/prometeu-system/src/programs/prometeu_hub.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-system/src/programs/prometeu_hub.rs)
|
|
||||||
or its local modules to accept the discovered game entries.
|
|
||||||
3. Render the Home games list with stable dimensions and simple clickable rows.
|
|
||||||
Rows must display only `title`, `app_id`, and `app_version` for v1.
|
|
||||||
4. Add hit-testing so selecting a row emits the game-launch action immediately.
|
|
||||||
5. Update
|
|
||||||
[crates/console/prometeu-firmware/src/firmware/steps/firmware_step_hub_home.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-firmware/src/firmware/steps/firmware_step_hub_home.rs)
|
|
||||||
to consume the new action.
|
|
||||||
6. In the firmware action handler:
|
|
||||||
- call the existing cartridge loader for the selected path;
|
|
||||||
- on success, return `FirmwareState::LoadCartridge(...)`;
|
|
||||||
- on failure, log/record the error and remain in the Home path.
|
|
||||||
7. Ensure shell launch still works exactly as before.
|
|
||||||
8. Add unit or integration tests for:
|
|
||||||
- Home list presentation data;
|
|
||||||
- click-to-action emission;
|
|
||||||
- firmware action handling success;
|
|
||||||
- firmware action handling failure.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- Starting the host with `--games-root <dir>` exposes valid Game entries in
|
|
||||||
Home.
|
|
||||||
- The Home list displays `title`, `app_id`, and `app_version`.
|
|
||||||
- Clicking a listed Game routes through a system action and enters
|
|
||||||
`LoadCartridge`.
|
|
||||||
- The launch path does not use a guest syscall and does not mention
|
|
||||||
`system.run_cart`.
|
|
||||||
- Invalid or failing launch paths remain in Home and record an error.
|
|
||||||
- Existing native shell Home actions continue to work.
|
|
||||||
- Direct `--run` remains separate and unchanged.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- Run `cargo test -p prometeu-system` for Hub action and presentation behavior.
|
|
||||||
- Run `cargo test -p prometeu-firmware` for firmware transition behavior.
|
|
||||||
- Run `cargo test -p prometeu-host-desktop-winit` if runner integration changes.
|
|
||||||
- Run `discussion validate`.
|
|
||||||
|
|
||||||
## Affected Artifacts
|
|
||||||
|
|
||||||
- `crates/console/prometeu-system/src/programs/prometeu_hub.rs`
|
|
||||||
- `crates/console/prometeu-system/src/services/`
|
|
||||||
- `crates/console/prometeu-firmware/src/firmware/steps/firmware_step_hub_home.rs`
|
|
||||||
- `crates/console/prometeu-firmware/src/firmware/firmware.rs`
|
|
||||||
- `crates/host/prometeu-host-desktop-winit/src/runner.rs`
|
|
||||||
- `discussion/workflow/plans/PLN-0134-wire-home-game-list-launch-flow.md`
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
---
|
|
||||||
id: PLN-0135
|
|
||||||
ticket: system-run-cart
|
|
||||||
title: Validate Games Root Launch End To End
|
|
||||||
status: done
|
|
||||||
created: 2026-07-03
|
|
||||||
ref_decisions: [DEC-0036]
|
|
||||||
tags: []
|
|
||||||
---
|
|
||||||
|
|
||||||
## Briefing
|
|
||||||
|
|
||||||
`PLN-0132` through `PLN-0134` specify, discover, display, and launch games
|
|
||||||
from a local games root. This plan closes the slice by adding end-to-end
|
|
||||||
validation evidence and manual smoke instructions for the product behavior the
|
|
||||||
user wants: point the SO at a directory, click a listed game, and boot it.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Prove the complete `--games-root -> Home list -> click -> LoadCartridge ->
|
|
||||||
GameRunning` path while preserving direct `--run` boot and the absence of
|
|
||||||
userland `run_cart`.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Source decision: `DEC-0036`.
|
|
||||||
- `PLN-0132`, `PLN-0133`, and `PLN-0134` must be complete.
|
|
||||||
- Existing test cartridge fixtures, especially `test-cartridges/stress-console`.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add automated integration coverage for the full games-root Home launch path
|
|
||||||
where the existing host/firmware test seams allow it.
|
|
||||||
- Add or document a minimal local games-root fixture using existing directory
|
|
||||||
cartridges.
|
|
||||||
- Validate invalid-candidate omission at the end-to-end boundary.
|
|
||||||
- Validate direct `--run` still works after games-root wiring.
|
|
||||||
- Capture manual smoke-test commands for release-mode functional verification.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- No new product UI polish.
|
|
||||||
- No screenshot automation unless the existing test harness already supports it
|
|
||||||
cleanly.
|
|
||||||
- No return-to-Home after a running Game.
|
|
||||||
- No Game A -> Home -> Game B orchestration.
|
|
||||||
- No `.pmc` validation.
|
|
||||||
- No broad performance tuning beyond confirming release-mode smoke behavior.
|
|
||||||
|
|
||||||
## Execution Method
|
|
||||||
|
|
||||||
1. Add an integration-style firmware or host test that constructs a games root
|
|
||||||
with a valid Game directory cartridge and boots to Hub/Home with that root.
|
|
||||||
2. Drive the Home selection path through the same action path used by user
|
|
||||||
input, not by calling the cartridge loader directly.
|
|
||||||
3. Assert that the machine reaches `LoadCartridge` or `GameRunning`, depending
|
|
||||||
on the most stable observable state available in the test harness.
|
|
||||||
4. Add a negative case with an invalid candidate and assert it is omitted or
|
|
||||||
reported as a diagnostic without appearing in Home.
|
|
||||||
5. Add a regression assertion that no public guest syscall named
|
|
||||||
`system.run_cart` is required by this path.
|
|
||||||
6. Re-run direct boot tests or add a small regression test proving
|
|
||||||
`--run test-cartridges/stress-console` remains independent from
|
|
||||||
`--games-root`.
|
|
||||||
7. Record manual smoke commands in the relevant spec or developer-facing doc:
|
|
||||||
- `cargo run --release -p prometeu-host-desktop-winit -- --games-root test-cartridges`
|
|
||||||
- `cargo run --release -p prometeu-host-desktop-winit -- --run test-cartridges/stress-console`
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- Automated tests cover a valid games-root Home launch path.
|
|
||||||
- Automated tests cover invalid candidate omission or diagnostics at the
|
|
||||||
end-to-end boundary.
|
|
||||||
- Automated or documented validation proves direct `--run` still works.
|
|
||||||
- Manual smoke instructions use release mode for realistic interactive speed.
|
|
||||||
- Validation evidence shows no guest syscall participates in launch.
|
|
||||||
- `discussion validate` passes.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- Run `cargo test -p prometeu-system`.
|
|
||||||
- Run `cargo test -p prometeu-firmware`.
|
|
||||||
- Run `cargo test -p prometeu-host-desktop-winit`.
|
|
||||||
- Run `cargo test -p prometeu-hal syscalls` to keep the removed syscall surface
|
|
||||||
guarded.
|
|
||||||
- Manually smoke test:
|
|
||||||
`cargo run --release -p prometeu-host-desktop-winit -- --games-root test-cartridges`.
|
|
||||||
- Manually smoke test:
|
|
||||||
`cargo run --release -p prometeu-host-desktop-winit -- --run test-cartridges/stress-console`.
|
|
||||||
- Run `discussion validate`.
|
|
||||||
|
|
||||||
## Affected Artifacts
|
|
||||||
|
|
||||||
- `crates/console/prometeu-system/`
|
|
||||||
- `crates/console/prometeu-firmware/`
|
|
||||||
- `crates/host/prometeu-host-desktop-winit/`
|
|
||||||
- `crates/console/prometeu-hal/src/syscalls/tests.rs`
|
|
||||||
- Runtime specs or developer docs containing smoke commands
|
|
||||||
- `discussion/workflow/plans/PLN-0135-validate-games-root-launch-end-to-end.md`
|
|
||||||
@ -239,8 +239,10 @@ The same physical surface may be reused by multiple visual owners, but logical
|
|||||||
ownership MUST remain explicit.
|
ownership MUST remain explicit.
|
||||||
|
|
||||||
Game pause/resume, foreground stack behavior, and coexistence of a paused Game
|
Game pause/resume, foreground stack behavior, and coexistence of a paused Game
|
||||||
with VM-backed Shell apps are outside this contract and are tracked by the
|
with VM-backed Shell apps are defined by the foreground/lifecycle contract. The
|
||||||
foreground/lifecycle discussion.
|
render boundary's responsibility is narrower: foreground owner changes MUST
|
||||||
|
advance ownership, stale submissions MUST be discarded, and visual return to a
|
||||||
|
resumed Game MUST wait for a valid submission from the current ownership epoch.
|
||||||
|
|
||||||
### 4.7 Render telemetry
|
### 4.7 Render telemetry
|
||||||
|
|
||||||
|
|||||||
@ -107,3 +107,21 @@ Input access is VM-owned and should not be reported as host syscall consumption.
|
|||||||
All platforms must provide the mandatory input elements (`pad`, `touch`, `button`) to the runtime.
|
All platforms must provide the mandatory input elements (`pad`, `touch`, `button`) to the runtime.
|
||||||
|
|
||||||
Platform differences in physical device mapping are resolved outside VM semantics.
|
Platform differences in physical device mapping are resolved outside VM semantics.
|
||||||
|
|
||||||
|
## 9 Host/System Controls
|
||||||
|
|
||||||
|
Host/System controls are not part of the guest-visible input surface.
|
||||||
|
|
||||||
|
The Home/SystemOS request changes machine foreground authority. It must be
|
||||||
|
handled by the host, firmware, or SystemOS before input is exposed to the Game.
|
||||||
|
It must not be represented as `pad.start`, `pad.select`, a new pad field, a VM
|
||||||
|
intrinsic, or a userland syscall.
|
||||||
|
|
||||||
|
For the desktop host, `Esc` is the primary keyboard mapping for the
|
||||||
|
Home/SystemOS request. The physical `Home` key may be supported as an alias.
|
||||||
|
Both mappings are host controls and must not mutate `InputSignals`.
|
||||||
|
|
||||||
|
When a Game leaves or re-enters foreground, pending Game input must be cleared
|
||||||
|
or barriered so held or pressed input cannot leak across pause/resume
|
||||||
|
boundaries. This barrier applies to the Game-facing snapshot; it does not
|
||||||
|
change the meaning of host/system Home controls.
|
||||||
|
|||||||
@ -100,7 +100,70 @@ Important properties:
|
|||||||
- no execution occurs outside the frame loop;
|
- no execution occurs outside the frame loop;
|
||||||
- frame structure remains observable for host tooling and host-owned certification.
|
- frame structure remains observable for host tooling and host-owned certification.
|
||||||
|
|
||||||
## 7 Render Worker Concurrency
|
## 7 Foreground Lifecycle Events
|
||||||
|
|
||||||
|
Foreground lifecycle events are system events with bounded delivery points.
|
||||||
|
They do not execute arbitrary guest callbacks at arrival time.
|
||||||
|
|
||||||
|
The Game pause path is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Home/SystemOS request
|
||||||
|
-> pause event becomes visible to the Game
|
||||||
|
-> bounded pause reaction budget
|
||||||
|
-> SystemOS suspension, even if the Game did not cooperate
|
||||||
|
```
|
||||||
|
|
||||||
|
The Game resume path is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SystemOS resume request
|
||||||
|
-> VM reactivation
|
||||||
|
-> resume or foreground-restore event becomes visible to the Game
|
||||||
|
-> Game may synchronize while still internally paused
|
||||||
|
-> visual foreground returns after a valid current-epoch render submission
|
||||||
|
```
|
||||||
|
|
||||||
|
`Paused` is the Game-visible event/state. `Suspended` is the OS-owned
|
||||||
|
scheduler/runtime state. A Game may observe pause/resume, but it must not
|
||||||
|
control whether SystemOS suspends or resumes the VM.
|
||||||
|
|
||||||
|
Pause budgets are measured in deterministic machine units, not host wall-clock
|
||||||
|
time. A suspended Game receives no normal gameplay ticks, no normal Game input,
|
||||||
|
and no frame pacing. Real background execution is outside the v1 foreground
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## 8 VM Sessions and Execution Eligibility
|
||||||
|
|
||||||
|
Every VM-backed process MUST own a distinct VM session. A VM session owns the
|
||||||
|
mutable execution context for that process, including the VM instance,
|
||||||
|
per-session runtime counters, lifecycle delivery bookkeeping, debug/pause state,
|
||||||
|
and transient handles opened through VM-facing services.
|
||||||
|
|
||||||
|
Foreground ownership and execution eligibility are related but separate
|
||||||
|
concepts:
|
||||||
|
|
||||||
|
- foreground ownership selects the visual/navigation owner;
|
||||||
|
- execution eligibility selects which sessions may receive VM ticks;
|
||||||
|
- v1 grants normal VM execution only to the foreground VM-backed task, plus the
|
||||||
|
bounded Game pause handoff tick defined above;
|
||||||
|
- a suspended resident Game preserves its VM session but MUST NOT receive
|
||||||
|
normal gameplay ticks, normal Game input, or frame pacing;
|
||||||
|
- a VM-backed Shell owns its own VM session and MUST NOT overwrite or advance
|
||||||
|
the resident Game session while the Shell is foreground;
|
||||||
|
- a native Shell does not own a VM session.
|
||||||
|
|
||||||
|
The model is intentionally compatible with future background execution. Future
|
||||||
|
service or media processes may become execution-eligible without changing the
|
||||||
|
rule that mutable VM context belongs to the owning session. This chapter does
|
||||||
|
not grant any v1 background progress guarantee.
|
||||||
|
|
||||||
|
Durable app data remains keyed by `app_id` where the relevant storage contract
|
||||||
|
requires it. Transient VM handles, open files, lifecycle delivery queues, and
|
||||||
|
staging state are session-scoped unless another spec explicitly makes them
|
||||||
|
durable app data.
|
||||||
|
|
||||||
|
## 9 Render Worker Concurrency
|
||||||
|
|
||||||
The render worker is not a machine-visible event source and does not introduce
|
The render worker is not a machine-visible event source and does not introduce
|
||||||
guest callbacks. It is an implementation-side consumer of closed render
|
guest callbacks. It is an implementation-side consumer of closed render
|
||||||
@ -127,7 +190,7 @@ Shutdown is explicit and bounded. A shutdown request wakes a waiting worker,
|
|||||||
causes pending work that will not be consumed to be discarded, and reports a
|
causes pending work that will not be consumed to be discarded, and reports a
|
||||||
typed failure if the worker cannot join within the configured timeout.
|
typed failure if the worker cannot join within the configured timeout.
|
||||||
|
|
||||||
## 8 Async Asset and IO Work Lane
|
## 10 Async Asset and IO Work Lane
|
||||||
|
|
||||||
The asset/IO async work lane is not a machine-visible event source and does not
|
The asset/IO async work lane is not a machine-visible event source and does not
|
||||||
introduce guest callbacks. It is an implementation-side lane for asset
|
introduce guest callbacks. It is an implementation-side lane for asset
|
||||||
@ -151,7 +214,7 @@ does not publish resident graphics/audio/scene state directly.
|
|||||||
FS and game persistence services may consume this lane for IO-style work, but
|
FS and game persistence services may consume this lane for IO-style work, but
|
||||||
public FS API shape is defined by the FS/app-home contract, not by this chapter.
|
public FS API shape is defined by the FS/app-home contract, not by this chapter.
|
||||||
|
|
||||||
## 9 Determinism and Best Practices
|
## 11 Determinism and Best Practices
|
||||||
|
|
||||||
PROMETEU encourages:
|
PROMETEU encourages:
|
||||||
|
|
||||||
@ -166,7 +229,7 @@ PROMETEU discourages:
|
|||||||
- hidden timing channels;
|
- hidden timing channels;
|
||||||
- ambiguous out-of-band execution.
|
- ambiguous out-of-band execution.
|
||||||
|
|
||||||
## 10 Relationship to Other Specs
|
## 12 Relationship to Other Specs
|
||||||
|
|
||||||
- [`09a-coroutines-and-cooperative-scheduling.md`](09a-coroutines-and-cooperative-scheduling.md) defines coroutine lifecycle and scheduling behavior.
|
- [`09a-coroutines-and-cooperative-scheduling.md`](09a-coroutines-and-cooperative-scheduling.md) defines coroutine lifecycle and scheduling behavior.
|
||||||
- [`10-debug-inspection-and-profiling.md`](10-debug-inspection-and-profiling.md) defines observability and diagnostics surfaces.
|
- [`10-debug-inspection-and-profiling.md`](10-debug-inspection-and-profiling.md) defines observability and diagnostics surfaces.
|
||||||
|
|||||||
@ -43,13 +43,23 @@ The VM does not own the machine lifecycle. Firmware does.
|
|||||||
Game directory cartridges.
|
Game directory cartridges.
|
||||||
- **Library entry**: an internal catalog record retained by Hub/SystemOS for a
|
- **Library entry**: an internal catalog record retained by Hub/SystemOS for a
|
||||||
discovered cartridge.
|
discovered cartridge.
|
||||||
|
- **Foreground visual owner**: the single machine owner whose visual output may
|
||||||
|
be presented at a given time.
|
||||||
|
- **Paused**: a Game-visible lifecycle event/state. The Game may observe it and
|
||||||
|
prepare for foreground loss.
|
||||||
|
- **Suspended**: an OS-owned scheduler/runtime state. A suspended Game remains
|
||||||
|
resident but does not receive normal gameplay ticks, normal Game input, or
|
||||||
|
frame pacing.
|
||||||
|
- **VM session**: the POS-owned mutable execution context for one VM-backed
|
||||||
|
process. It contains the VM instance and per-session runtime state.
|
||||||
|
|
||||||
## 3 POS Responsibilities
|
## 3 POS Responsibilities
|
||||||
|
|
||||||
POS is responsible for:
|
POS is responsible for:
|
||||||
|
|
||||||
- deterministic reset into known machine state;
|
- deterministic reset into known machine state;
|
||||||
- VM initialization and teardown for cartridge execution;
|
- VM session creation, initialization, activation, and teardown for cartridge
|
||||||
|
execution;
|
||||||
- input latching and logical-frame budgeting;
|
- input latching and logical-frame budgeting;
|
||||||
- peripheral reset/orchestration;
|
- peripheral reset/orchestration;
|
||||||
- fault capture and crash-flow transition;
|
- fault capture and crash-flow transition;
|
||||||
@ -61,6 +71,10 @@ At the VM boundary, POS must preserve:
|
|||||||
- `FRAME_SYNC` as the canonical frame boundary;
|
- `FRAME_SYNC` as the canonical frame boundary;
|
||||||
- deterministic transition from host tick to VM slice execution.
|
- deterministic transition from host tick to VM slice execution.
|
||||||
|
|
||||||
|
Firmware must not treat a single global VM object as the canonical execution
|
||||||
|
owner for all cartridges. The firmware state machine orchestrates macro-state;
|
||||||
|
POS/session services own VM-backed process context.
|
||||||
|
|
||||||
## 4 PrometeuHub Responsibilities
|
## 4 PrometeuHub Responsibilities
|
||||||
|
|
||||||
PrometeuHub is responsible for:
|
PrometeuHub is responsible for:
|
||||||
@ -154,19 +168,74 @@ Shell task loses its eligible focused window
|
|||||||
-> FirmwareState::HubHome
|
-> FirmwareState::HubHome
|
||||||
```
|
```
|
||||||
|
|
||||||
## 7 Cartridge Load Flow
|
## 7 Foreground Stack and Game Pause
|
||||||
|
|
||||||
|
The v1 foreground contract is single-owner:
|
||||||
|
|
||||||
|
- exactly one foreground visual owner exists at a time;
|
||||||
|
- at most one Game may be resident;
|
||||||
|
- at most one Shell app may be foreground;
|
||||||
|
- Hub/Home is the root Shell owner and back-stack controller.
|
||||||
|
|
||||||
|
The minimal supported navigation cycle is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Game foreground
|
||||||
|
-> Home request
|
||||||
|
-> Game pause notification
|
||||||
|
-> Game suspended by SystemOS after a bounded budget
|
||||||
|
-> Hub/Home foreground
|
||||||
|
-> optional Shell foreground
|
||||||
|
-> Shell close returns to Hub/Home
|
||||||
|
-> same Game resume/foreground-restore notification
|
||||||
|
-> Game foreground after a valid current-epoch render submission
|
||||||
|
```
|
||||||
|
|
||||||
|
`Paused` is cooperative and visible to the Game. `Suspended` is imposed by
|
||||||
|
SystemOS. The Game may react to pause/resume, but it must not control whether
|
||||||
|
the OS suspends or resumes its VM.
|
||||||
|
|
||||||
|
When Home is requested during Game execution, SystemOS must notify the Game and
|
||||||
|
grant a short bounded pause budget. After the budget, SystemOS may suspend the
|
||||||
|
VM even if the Game did not cooperate. While suspended, the Game must not
|
||||||
|
receive normal gameplay ticks, normal Game input, or frame pacing.
|
||||||
|
|
||||||
|
When returning to the same Game, SystemOS must reactivate the VM and send a
|
||||||
|
resume or foreground-restore notification before visual foreground is restored.
|
||||||
|
The Game may remain internally paused while it synchronizes. Hub/Shell remains
|
||||||
|
visible until the resumed Game publishes a valid render submission for the
|
||||||
|
current ownership epoch.
|
||||||
|
|
||||||
|
Input pending for the Game must be cleared or barriered across pause/resume
|
||||||
|
boundaries. Game audio pauses with the Game VM in v1. Independent background
|
||||||
|
audio, real background execution, multiple resident Games, multiple foreground
|
||||||
|
Shells, and direct Game-to-Game switching are outside this contract.
|
||||||
|
|
||||||
|
The resident Game VM session remains allocated while Hub or Shell is foreground.
|
||||||
|
It preserves Game VM state but is not execution-eligible except for the bounded
|
||||||
|
pause handoff and later foreground resume. A VM-backed Shell has a separate VM
|
||||||
|
session. A native Shell has no VM session.
|
||||||
|
|
||||||
|
## 8 Cartridge Load Flow
|
||||||
|
|
||||||
Current high-level flow:
|
Current high-level flow:
|
||||||
|
|
||||||
1. POS receives a cartridge to load;
|
1. POS receives a cartridge to load;
|
||||||
2. asset manager is initialized from the cartridge asset table, preload list, and packed asset bytes;
|
2. asset manager is initialized from the cartridge asset table, preload list, and packed asset bytes;
|
||||||
3. POS initializes the VM/runtime for the cartridge;
|
3. POS creates or selects the VM session for the cartridge;
|
||||||
4. launch behavior branches by `AppMode`:
|
4. POS initializes the session-owned VM/runtime for the cartridge;
|
||||||
|
5. launch behavior branches by `AppMode`:
|
||||||
- `Game` -> transition to the game-running firmware state;
|
- `Game` -> transition to the game-running firmware state;
|
||||||
- `System` -> create/focus a Hub window and return to the Hub state.
|
- `System` -> create/focus a Hub window and return to the Hub state.
|
||||||
|
|
||||||
If VM initialization fails, firmware transitions to the crash path.
|
If VM initialization fails, firmware transitions to the crash path.
|
||||||
|
|
||||||
|
For v1, POS must enforce at most one resident Game session. Selecting the same
|
||||||
|
resident Game from Home resumes that session. Selecting a different Game while a
|
||||||
|
resident Game exists is outside the v1 foreground contract and must not create a
|
||||||
|
second resident Game session. VM-backed Shell loading creates a distinct Shell
|
||||||
|
session and must not overwrite resident Game VM state.
|
||||||
|
|
||||||
POS selects the cartridge and its execution context, but the cartridge's initial callable is not chosen by firmware metadata. Execution starts from the cartridge boot protocol defined in [`13-cartridge.md`](13-cartridge.md), currently `func_id = 0`.
|
POS selects the cartridge and its execution context, but the cartridge's initial callable is not chosen by firmware metadata. Execution starts from the cartridge boot protocol defined in [`13-cartridge.md`](13-cartridge.md), currently `func_id = 0`.
|
||||||
|
|
||||||
When Home launches a discovered Game entry, the request is a system-owned action
|
When Home launches a discovered Game entry, the request is a system-owned action
|
||||||
@ -179,7 +248,7 @@ If the selected cartridge cannot be loaded, firmware must leave or return the
|
|||||||
machine in the Hub/Home flow and record the failure through diagnostics or
|
machine in the Hub/Home flow and record the failure through diagnostics or
|
||||||
logging. A polished Home error surface is not required by v1.
|
logging. A polished Home error surface is not required by v1.
|
||||||
|
|
||||||
## 8 Firmware States
|
## 9 Firmware States
|
||||||
|
|
||||||
The current firmware state model includes:
|
The current firmware state model includes:
|
||||||
|
|
||||||
@ -189,11 +258,12 @@ The current firmware state model includes:
|
|||||||
- `HubHome`
|
- `HubHome`
|
||||||
- `LoadCartridge`
|
- `LoadCartridge`
|
||||||
- `GameRunning`
|
- `GameRunning`
|
||||||
|
- `ShellRunning`
|
||||||
- `AppCrashes`
|
- `AppCrashes`
|
||||||
|
|
||||||
These states express machine orchestration above the VM. They are not guest-visible bytecode states.
|
These states express machine orchestration above the VM. They are not guest-visible bytecode states.
|
||||||
|
|
||||||
## 9 Execution Contract
|
## 10 Execution Contract
|
||||||
|
|
||||||
For game-mode execution, firmware/runtime coordination preserves:
|
For game-mode execution, firmware/runtime coordination preserves:
|
||||||
|
|
||||||
@ -204,7 +274,7 @@ For game-mode execution, firmware/runtime coordination preserves:
|
|||||||
|
|
||||||
This keeps the machine model deterministic and observable.
|
This keeps the machine model deterministic and observable.
|
||||||
|
|
||||||
## 10 Crash Handling
|
## 11 Crash Handling
|
||||||
|
|
||||||
Firmware owns terminal fault presentation.
|
Firmware owns terminal fault presentation.
|
||||||
|
|
||||||
@ -216,7 +286,32 @@ When a terminal app fault occurs:
|
|||||||
|
|
||||||
Crash handling is outside the guest VM execution model.
|
Crash handling is outside the guest VM execution model.
|
||||||
|
|
||||||
## 11 Relationship to Other Specs
|
## 12 Desktop Smoke Validation
|
||||||
|
|
||||||
|
The foreground contract can be smoke-tested on the desktop host with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo run --release -p prometeu-host-desktop-winit -- --run test-cartridges/stress-console
|
||||||
|
cargo run --release -p prometeu-host-desktop-winit -- --games-root test-cartridges
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- direct `--run` still boots the selected Game without using a guest cartridge
|
||||||
|
switching API;
|
||||||
|
- `--games-root` starts in Home and launches Games through the Home library;
|
||||||
|
- while a Game is running, `Esc` opens Home/SystemOS without entering the guest
|
||||||
|
pad input surface;
|
||||||
|
- the Game is paused, then suspended after the bounded SystemOS budget;
|
||||||
|
- Home remains foreground while the Game is resident;
|
||||||
|
- launching and closing a Shell app returns to Home without losing the resident
|
||||||
|
Game;
|
||||||
|
- a VM-backed Shell advances its own VM session and does not tick the suspended
|
||||||
|
resident Game session;
|
||||||
|
- returning to the same Game resumes it through the foreground lifecycle and
|
||||||
|
waits for current-epoch Game rendering before visual Game presentation.
|
||||||
|
|
||||||
|
## 13 Relationship to Other Specs
|
||||||
|
|
||||||
- [`02-vm-instruction-set.md`](02-vm-instruction-set.md) defines the VM subsystem run by firmware.
|
- [`02-vm-instruction-set.md`](02-vm-instruction-set.md) defines the VM subsystem run by firmware.
|
||||||
- [`09-events-and-concurrency.md`](09-events-and-concurrency.md) defines the frame-boundary model used by firmware.
|
- [`09-events-and-concurrency.md`](09-events-and-concurrency.md) defines the frame-boundary model used by firmware.
|
||||||
|
|||||||
@ -50,6 +50,8 @@ For a cartridge boot target:
|
|||||||
- `Game` cartridges transition into the game-running pipeline;
|
- `Game` cartridges transition into the game-running pipeline;
|
||||||
- `System` cartridges transition into a Runtime/Hub pipeline dedicated to
|
- `System` cartridges transition into a Runtime/Hub pipeline dedicated to
|
||||||
system UI and app hosting.
|
system UI and app hosting.
|
||||||
|
- VM-backed cartridges are initialized in a POS-owned VM session associated with
|
||||||
|
the created task/process.
|
||||||
|
|
||||||
This preserves the distinction between machine firmware state and app execution mode.
|
This preserves the distinction between machine firmware state and app execution mode.
|
||||||
|
|
||||||
@ -99,6 +101,14 @@ Game-to-game switching after a game is already active is not part of this boot
|
|||||||
profile. That orchestration belongs to the foreground/home and cartridge switch
|
profile. That orchestration belongs to the foreground/home and cartridge switch
|
||||||
contracts.
|
contracts.
|
||||||
|
|
||||||
|
The foreground/home contract defines `Game -> Home/Shell -> same Game` after a
|
||||||
|
Game is already running. It does not change direct `--run`, debugger direct
|
||||||
|
boot, or `--games-root` Home library startup semantics. Home/SystemOS requests
|
||||||
|
are host/system controls, not guest cartridge boot APIs.
|
||||||
|
|
||||||
|
Direct boot creates the initial VM session for the selected cartridge. It does
|
||||||
|
not create a reusable global VM shared by later Game or Shell cartridges.
|
||||||
|
|
||||||
## 5 Firmware State Relationship
|
## 5 Firmware State Relationship
|
||||||
|
|
||||||
Boot target selection feeds into firmware states such as:
|
Boot target selection feeds into firmware states such as:
|
||||||
|
|||||||
@ -187,6 +187,11 @@ The game memcard profile uses module `mem` with status-first return shapes.
|
|||||||
The `mem` module is a game profile surface. It is not the public storage model
|
The `mem` module is a game profile surface. It is not the public storage model
|
||||||
for `System` profile apps.
|
for `System` profile apps.
|
||||||
|
|
||||||
|
Durable memcard data is keyed by `app_id` according to the save-memory
|
||||||
|
contract. Runtime handles, open file tables, pending lifecycle delivery, and
|
||||||
|
other mutable VM-facing syscall context are scoped to the owning VM session and
|
||||||
|
MUST NOT be used as durable app identity.
|
||||||
|
|
||||||
Canonical operations in v1 are:
|
Canonical operations in v1 are:
|
||||||
|
|
||||||
- `mem.slot_count() -> (status, count)`
|
- `mem.slot_count() -> (status, count)`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user