Merge pull request 'dev/render-worker-establishment' (#32) from dev/real-render-worker-establishment into master
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good

Reviewed-on: #32
This commit is contained in:
bquarkz 2026-06-20 16:11:07 +00:00
commit 443b1d7cfb
78 changed files with 4892 additions and 2220 deletions

View File

@ -8,8 +8,8 @@ use prometeu_hal::scene_viewport_resolver::{
};
use prometeu_hal::sprite::Sprite;
use prometeu_hal::{
BoundScenePacket, Camera2D, ComposerFramePacket, Game2DFramePacket, GameSpritePacket,
GfxBridge, HudPacket,
BoundScenePacket, Camera2D, ComposerFramePacket, ComposerOpStatus, Game2DFrameComposer,
Game2DFramePacket, GameSpritePacket, GfxBridge, HudPacket,
};
use std::sync::Arc;
@ -327,6 +327,55 @@ impl FrameComposer {
gfx.render_no_scene_frame();
}
pub fn render_game2d_frame_packet(
&mut self,
packet: &Game2DFramePacket,
gfx: &mut dyn GfxBridge,
) {
let sprites: Vec<Sprite> =
packet.composer.sprites.iter().map(Self::sprite_from_packet).collect();
gfx.load_frame_sprites(&sprites);
let Some(bound_scene) = packet.composer.bound_scene else {
gfx.render_no_scene_frame();
return;
};
let scene_bank_id = bound_scene.bank_id as usize;
if self.active_scene_id != Some(scene_bank_id) && !self.bind_scene(scene_bank_id) {
gfx.render_no_scene_frame();
return;
}
self.camera_x_px = packet.composer.camera.x;
self.camera_y_px = packet.composer.camera.y;
let scene =
self.active_scene.as_deref().expect("active scene should exist after packet bind");
let resolved_glyph_slots = self.resolve_glyph_slots(scene, scene_bank_id, "render_packet");
let (Some(cache), Some(resolver)) = (self.cache.as_mut(), self.resolver.as_mut()) else {
panic!("SCENE runtime invariant broken: packet scene without cache/resolver");
};
let update = resolver.update(scene, self.camera_x_px, self.camera_y_px);
Self::sync_cache_windows(cache, &update);
Self::apply_refresh_requests(cache, scene, &update.refresh_requests);
gfx.render_scene_from_cache(cache, &update, &resolved_glyph_slots);
}
fn sprite_from_packet(sprite: &GameSpritePacket) -> Sprite {
Sprite {
glyph: Glyph { glyph_id: sprite.glyph_id as u16, palette_id: sprite.palette_id as u8 },
x: sprite.x,
y: sprite.y,
layer: sprite.layer as u8,
bank_id: sprite.bank_id as u8,
active: true,
flip_x: sprite.flip_x,
flip_y: sprite.flip_y,
priority: sprite.priority as u8,
}
}
fn build_scene_runtime(
viewport_width_px: usize,
viewport_height_px: usize,
@ -403,6 +452,41 @@ impl FrameComposer {
}
}
impl Game2DFrameComposer for FrameComposer {
fn begin_frame(&mut self) {
FrameComposer::begin_frame(self);
}
fn bind_scene(&mut self, scene_bank_id: usize) -> ComposerOpStatus {
if FrameComposer::bind_scene(self, scene_bank_id) {
ComposerOpStatus::Ok
} else {
ComposerOpStatus::SceneUnavailable
}
}
fn unbind_scene(&mut self) {
FrameComposer::unbind_scene(self);
}
fn set_camera(&mut self, x: i32, y: i32) -> ComposerOpStatus {
FrameComposer::set_camera(self, x, y);
ComposerOpStatus::Ok
}
fn emit_sprite(&mut self, sprite: Sprite) -> ComposerOpStatus {
if FrameComposer::emit_sprite(self, sprite) {
ComposerOpStatus::Ok
} else {
ComposerOpStatus::SpriteOverflow
}
}
fn close_game2d_packet(&self) -> Game2DFramePacket {
FrameComposer::close_game2d_packet(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -847,6 +931,45 @@ mod tests {
assert!(packet.gfx2d.is_empty());
}
#[test]
fn game2d_frame_composer_facade_closes_equivalent_packet() {
let mut frame_composer = FrameComposer::new(
Hardware::W,
Hardware::H,
Arc::new(MemoryBanks::default()),
make_glyph_slot_index(&[]),
);
let composer: &mut dyn Game2DFrameComposer = &mut frame_composer;
assert_eq!(composer.set_camera(10, 20), ComposerOpStatus::Ok);
composer.begin_frame();
assert_eq!(
composer.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 7, palette_id: 3 },
x: 4,
y: 5,
layer: 2,
bank_id: 1,
active: false,
flip_x: true,
flip_y: false,
priority: 9,
}),
ComposerOpStatus::Ok
);
let packet = composer.close_game2d_packet();
assert_eq!(packet.composer.bound_scene, None);
assert_eq!(packet.composer.camera, Camera2D { x: 10, y: 20 });
assert_eq!(packet.composer.sprites.len(), 1);
assert_eq!(packet.composer.sprites[0].glyph_id, 7);
assert_eq!(packet.composer.sprites[0].palette_id, 3);
assert_eq!(packet.composer.sprites[0].layer, 2);
assert!(packet.composer.sprites[0].flip_x);
assert!(packet.gfx2d.is_empty());
}
#[test]
fn composer_buffer_preserves_integer_layer_ordering() {
let mut frame_composer = FrameComposer::new(

View File

@ -661,9 +661,7 @@ impl Gfx {
self.load_frame_sprites(&sprites);
self.render_no_scene_frame();
for command in &packet.gfx2d {
self.apply_gfx2d_command(command);
}
self.apply_gfx2d_commands(&packet.gfx2d);
}
pub fn render_shell_ui_frame_packet(&mut self, packet: &ShellUiFramePacket) {
@ -672,6 +670,12 @@ impl Gfx {
}
}
pub fn apply_gfx2d_commands(&mut self, commands: &[Gfx2dCommand]) {
for command in commands {
self.apply_gfx2d_command(command);
}
}
fn apply_gfx2d_command(&mut self, command: &Gfx2dCommand) {
match command {
Gfx2dCommand::Clear { color } => self.clear(*color),

View File

@ -10,8 +10,14 @@ use crate::pad::Pad;
use crate::touch::Touch;
use prometeu_hal::cartridge::AssetsPayloadSource;
use prometeu_hal::sprite::Sprite;
use prometeu_hal::{AssetBridge, AudioBridge, GfxBridge, HardwareBridge, PadBridge, TouchBridge};
use prometeu_hal::{Game2DFramePacket, RenderSubmission, RenderSubmissionPacket};
use prometeu_hal::{
AssetBridge, AudioBridge, InputPlatform, NoopTelemetryPlatform, PadBridge, RenderBackend,
RuntimePlatform, TelemetryPlatform, TouchBridge,
};
use prometeu_hal::{
Game2DFrameComposer, Game2DFramePacket, RenderSubmission, RenderSubmissionPacket,
};
use prometeu_hal::{RenderSubmissionSink, RenderSubmitError};
use std::sync::Arc;
/// Aggregate structure for all virtual hardware peripherals.
@ -27,6 +33,8 @@ use std::sync::Arc;
/// - **Audio**: Stereo, Command-based mixing.
/// - **Input**: 12-button Digital Gamepad + Absolute Touch/Mouse.
pub struct Hardware {
/// Shared physical memory bank ownership used by peripherals and worker backends.
pub memory_banks: Arc<MemoryBanks>,
/// The Graphics Processing Unit (GPU). Handles drawing primitives, sprites, and tilemaps.
pub gfx: Gfx,
/// Canonical frame orchestration owner for scene/camera/cache/resolver/sprites.
@ -47,38 +55,15 @@ impl Default for Hardware {
}
}
impl HardwareBridge for Hardware {
fn begin_frame(&mut self) {
self.frame_composer.begin_frame();
}
fn bind_scene(&mut self, scene_bank_id: usize) -> bool {
self.frame_composer.bind_scene(scene_bank_id)
}
fn unbind_scene(&mut self) {
self.frame_composer.unbind_scene();
}
fn set_camera(&mut self, x: i32, y: i32) {
self.frame_composer.set_camera(x, y);
}
fn emit_sprite(&mut self, sprite: Sprite) -> bool {
self.frame_composer.emit_sprite(sprite)
}
fn close_game2d_packet(&self) -> Game2DFramePacket {
self.frame_composer.close_game2d_packet()
}
fn publish_render_submission(&mut self, submission: &RenderSubmission) {
impl Hardware {
fn consume_render_submission(&mut self, submission: &RenderSubmission) {
match &submission.packet {
RenderSubmissionPacket::Game2D(packet) => {
if self.frame_composer.active_scene_id().is_none() {
if packet.composer.bound_scene.is_none() {
self.gfx.render_game2d_frame_packet(packet);
} else {
self.frame_composer.render_frame(&mut self.gfx);
self.frame_composer.render_game2d_frame_packet(packet, &mut self.gfx);
self.gfx.apply_gfx2d_commands(&packet.gfx2d);
}
}
RenderSubmissionPacket::ShellUi(packet) => {
@ -87,7 +72,19 @@ impl HardwareBridge for Hardware {
}
self.gfx.present();
}
}
impl RenderSubmissionSink for Hardware {
fn submit_render_submission(
&mut self,
submission: RenderSubmission,
) -> Result<(), RenderSubmitError> {
self.consume_render_submission(&submission);
Ok(())
}
}
impl RenderBackend for Hardware {
fn render_frame(&mut self) {
if self.frame_composer.active_scene_id().is_none() {
let packet = self.frame_composer.close_game2d_packet();
@ -96,28 +93,52 @@ impl HardwareBridge for Hardware {
self.frame_composer.render_frame(&mut self.gfx);
}
}
}
fn has_glyph_bank(&self, bank_id: usize) -> bool {
self.gfx.glyph_banks.glyph_bank_slot(bank_id).is_some()
impl Game2DFrameComposer for Hardware {
fn begin_frame(&mut self) {
self.frame_composer.begin_frame();
}
fn gfx(&self) -> &dyn GfxBridge {
&self.gfx
}
fn gfx_mut(&mut self) -> &mut dyn GfxBridge {
&mut self.gfx
fn bind_scene(&mut self, scene_bank_id: usize) -> prometeu_hal::ComposerOpStatus {
if self.frame_composer.bind_scene(scene_bank_id) {
prometeu_hal::ComposerOpStatus::Ok
} else {
prometeu_hal::ComposerOpStatus::SceneUnavailable
}
}
fn audio(&self) -> &dyn AudioBridge {
&self.audio
}
fn audio_mut(&mut self) -> &mut dyn AudioBridge {
&mut self.audio
fn unbind_scene(&mut self) {
self.frame_composer.unbind_scene();
}
fn set_camera(&mut self, x: i32, y: i32) -> prometeu_hal::ComposerOpStatus {
self.frame_composer.set_camera(x, y);
prometeu_hal::ComposerOpStatus::Ok
}
fn emit_sprite(&mut self, sprite: Sprite) -> prometeu_hal::ComposerOpStatus {
if self.gfx.glyph_banks.glyph_bank_slot(sprite.bank_id as usize).is_none() {
return prometeu_hal::ComposerOpStatus::BankInvalid;
}
if self.frame_composer.emit_sprite(sprite) {
prometeu_hal::ComposerOpStatus::Ok
} else {
prometeu_hal::ComposerOpStatus::SpriteOverflow
}
}
fn close_game2d_packet(&self) -> Game2DFramePacket {
self.frame_composer.close_game2d_packet()
}
}
impl InputPlatform for Hardware {
fn pad(&self) -> &dyn PadBridge {
&self.pad
}
fn pad_mut(&mut self) -> &mut dyn PadBridge {
&mut self.pad
}
@ -125,16 +146,54 @@ impl HardwareBridge for Hardware {
fn touch(&self) -> &dyn TouchBridge {
&self.touch
}
fn touch_mut(&mut self) -> &mut dyn TouchBridge {
&mut self.touch
}
}
static NOOP_TELEMETRY: NoopTelemetryPlatform = NoopTelemetryPlatform;
impl RuntimePlatform for Hardware {
fn render_submission_sink(&mut self) -> &mut dyn RenderSubmissionSink {
self
}
fn render_backend(&mut self) -> &mut dyn RenderBackend {
self
}
fn game2d_frame_composer(&mut self) -> &mut dyn Game2DFrameComposer {
self
}
fn audio(&self) -> &dyn AudioBridge {
&self.audio
}
fn audio_mut(&mut self) -> &mut dyn AudioBridge {
&mut self.audio
}
fn input(&self) -> &dyn InputPlatform {
self
}
fn input_mut(&mut self) -> &mut dyn InputPlatform {
self
}
fn assets(&self) -> &dyn AssetBridge {
&self.assets
}
fn assets_mut(&mut self) -> &mut dyn AssetBridge {
&mut self.assets
}
fn telemetry(&self) -> &dyn TelemetryPlatform {
&NOOP_TELEMETRY
}
}
impl Hardware {
@ -159,6 +218,7 @@ impl Hardware {
);
let glyph_slot_index = assets.glyph_asset_slot_index();
Self {
memory_banks: Arc::clone(&memory_banks),
gfx: Gfx::new(
Self::W,
Self::H,
@ -187,12 +247,17 @@ mod tests {
use prometeu_hal::color::Color;
use prometeu_hal::glyph::Glyph;
use prometeu_hal::glyph_bank::{GlyphBank, TileSize};
use prometeu_hal::primitives::Rect;
use prometeu_hal::scene_bank::SceneBank;
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::scene_viewport_cache::SceneViewportCache;
use prometeu_hal::scene_viewport_resolver::SceneViewportResolver;
use prometeu_hal::tile::Tile;
use prometeu_hal::tilemap::TileMap;
use prometeu_hal::{
BoundScenePacket, Camera2D, ComposerFramePacket, FrameId, GameSpritePacket, Gfx2dCommand,
GfxUiCommand, ShellUiFramePacket,
};
fn make_glyph_bank() -> GlyphBank {
let mut bank = GlyphBank::new(TileSize::Size8, 8, 8);
@ -204,9 +269,13 @@ mod tests {
}
fn make_scene() -> SceneBank {
make_scene_with_glyph_asset_id(1)
}
fn make_scene_with_glyph_asset_id(glyph_asset_id: i32) -> SceneBank {
let layer = SceneLayer {
active: true,
glyph_asset_id: 1,
glyph_asset_id,
tile_size: TileSize::Size8,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap {
@ -231,7 +300,7 @@ mod tests {
fn hardware_can_render_scene_from_shared_scene_bank_pipeline() {
let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(0, Arc::new(make_glyph_bank()));
banks.install_scene_bank(0, Arc::new(make_scene()));
banks.install_scene_bank(0, Arc::new(make_scene_with_glyph_asset_id(0)));
let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks));
let scene = banks.scene_bank_slot(0).expect("scene bank slot 0 should be resident");
@ -262,4 +331,106 @@ mod tests {
assert_eq!(hardware.frame_composer.scene_bank_slot_count(), 16);
assert_eq!(scene.layers[0].tile_size, TileSize::Size8);
}
#[test]
fn publish_game2d_submission_applies_gfx2d_overlay_after_active_scene() {
let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(0, Arc::new(make_glyph_bank()));
banks.install_scene_bank(0, Arc::new(make_scene_with_glyph_asset_id(0)));
let mut hardware = Hardware::new_with_memory_banks(banks);
let mut glyph_slots = [None; 16];
glyph_slots[0] = Some(0);
hardware.assets.glyph_asset_slot_index().rebuild_from_slots(&glyph_slots);
assert_eq!(
Game2DFrameComposer::bind_scene(&mut hardware, 0),
prometeu_hal::ComposerOpStatus::Ok
);
let mut packet = Game2DFrameComposer::close_game2d_packet(&hardware);
packet.gfx2d = vec![Gfx2dCommand::FillRect {
rect: Rect { x: 0, y: 0, w: 1, h: 1 },
color: Color::BLUE,
}];
RenderSubmissionSink::submit_render_submission(
&mut hardware,
RenderSubmission::game2d(FrameId::ZERO, packet),
)
.expect("local sink should publish Game2D packet");
assert_eq!(hardware.gfx.front_buffer()[0], Color::BLUE.raw());
}
#[test]
fn publish_game2d_submission_uses_packet_scene_and_sprites_not_live_frame_state() {
let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(0, Arc::new(make_glyph_bank()));
banks.install_scene_bank(0, Arc::new(make_scene_with_glyph_asset_id(0)));
let mut hardware = Hardware::new_with_memory_banks(banks);
let mut glyph_slots = [None; 16];
glyph_slots[0] = Some(0);
hardware.assets.glyph_asset_slot_index().rebuild_from_slots(&glyph_slots);
assert_eq!(
Game2DFrameComposer::bind_scene(&mut hardware, 0),
prometeu_hal::ComposerOpStatus::Ok
);
let packet = Game2DFramePacket::new(
ComposerFramePacket {
bound_scene: Some(BoundScenePacket { bank_id: 0 }),
camera: Camera2D::default(),
sprites: vec![GameSpritePacket {
glyph_id: 0,
palette_id: 0,
x: 0,
y: 0,
layer: 0,
bank_id: 0,
flip_x: false,
flip_y: false,
priority: 0,
}],
hud: Default::default(),
},
Vec::new(),
);
Game2DFrameComposer::begin_frame(&mut hardware);
RenderSubmissionSink::submit_render_submission(
&mut hardware,
RenderSubmission::game2d(FrameId::ZERO, packet),
)
.expect("local sink should publish Game2D packet");
assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw());
}
#[test]
fn render_submission_sink_accepts_owned_game2d_submission() {
let mut hardware = Hardware::new();
let packet = Game2DFramePacket::new(
ComposerFramePacket::default(),
vec![Gfx2dCommand::Clear { color: Color::BLUE }],
);
let submission = RenderSubmission::game2d(FrameId::ZERO, packet);
RenderSubmissionSink::submit_render_submission(&mut hardware, submission)
.expect("local sink should accept owned Game2D submissions");
assert_eq!(hardware.gfx.front_buffer()[0], Color::BLUE.raw());
}
#[test]
fn render_submission_sink_accepts_owned_shell_ui_submission() {
let mut hardware = Hardware::new();
let packet = ShellUiFramePacket::new(vec![GfxUiCommand::Clear { color: Color::RED }]);
let submission = RenderSubmission::shell_ui(FrameId::ZERO, packet);
RenderSubmissionSink::submit_render_submission(&mut hardware, submission)
.expect("local sink should accept owned ShellUi submissions");
assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw());
}
}

View File

@ -3,16 +3,20 @@ mod audio;
mod frame_composer;
mod gfx;
pub mod hardware;
mod local_render_backend;
mod memory_banks;
mod pad;
mod test_platform;
mod touch;
pub use crate::asset::AssetManager;
pub use crate::audio::{Audio, AudioCommand, Channel, MAX_CHANNELS, OUTPUT_SAMPLE_RATE};
pub use crate::frame_composer::{ComposerBuffer, FrameComposer, SceneStatus, SpriteController};
pub use crate::gfx::Gfx;
pub use crate::local_render_backend::LocalFramebufferRenderBackend;
pub use crate::memory_banks::{
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess,
SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller,
};
pub use crate::pad::Pad;
pub use crate::test_platform::TestPlatform;

View File

@ -0,0 +1,164 @@
use crate::asset::GlyphAssetSlotIndex;
use crate::frame_composer::FrameComposer;
use crate::gfx::Gfx;
use crate::memory_banks::{GlyphBankPoolAccess, MemoryBanks, SceneBankPoolAccess};
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::{
OwnedRgba8888Frame, RenderSubmission, RenderSubmissionPacket, RenderWorkerBackend,
RenderWorkerError,
};
use std::sync::{Arc, Mutex};
pub struct LocalFramebufferRenderBackend {
gfx: Mutex<Gfx>,
frame_composer: Mutex<FrameComposer>,
}
impl LocalFramebufferRenderBackend {
pub fn new(width: usize, height: usize, glyph_banks: Arc<dyn GlyphBankPoolAccess>) -> Self {
Self::new_with_parts(
width,
height,
glyph_banks,
Arc::new(MemoryBanks::new()),
GlyphAssetSlotIndex::new(),
)
}
pub fn new_with_memory_banks(
width: usize,
height: usize,
memory_banks: Arc<MemoryBanks>,
glyph_slot_index: GlyphAssetSlotIndex,
) -> Self {
Self::new_with_parts(
width,
height,
Arc::clone(&memory_banks) as Arc<dyn GlyphBankPoolAccess>,
memory_banks,
glyph_slot_index,
)
}
fn new_with_parts(
width: usize,
height: usize,
glyph_banks: Arc<dyn GlyphBankPoolAccess>,
scene_banks: Arc<dyn SceneBankPoolAccess>,
glyph_slot_index: GlyphAssetSlotIndex,
) -> Self {
Self {
gfx: Mutex::new(Gfx::new(width, height, glyph_banks)),
frame_composer: Mutex::new(FrameComposer::new(
width,
height,
scene_banks,
glyph_slot_index,
)),
}
}
pub fn render_submission(
&self,
submission: &RenderSubmission,
) -> Result<OwnedRgba8888Frame, RenderWorkerError> {
let mut gfx = self.gfx.lock().map_err(|_| RenderWorkerError::InternalFailure)?;
match &submission.packet {
RenderSubmissionPacket::Game2D(packet) if submission.app_mode == AppMode::Game => {
if packet.composer.bound_scene.is_none() {
gfx.render_game2d_frame_packet(packet);
} else {
let mut frame_composer = self
.frame_composer
.lock()
.map_err(|_| RenderWorkerError::InternalFailure)?;
frame_composer.render_game2d_frame_packet(packet, &mut *gfx);
gfx.apply_gfx2d_commands(&packet.gfx2d);
}
}
RenderSubmissionPacket::ShellUi(packet) if submission.app_mode == AppMode::Shell => {
gfx.render_shell_ui_frame_packet(packet);
}
_ => return Err(RenderWorkerError::RenderFailed),
}
gfx.present();
let (width, height) = gfx.size();
OwnedRgba8888Frame::packed(
submission.frame_id,
submission.ownership,
width,
height,
gfx.front_buffer().to_vec(),
)
.map_err(|_| RenderWorkerError::InternalFailure)
}
}
impl RenderWorkerBackend for LocalFramebufferRenderBackend {
fn render(
&self,
submission: &RenderSubmission,
) -> Result<OwnedRgba8888Frame, RenderWorkerError> {
self.render_submission(submission)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory_banks::MemoryBanks;
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::color::Color;
use prometeu_hal::primitives::Rect;
use prometeu_hal::{
FrameId, Game2DFramePacket, Gfx2dCommand, GfxUiCommand, RenderOwnership, ShellUiFramePacket,
};
fn backend() -> LocalFramebufferRenderBackend {
let banks = Arc::new(MemoryBanks::new());
LocalFramebufferRenderBackend::new(4, 4, banks)
}
#[test]
fn local_backend_renders_game2d_submission_to_owned_frame() {
let backend = backend();
let packet = Game2DFramePacket {
gfx2d: vec![Gfx2dCommand::FillRect {
rect: Rect { x: 1, y: 1, w: 2, h: 2 },
color: Color::RED,
}],
..Default::default()
};
let ownership = RenderOwnership::new(3, AppMode::Game, 9);
let submission =
RenderSubmission::game2d(FrameId::new(5), packet).with_ownership(ownership);
let frame = backend.render(&submission).expect("game frame should render");
assert_eq!(frame.frame_id, FrameId::new(5));
assert_eq!(frame.ownership, ownership);
assert_eq!(frame.width, 4);
assert_eq!(frame.height, 4);
assert_eq!(frame.pixels[1 + 4], Color::RED.raw());
assert_eq!(frame.pixels[2 + 2 * 4], Color::RED.raw());
}
#[test]
fn local_backend_renders_shell_ui_submission_to_owned_frame() {
let backend = backend();
let packet = ShellUiFramePacket::new(vec![GfxUiCommand::FillRect {
rect: Rect { x: 0, y: 0, w: 1, h: 1 },
color: Color::GREEN,
}]);
let ownership = RenderOwnership::new(4, AppMode::Shell, 10);
let submission =
RenderSubmission::shell_ui(FrameId::new(6), packet).with_ownership(ownership);
let frame = backend.render(&submission).expect("shell frame should render");
assert_eq!(frame.frame_id, FrameId::new(6));
assert_eq!(frame.ownership, ownership);
assert_eq!(frame.pixels[0], Color::GREEN.raw());
}
}

View File

@ -1,4 +1,13 @@
//! Memory bank access boundary.
//!
//! Render consumers must cross this boundary through small, read-only traits.
//! Frame packets carry resource IDs; large resident resources stay in banks and
//! are read in place through a narrow HAL callback contract. Installation and
//! slot replacement remain separate owner-side operations and are not part of
//! the render read surface.
use prometeu_hal::glyph_bank::GlyphBank;
use prometeu_hal::render_resources::RenderResourceAccess as HalRenderResourceAccess;
use prometeu_hal::scene_bank::SceneBank;
use prometeu_hal::sound_bank::SoundBank;
use std::sync::{Arc, RwLock};
@ -132,3 +141,90 @@ impl SceneBankPoolInstaller for MemoryBanks {
}
}
}
impl HalRenderResourceAccess for MemoryBanks {
fn with_glyph_bank<R>(&self, bank_id: usize, read: impl FnOnce(&GlyphBank) -> R) -> Option<R> {
let pool = self.glyph_bank_pool.read().unwrap();
let bank = pool.get(bank_id)?.as_deref()?;
Some(read(bank))
}
fn glyph_bank_count(&self) -> usize {
16
}
fn with_scene_bank<R>(&self, bank_id: usize, read: impl FnOnce(&SceneBank) -> R) -> Option<R> {
let pool = self.scene_bank_pool.read().unwrap();
let bank = pool.get(bank_id)?.as_deref()?;
Some(read(bank))
}
fn scene_bank_count(&self) -> usize {
16
}
}
#[cfg(test)]
mod tests {
use super::*;
use prometeu_hal::color::Color;
use prometeu_hal::glyph::Glyph;
use prometeu_hal::glyph_bank::TileSize;
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::tile::Tile;
use prometeu_hal::tilemap::TileMap;
fn assert_send_sync<T: Send + Sync>() {}
fn make_glyph_bank() -> GlyphBank {
let mut bank = GlyphBank::new(TileSize::Size8, 8, 8);
bank.palettes[0][1] = Color::WHITE;
bank
}
fn make_scene_bank() -> SceneBank {
let layer = SceneLayer {
active: true,
glyph_asset_id: 0,
tile_size: TileSize::Size8,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap {
width: 1,
height: 1,
tiles: vec![Tile {
active: true,
glyph: Glyph { glyph_id: 0, palette_id: 0 },
flip_x: false,
flip_y: false,
}],
},
};
SceneBank { layers: std::array::from_fn(|_| layer.clone()) }
}
#[test]
fn render_resource_access_traits_are_thread_safe() {
assert_send_sync::<MemoryBanks>();
assert_send_sync::<Arc<dyn GlyphBankPoolAccess>>();
assert_send_sync::<Arc<dyn SceneBankPoolAccess>>();
}
#[test]
fn render_resource_access_exposes_read_only_glyph_and_scene_banks_without_arc_contract() {
let banks = MemoryBanks::new();
banks.install_glyph_bank(0, Arc::new(make_glyph_bank()));
banks.install_scene_bank(1, Arc::new(make_scene_bank()));
let glyph_tile_size =
HalRenderResourceAccess::with_glyph_bank(&banks, 0, |bank| bank.tile_size);
let scene_tile_size =
HalRenderResourceAccess::with_scene_bank(&banks, 1, |bank| bank.layers[0].tile_size);
assert_eq!(glyph_tile_size, Some(TileSize::Size8));
assert_eq!(scene_tile_size, Some(TileSize::Size8));
assert_eq!(HalRenderResourceAccess::with_glyph_bank(&banks, 15, |_| ()), None);
assert_eq!(HalRenderResourceAccess::with_scene_bank(&banks, 15, |_| ()), None);
assert_eq!(HalRenderResourceAccess::glyph_bank_count(&banks), 16);
assert_eq!(HalRenderResourceAccess::scene_bank_count(&banks), 16);
}
}

View File

@ -0,0 +1,100 @@
use crate::hardware::Hardware;
use crate::memory_banks::MemoryBanks;
use prometeu_hal::{
AssetBridge, AudioBridge, Game2DFrameComposer, InputPlatform, RenderBackend,
RenderSubmissionSink, RuntimePlatform, TelemetryPlatform,
};
use std::sync::Arc;
pub struct TestPlatform {
hardware: Hardware,
}
impl Default for TestPlatform {
fn default() -> Self {
Self::new()
}
}
impl TestPlatform {
pub fn new() -> Self {
Self { hardware: Hardware::new() }
}
pub fn new_with_memory_banks(memory_banks: Arc<MemoryBanks>) -> Self {
Self { hardware: Hardware::new_with_memory_banks(memory_banks) }
}
pub fn local_hardware(&self) -> &Hardware {
&self.hardware
}
pub fn local_hardware_mut(&mut self) -> &mut Hardware {
&mut self.hardware
}
}
impl RuntimePlatform for TestPlatform {
fn render_submission_sink(&mut self) -> &mut dyn RenderSubmissionSink {
self.hardware.render_submission_sink()
}
fn render_backend(&mut self) -> &mut dyn RenderBackend {
self.hardware.render_backend()
}
fn game2d_frame_composer(&mut self) -> &mut dyn Game2DFrameComposer {
self.hardware.game2d_frame_composer()
}
fn audio(&self) -> &dyn AudioBridge {
self.hardware.audio()
}
fn audio_mut(&mut self) -> &mut dyn AudioBridge {
self.hardware.audio_mut()
}
fn input(&self) -> &dyn InputPlatform {
self.hardware.input()
}
fn input_mut(&mut self) -> &mut dyn InputPlatform {
self.hardware.input_mut()
}
fn assets(&self) -> &dyn AssetBridge {
self.hardware.assets()
}
fn assets_mut(&mut self) -> &mut dyn AssetBridge {
self.hardware.assets_mut()
}
fn telemetry(&self) -> &dyn TelemetryPlatform {
self.hardware.telemetry()
}
}
#[cfg(test)]
mod tests {
use super::*;
use prometeu_hal::{FrameId, Game2DFramePacket, RenderSubmission};
#[test]
fn test_platform_initializes_required_facades() {
let mut platform = TestPlatform::new();
platform.game2d_frame_composer().begin_frame();
platform
.render_submission_sink()
.submit_render_submission(RenderSubmission::game2d(
FrameId::ZERO,
Game2DFramePacket::default(),
))
.expect("test platform render sink should accept local submission");
assert!(!platform.audio().is_playing(0));
assert!(!platform.input().pad().any());
assert!(platform.telemetry().telemetry_snapshot().is_none());
}
}

View File

@ -3,7 +3,7 @@ use crate::firmware::firmware_state::{FirmwareState, LoadCartridgeStep, ResetSte
use crate::firmware::prometeu_context::PrometeuContext;
use prometeu_hal::cartridge::Cartridge;
use prometeu_hal::telemetry::CertificationConfig;
use prometeu_hal::{HardwareBridge, InputSignals};
use prometeu_hal::{InputSignals, RuntimePlatform};
use prometeu_system::{PrometeuHub, SystemOS};
use prometeu_vm::VirtualMachine;
@ -54,24 +54,24 @@ impl Firmware {
///
/// This method is called exactly once per Host frame (60Hz).
/// It updates peripheral signals and delegates the logic to the current state.
pub fn tick(&mut self, signals: &InputSignals, hw: &mut dyn HardwareBridge) {
pub fn tick(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
// 0. Process asset commits at the beginning of the frame boundary.
hw.assets_mut().apply_commits();
platform.assets_mut().apply_commits();
// 1. Update the peripheral state using the latest signals from the Host.
// This ensures input is consistent throughout the entire update.
hw.pad_mut().begin_frame(signals);
hw.touch_mut().begin_frame(signals);
platform.input_mut().pad_mut().begin_frame(signals);
platform.input_mut().touch_mut().begin_frame(signals);
// 2. State machine lifecycle management.
if !self.state_initialized {
self.on_enter(signals, hw);
self.on_enter(signals, platform);
self.state_initialized = true;
}
// 3. Update the current state and check for transitions.
if let Some(next_state) = self.on_update(signals, hw) {
self.change_state(next_state, signals, hw);
if let Some(next_state) = self.on_update(signals, platform) {
self.change_state(next_state, signals, platform);
}
}
@ -80,26 +80,26 @@ impl Firmware {
&mut self,
new_state: FirmwareState,
signals: &InputSignals,
hw: &mut dyn HardwareBridge,
platform: &mut dyn RuntimePlatform,
) {
self.on_exit(signals, hw);
self.on_exit(signals, platform);
self.state = new_state;
self.state_initialized = false;
// Enter the new state immediately to avoid "empty" frames during transitions.
self.on_enter(signals, hw);
self.on_enter(signals, platform);
self.state_initialized = true;
}
/// Dispatches the `on_enter` event to the current state implementation.
fn on_enter(&mut self, signals: &InputSignals, hw: &mut dyn HardwareBridge) {
fn on_enter(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
let mut req = PrometeuContext {
vm: &mut self.vm,
os: &mut self.os,
hub: &mut self.hub,
boot_target: &self.boot_target,
signals,
hw,
platform,
};
match &mut self.state {
FirmwareState::Reset(s) => s.on_enter(&mut req),
@ -118,7 +118,7 @@ impl Firmware {
fn on_update(
&mut self,
signals: &InputSignals,
hw: &mut dyn HardwareBridge,
platform: &mut dyn RuntimePlatform,
) -> Option<FirmwareState> {
let mut req = PrometeuContext {
vm: &mut self.vm,
@ -126,7 +126,7 @@ impl Firmware {
hub: &mut self.hub,
boot_target: &self.boot_target,
signals,
hw,
platform,
};
match &mut self.state {
FirmwareState::Reset(s) => s.on_update(&mut req),
@ -141,14 +141,14 @@ impl Firmware {
}
/// Dispatches the `on_exit` event to the current state implementation.
fn on_exit(&mut self, signals: &InputSignals, hw: &mut dyn HardwareBridge) {
fn on_exit(&mut self, signals: &InputSignals, platform: &mut dyn RuntimePlatform) {
let mut req = PrometeuContext {
vm: &mut self.vm,
os: &mut self.os,
hub: &mut self.hub,
boot_target: &self.boot_target,
signals,
hw,
platform,
};
match &mut self.state {
FirmwareState::Reset(s) => s.on_exit(&mut req),
@ -174,7 +174,7 @@ mod tests {
use crate::firmware::firmware_state::HubHomeStep;
use prometeu_bytecode::assembler::assemble;
use prometeu_bytecode::model::{BytecodeModule, FunctionMeta, SyscallDecl};
use prometeu_drivers::hardware::Hardware;
use prometeu_drivers::TestPlatform;
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::cartridge::AssetsPayloadSource;
use prometeu_hal::color::Color;
@ -267,28 +267,28 @@ mod tests {
}
}
fn load_shell_running_firmware() -> (Firmware, Hardware, InputSignals, TaskId) {
fn load_shell_running_firmware() -> (Firmware, TestPlatform, InputSignals, TaskId) {
let mut firmware = Firmware::new(None);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
firmware.load_cartridge(valid_cartridge(AppMode::Shell));
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
let task_id = match &firmware.state {
FirmwareState::ShellRunning(step) => step.task_id,
other => panic!("expected ShellRunning state, got {:?}", other),
};
(firmware, hardware, signals, task_id)
(firmware, platform, signals, task_id)
}
fn hub_home_firmware() -> (Firmware, Hardware) {
fn hub_home_firmware() -> (Firmware, TestPlatform) {
let mut firmware = Firmware::new(None);
firmware.state = FirmwareState::HubHome(HubHomeStep);
firmware.state_initialized = false;
(firmware, Hardware::new())
(firmware, TestPlatform::new())
}
fn focus_window(firmware: &mut Firmware, owner: WindowOwner) {
@ -306,11 +306,11 @@ mod tests {
#[test]
fn load_cartridge_transitions_to_app_crashes_when_vm_init_fails() {
let mut firmware = Firmware::new(None);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
firmware.load_cartridge(invalid_game_cartridge());
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
match &firmware.state {
FirmwareState::AppCrashes(step) => match &step.report {
@ -326,18 +326,18 @@ mod tests {
#[test]
fn game_running_transitions_to_app_crashes_when_runtime_surfaces_trap() {
let mut firmware = Firmware::new(None);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
firmware.load_cartridge(trapping_game_cartridge());
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
let task_id = match &firmware.state {
FirmwareState::GameRunning(step) => step.task_id,
other => panic!("expected GameRunning state, got {:?}", other),
};
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
match &firmware.state {
FirmwareState::AppCrashes(step) => match &step.report {
@ -359,11 +359,11 @@ mod tests {
#[test]
fn reset_routes_hub_boot_target_to_splash_screen() {
let mut firmware = Firmware::new(None);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
firmware.boot_target = BootTarget::Hub;
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::SplashScreen(_)));
}
@ -371,12 +371,12 @@ mod tests {
#[test]
fn reset_routes_cartridge_boot_target_to_launch_hub() {
let mut firmware = Firmware::new(None);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
firmware.boot_target =
BootTarget::Cartridge { path: "missing-cart".into(), debug: false, debug_port: 7777 };
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::LaunchHub(_)));
}
@ -384,11 +384,11 @@ mod tests {
#[test]
fn load_cartridge_routes_system_apps_to_system_running() {
let mut firmware = Firmware::new(None);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
firmware.load_cartridge(valid_cartridge(AppMode::Shell));
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::ShellRunning(_)));
assert!(firmware.os.windows().focused_window().is_some());
@ -398,18 +398,18 @@ mod tests {
#[test]
fn system_running_ticks_focused_system_app_through_hub_pipeline() {
let mut firmware = Firmware::new(None);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
firmware.load_cartridge(valid_cartridge(AppMode::Shell));
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
let task_id = match &firmware.state {
FirmwareState::ShellRunning(step) => step.task_id,
other => panic!("expected ShellRunning state, got {:?}", other),
};
let tick_index_before_system_update = firmware.os.vm().tick_index();
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::ShellRunning(_)));
assert!(firmware.os.windows().focused_window_belongs_to_task(task_id));
@ -418,10 +418,10 @@ mod tests {
#[test]
fn hub_home_does_not_launch_shell_before_home_input_is_armed() {
let (mut firmware, mut hardware) = hub_home_firmware();
let (mut firmware, mut platform) = hub_home_firmware();
let signals = InputSignals { f_signal: true, x_pos: 112, y_pos: 108, ..Default::default() };
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
assert_eq!(firmware.os.windows().window_count(), 0);
@ -429,11 +429,11 @@ mod tests {
#[test]
fn hub_home_click_launches_shell_a_as_native_shell_task_window() {
let (mut firmware, mut hardware) = hub_home_firmware();
firmware.tick(&InputSignals::default(), &mut hardware);
let (mut firmware, mut platform) = hub_home_firmware();
firmware.tick(&InputSignals::default(), &mut platform);
let signals = InputSignals { f_signal: true, x_pos: 112, y_pos: 108, ..Default::default() };
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
let task_id = match &firmware.state {
FirmwareState::ShellRunning(step) => step.task_id,
@ -452,11 +452,11 @@ mod tests {
#[test]
fn hub_home_click_launches_shell_b_as_native_shell_task_window() {
let (mut firmware, mut hardware) = hub_home_firmware();
firmware.tick(&InputSignals::default(), &mut hardware);
let (mut firmware, mut platform) = hub_home_firmware();
firmware.tick(&InputSignals::default(), &mut platform);
let signals = InputSignals { f_signal: true, x_pos: 256, y_pos: 108, ..Default::default() };
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
let task_id = match &firmware.state {
FirmwareState::ShellRunning(step) => step.task_id,
@ -475,10 +475,10 @@ mod tests {
#[test]
fn shell_running_start_close_uses_lifecycle_and_returns_hub_home() {
let (mut firmware, mut hardware, _signals, task_id) = load_shell_running_firmware();
let (mut firmware, mut platform, _signals, task_id) = load_shell_running_firmware();
let close_signals = InputSignals { start_signal: true, ..Default::default() };
firmware.tick(&close_signals, &mut hardware);
firmware.tick(&close_signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
@ -490,11 +490,11 @@ mod tests {
#[test]
fn shell_running_close_click_uses_lifecycle_and_returns_hub_home() {
let (mut firmware, mut hardware, _signals, task_id) = load_shell_running_firmware();
let (mut firmware, mut platform, _signals, task_id) = load_shell_running_firmware();
let close_signals =
InputSignals { f_signal: true, x_pos: 420, y_pos: 24, ..Default::default() };
firmware.tick(&close_signals, &mut hardware);
firmware.tick(&close_signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
@ -506,10 +506,10 @@ mod tests {
#[test]
fn shell_running_closes_task_and_returns_hub_home_without_focused_window() {
let (mut firmware, mut hardware, signals, task_id) = load_shell_running_firmware();
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
firmware.os.windows().remove_all_windows();
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
@ -521,10 +521,10 @@ mod tests {
#[test]
fn shell_running_closes_task_and_returns_hub_home_for_focused_hub_window() {
let (mut firmware, mut hardware, signals, task_id) = load_shell_running_firmware();
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
focus_window(&mut firmware, WindowOwner::Hub);
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
@ -536,10 +536,10 @@ mod tests {
#[test]
fn shell_running_closes_task_and_returns_hub_home_for_focused_overlay_window() {
let (mut firmware, mut hardware, signals, task_id) = load_shell_running_firmware();
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
focus_window(&mut firmware, WindowOwner::Overlay);
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
@ -551,10 +551,10 @@ mod tests {
#[test]
fn shell_running_closes_task_and_returns_hub_home_for_other_task_window() {
let (mut firmware, mut hardware, signals, task_id) = load_shell_running_firmware();
let (mut firmware, mut platform, signals, task_id) = load_shell_running_firmware();
focus_window(&mut firmware, WindowOwner::Task(TaskId(task_id.0 + 1)));
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::HubHome(_)));
assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed));
@ -567,11 +567,11 @@ mod tests {
#[test]
fn load_cartridge_routes_game_apps_to_game_running() {
let mut firmware = Firmware::new(None);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
firmware.load_cartridge(valid_cartridge(AppMode::Game));
firmware.tick(&signals, &mut hardware);
firmware.tick(&signals, &mut platform);
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
assert_eq!(firmware.os.windows().window_count(), 0);

View File

@ -21,13 +21,16 @@ impl AppCrashesStep {
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
// Update peripherals for input on the crash screen
ctx.hw.pad_mut().begin_frame(ctx.signals);
ctx.platform.input_mut().pad_mut().begin_frame(ctx.signals);
let packet = ShellUiFramePacket::new(vec![GfxUiCommand::Clear { color: Color::RED }]);
ctx.hw.publish_render_submission(&RenderSubmission::shell_ui(FrameId::ZERO, packet));
ctx.platform
.render_submission_sink()
.submit_render_submission(RenderSubmission::shell_ui(FrameId::ZERO, packet))
.expect("firmware crash render submission should publish");
// If START is pressed, return to the Hub
if ctx.hw.pad().start().down {
if ctx.platform.input().pad().start().down {
return Some(FirmwareState::LaunchHub(LaunchHubStep));
}

View File

@ -36,7 +36,7 @@ impl GameRunningStep {
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}
let result = ctx.os.vm().tick(ctx.vm, ctx.signals, ctx.hw);
let result = ctx.os.vm().tick(ctx.vm, ctx.signals, ctx.platform);
if let Some(report) = result {
let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report));

View File

@ -16,7 +16,7 @@ impl HubHomeStep {
}
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.hw);
let outcome = ctx.hub.update_shell_profile(ctx.os, ctx.vm, ctx.signals, ctx.platform);
if let Some(report) = outcome.crash {
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}

View File

@ -30,7 +30,7 @@ impl LoadCartridgeStep {
);
// Initialize Asset Manager
ctx.hw.assets_mut().initialize_for_cartridge(
ctx.platform.assets_mut().initialize_for_cartridge(
self.cartridge.asset_table.clone(),
self.cartridge.preload.clone(),
self.cartridge.assets.clone(),

View File

@ -59,7 +59,7 @@ impl ShellRunningStep {
}
}
let outcome = ctx.hub.update_shell_profile(ctx.os, ctx.vm, ctx.signals, ctx.hw);
let outcome = ctx.hub.update_shell_profile(ctx.os, ctx.vm, ctx.signals, ctx.platform);
if let Some(report) = outcome.crash {
let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report));

View File

@ -14,7 +14,7 @@ impl SplashScreenStep {
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
ctx.os.log(LogLevel::Info, LogSource::Pos, 0, "Showing SplashScreen".to_string());
// Play sound on enter
// ctx.hw.audio_mut().play(0, 0, 0, 255, 127, 1.0, 0, LoopMode::Off);
// ctx.platform.audio_mut().play(0, 0, 0, 255, 127, 1.0, 0, LoopMode::Off);
}
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
@ -22,10 +22,10 @@ impl SplashScreenStep {
const TOTAL_DURATION: u32 = 240; // 4 seconds total (updated from 2s based on total_duration logic)
// Update peripherals for input
ctx.hw.pad_mut().begin_frame(ctx.signals);
ctx.platform.input_mut().pad_mut().begin_frame(ctx.signals);
// Draw expanding square
let (sw, sh) = ctx.hw.gfx().size();
let (sw, sh) = ctx.platform.display_size();
let max_size = (sw.min(sh) as i32 / 2).max(1);
let current_size = if self.frame < ANIMATION_DURATION {
@ -44,11 +44,14 @@ impl SplashScreenStep {
color: Color::WHITE,
},
]);
ctx.hw.publish_render_submission(&RenderSubmission::shell_ui(FrameId::ZERO, packet));
ctx.platform
.render_submission_sink()
.submit_render_submission(RenderSubmission::shell_ui(FrameId::ZERO, packet))
.expect("firmware splash render submission should publish");
// Transition logic
// If any button is pressed at any time after the animation ends
if self.frame >= ANIMATION_DURATION && ctx.hw.pad().any() {
if self.frame >= ANIMATION_DURATION && ctx.platform.input().pad().any() {
return Some(FirmwareState::LaunchHub(LaunchHubStep));
}

View File

@ -1,5 +1,5 @@
use crate::firmware::boot_target::BootTarget;
use prometeu_hal::{HardwareBridge, InputSignals};
use prometeu_hal::{InputSignals, RuntimePlatform};
use prometeu_system::{PrometeuHub, SystemOS};
use prometeu_vm::VirtualMachine;
@ -9,5 +9,5 @@ pub struct PrometeuContext<'a> {
pub hub: &'a mut PrometeuHub,
pub boot_target: &'a BootTarget,
pub signals: &'a InputSignals,
pub hw: &'a mut dyn HardwareBridge,
pub platform: &'a mut dyn RuntimePlatform,
}

View File

@ -1,34 +0,0 @@
use crate::asset_bridge::AssetBridge;
use crate::audio_bridge::AudioBridge;
use crate::gfx_bridge::GfxBridge;
use crate::pad_bridge::PadBridge;
use crate::render_submission::{Game2DFramePacket, RenderSubmission};
use crate::sprite::Sprite;
use crate::touch_bridge::TouchBridge;
pub trait HardwareBridge {
fn begin_frame(&mut self);
fn bind_scene(&mut self, scene_bank_id: usize) -> bool;
fn unbind_scene(&mut self);
fn set_camera(&mut self, x: i32, y: i32);
fn emit_sprite(&mut self, sprite: Sprite) -> bool;
fn close_game2d_packet(&self) -> Game2DFramePacket;
fn publish_render_submission(&mut self, submission: &RenderSubmission);
fn render_frame(&mut self);
fn has_glyph_bank(&self, bank_id: usize) -> bool;
fn gfx(&self) -> &dyn GfxBridge;
fn gfx_mut(&mut self) -> &mut dyn GfxBridge;
fn audio(&self) -> &dyn AudioBridge;
fn audio_mut(&mut self) -> &mut dyn AudioBridge;
fn pad(&self) -> &dyn PadBridge;
fn pad_mut(&mut self) -> &mut dyn PadBridge;
fn touch(&self) -> &dyn TouchBridge;
fn touch_mut(&mut self) -> &mut dyn TouchBridge;
fn assets(&self) -> &dyn AssetBridge;
fn assets_mut(&mut self) -> &mut dyn AssetBridge;
}

View File

@ -1,19 +1,23 @@
use crate::hardware_bridge::HardwareBridge;
use crate::platform::RuntimePlatform;
use crate::vm_fault::VmFault;
pub struct HostContext<'a> {
pub hw: Option<&'a mut dyn HardwareBridge>,
pub platform: Option<&'a mut dyn RuntimePlatform>,
}
impl<'a> HostContext<'a> {
pub fn new(hw: Option<&'a mut dyn HardwareBridge>) -> Self {
Self { hw }
pub fn new(platform: Option<&'a mut dyn RuntimePlatform>) -> Self {
Self { platform }
}
pub fn new_platform(platform: Option<&'a mut dyn RuntimePlatform>) -> Self {
Self::new(platform)
}
#[inline]
pub fn require_hw(&mut self) -> Result<&mut dyn HardwareBridge, VmFault> {
match &mut self.hw {
Some(hw) => Ok(*hw),
pub fn require_platform(&mut self) -> Result<&mut dyn RuntimePlatform, VmFault> {
match &mut self.platform {
Some(platform) => Ok(*platform),
None => Err(VmFault::Unavailable),
}
}

View File

@ -11,16 +11,19 @@ pub mod debugger_protocol;
pub mod gfx_bridge;
pub mod glyph;
pub mod glyph_bank;
pub mod hardware_bridge;
pub mod host_context;
pub mod host_return;
pub mod input_signals;
pub mod log;
pub mod native_helpers;
pub mod native_interface;
pub mod owned_frame;
pub mod pad_bridge;
pub mod platform;
pub mod primitives;
pub mod render_resources;
pub mod render_submission;
pub mod render_worker;
pub mod sample;
pub mod scene_bank;
pub mod scene_layer;
@ -39,16 +42,25 @@ pub use asset_bridge::AssetBridge;
pub use audio_bridge::{AudioBridge, AudioOpStatus, LoopMode};
pub use composer_status::ComposerOpStatus;
pub use gfx_bridge::{BlendMode, GfxBridge, GfxOpStatus};
pub use hardware_bridge::HardwareBridge;
pub use host_context::{HostContext, HostContextProvider};
pub use host_return::HostReturn;
pub use input_signals::InputSignals;
pub use native_helpers::{expect_bool, expect_int};
pub use native_interface::{NativeInterface, SyscallId};
pub use owned_frame::{OwnedRgba8888Frame, OwnedRgba8888FrameError};
pub use pad_bridge::PadBridge;
pub use platform::{
AssetStoragePlatform, AudioPlatform, ClockPacingPlatform, Game2DFrameComposer, InputPlatform,
NoopTelemetryPlatform, RenderBackend, RenderSubmissionSink, RenderSubmitError, RuntimePlatform,
TelemetryPlatform, TestPlatform,
};
pub use render_resources::RenderResourceAccess;
pub use render_submission::{
BoundScenePacket, Camera2D, ComposerFramePacket, FrameId, Game2DFramePacket, GameSpritePacket,
Gfx2dCommand, GfxUiCommand, HudCommand, HudPacket, RenderSubmission, RenderSubmissionPacket,
ShellUiFramePacket,
Gfx2dCommand, GfxUiCommand, HudCommand, HudPacket, RenderOwnership, RenderSubmission,
RenderSubmissionPacket, ShellUiFramePacket,
};
pub use render_worker::{
RenderWorkerBackend, RenderWorkerError, RenderWorkerFrameSink, RenderWorkerTelemetry,
};
pub use touch_bridge::TouchBridge;

View File

@ -0,0 +1,127 @@
use crate::render_submission::{FrameId, RenderOwnership};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnedRgba8888Frame {
pub frame_id: FrameId,
pub ownership: RenderOwnership,
pub width: usize,
pub height: usize,
pub stride_pixels: usize,
pub pixels: Vec<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnedRgba8888FrameError {
EmptyDimensions,
StrideTooSmall,
PixelBufferTooSmall,
}
impl OwnedRgba8888Frame {
pub fn new(
frame_id: FrameId,
ownership: RenderOwnership,
width: usize,
height: usize,
stride_pixels: usize,
pixels: Vec<u32>,
) -> Result<Self, OwnedRgba8888FrameError> {
if width == 0 || height == 0 {
return Err(OwnedRgba8888FrameError::EmptyDimensions);
}
if stride_pixels < width {
return Err(OwnedRgba8888FrameError::StrideTooSmall);
}
let required_len = stride_pixels
.checked_mul(height)
.ok_or(OwnedRgba8888FrameError::PixelBufferTooSmall)?;
if pixels.len() < required_len {
return Err(OwnedRgba8888FrameError::PixelBufferTooSmall);
}
Ok(Self { frame_id, ownership, width, height, stride_pixels, pixels })
}
pub fn packed(
frame_id: FrameId,
ownership: RenderOwnership,
width: usize,
height: usize,
pixels: Vec<u32>,
) -> Result<Self, OwnedRgba8888FrameError> {
Self::new(frame_id, ownership, width, height, width, pixels)
}
pub fn required_pixel_len(&self) -> usize {
self.stride_pixels * self.height
}
pub fn row(&self, y: usize) -> Option<&[u32]> {
if y >= self.height {
return None;
}
let start = y * self.stride_pixels;
Some(&self.pixels[start..start + self.width])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_mode::AppMode;
fn ownership() -> RenderOwnership {
RenderOwnership::new(11, AppMode::Game, 42)
}
#[test]
fn owned_rgba8888_frame_preserves_metadata_and_pixels() {
let pixels = vec![0x11223344, 0x55667788, 0x99AABBCC, 0xDDEEFF00];
let frame = OwnedRgba8888Frame::packed(FrameId::new(7), ownership(), 2, 2, pixels.clone())
.expect("valid packed RGBA8888 frame");
assert_eq!(frame.frame_id, FrameId::new(7));
assert_eq!(frame.ownership, ownership());
assert_eq!(frame.width, 2);
assert_eq!(frame.height, 2);
assert_eq!(frame.stride_pixels, 2);
assert_eq!(frame.pixels, pixels);
assert_eq!(frame.required_pixel_len(), 4);
}
#[test]
fn checked_constructor_rejects_empty_dimensions() {
let err = OwnedRgba8888Frame::new(FrameId::ZERO, ownership(), 0, 1, 1, vec![0])
.expect_err("zero width should be rejected");
assert_eq!(err, OwnedRgba8888FrameError::EmptyDimensions);
}
#[test]
fn checked_constructor_rejects_stride_smaller_than_width() {
let err = OwnedRgba8888Frame::new(FrameId::ZERO, ownership(), 3, 1, 2, vec![0, 0, 0])
.expect_err("stride smaller than width should be rejected");
assert_eq!(err, OwnedRgba8888FrameError::StrideTooSmall);
}
#[test]
fn checked_constructor_rejects_short_pixel_buffer() {
let err = OwnedRgba8888Frame::new(FrameId::ZERO, ownership(), 2, 2, 3, vec![0; 5])
.expect_err("buffer shorter than stride * height should be rejected");
assert_eq!(err, OwnedRgba8888FrameError::PixelBufferTooSmall);
}
#[test]
fn row_returns_visible_width_without_stride_padding() {
let frame =
OwnedRgba8888Frame::new(FrameId::ZERO, ownership(), 2, 2, 3, vec![1, 2, 99, 3, 4, 88])
.expect("valid padded frame");
assert_eq!(frame.row(0), Some(&[1, 2][..]));
assert_eq!(frame.row(1), Some(&[3, 4][..]));
assert_eq!(frame.row(2), None);
}
}

View File

@ -0,0 +1,140 @@
use crate::asset_bridge::AssetBridge;
use crate::audio_bridge::AudioBridge;
use crate::composer_status::ComposerOpStatus;
use crate::pad_bridge::PadBridge;
use crate::render_submission::{Game2DFramePacket, RenderSubmission};
use crate::sprite::Sprite;
use crate::telemetry::TelemetryFrame;
use crate::touch_bridge::TouchBridge;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderSubmitError {
BackendUnavailable,
SubmissionRejected,
}
pub trait RenderSubmissionSink {
fn submit_render_submission(
&mut self,
submission: RenderSubmission,
) -> Result<(), RenderSubmitError>;
}
pub trait RenderBackend {
fn render_frame(&mut self);
}
pub trait Game2DFrameComposer {
fn begin_frame(&mut self);
fn bind_scene(&mut self, scene_bank_id: usize) -> ComposerOpStatus;
fn unbind_scene(&mut self);
fn set_camera(&mut self, x: i32, y: i32) -> ComposerOpStatus;
fn emit_sprite(&mut self, sprite: Sprite) -> ComposerOpStatus;
fn close_game2d_packet(&self) -> Game2DFramePacket;
}
pub trait AudioPlatform: AudioBridge {}
impl<T: AudioBridge + ?Sized> AudioPlatform for T {}
pub trait InputPlatform {
fn pad(&self) -> &dyn PadBridge;
fn pad_mut(&mut self) -> &mut dyn PadBridge;
fn touch(&self) -> &dyn TouchBridge;
fn touch_mut(&mut self) -> &mut dyn TouchBridge;
}
pub trait AssetStoragePlatform: AssetBridge {}
impl<T: AssetBridge + ?Sized> AssetStoragePlatform for T {}
pub trait ClockPacingPlatform {}
pub trait TelemetryPlatform {
fn telemetry_snapshot(&self) -> Option<TelemetryFrame> {
None
}
}
#[derive(Debug, Default)]
pub struct NoopTelemetryPlatform;
impl TelemetryPlatform for NoopTelemetryPlatform {}
pub trait RuntimePlatform {
fn display_size(&self) -> (usize, usize) {
(480, 270)
}
fn render_submission_sink(&mut self) -> &mut dyn RenderSubmissionSink;
fn render_backend(&mut self) -> &mut dyn RenderBackend;
fn game2d_frame_composer(&mut self) -> &mut dyn Game2DFrameComposer;
fn audio(&self) -> &dyn AudioBridge;
fn audio_mut(&mut self) -> &mut dyn AudioBridge;
fn input(&self) -> &dyn InputPlatform;
fn input_mut(&mut self) -> &mut dyn InputPlatform;
fn assets(&self) -> &dyn AssetBridge;
fn assets_mut(&mut self) -> &mut dyn AssetBridge;
fn telemetry(&self) -> &dyn TelemetryPlatform;
}
pub trait TestPlatform: RuntimePlatform {}
impl<T: RuntimePlatform + ?Sized> TestPlatform for T {}
#[cfg(test)]
mod tests {
use super::*;
fn assert_render_sink_object(_: &mut dyn RenderSubmissionSink) {}
fn assert_backend_object(_: &mut dyn RenderBackend) {}
fn assert_composer_object(_: &mut dyn Game2DFrameComposer) {}
struct CompileOnly;
impl RenderSubmissionSink for CompileOnly {
fn submit_render_submission(
&mut self,
_submission: RenderSubmission,
) -> Result<(), RenderSubmitError> {
Ok(())
}
}
impl RenderBackend for CompileOnly {
fn render_frame(&mut self) {}
}
impl Game2DFrameComposer for CompileOnly {
fn begin_frame(&mut self) {}
fn bind_scene(&mut self, _scene_bank_id: usize) -> ComposerOpStatus {
ComposerOpStatus::Ok
}
fn unbind_scene(&mut self) {}
fn set_camera(&mut self, _x: i32, _y: i32) -> ComposerOpStatus {
ComposerOpStatus::Ok
}
fn emit_sprite(&mut self, _sprite: Sprite) -> ComposerOpStatus {
ComposerOpStatus::Ok
}
fn close_game2d_packet(&self) -> Game2DFramePacket {
Game2DFramePacket::default()
}
}
#[test]
fn core_platform_facades_are_object_safe() {
let mut compile_only = CompileOnly;
assert_render_sink_object(&mut compile_only);
assert_backend_object(&mut compile_only);
assert_composer_object(&mut compile_only);
}
#[test]
fn render_submit_error_is_typed_and_debuggable() {
assert_eq!(format!("{:?}", RenderSubmitError::BackendUnavailable), "BackendUnavailable");
}
}

View File

@ -0,0 +1,109 @@
use crate::glyph_bank::GlyphBank;
use crate::scene_bank::SceneBank;
/// Read-only render resource lookup used by render backends.
///
/// Resource ids are stable bank slots carried by render submissions. The
/// callback shape keeps bank ownership inside the provider while exposing only
/// borrowed read access to the caller.
pub trait RenderResourceAccess {
/// Reads a resident glyph bank by id.
fn with_glyph_bank<R>(&self, bank_id: usize, read: impl FnOnce(&GlyphBank) -> R) -> Option<R>;
/// Returns the number of glyph bank ids accepted by this provider.
fn glyph_bank_count(&self) -> usize;
/// Reads a resident scene bank by id.
fn with_scene_bank<R>(&self, bank_id: usize, read: impl FnOnce(&SceneBank) -> R) -> Option<R>;
/// Returns the number of scene bank ids accepted by this provider.
fn scene_bank_count(&self) -> usize;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::glyph::Glyph;
use crate::glyph_bank::TileSize;
use crate::scene_layer::{ParallaxFactor, SceneLayer};
use crate::tile::Tile;
use crate::tilemap::TileMap;
struct TestRenderResources {
glyph_banks: Vec<Option<GlyphBank>>,
scene_banks: Vec<Option<SceneBank>>,
}
impl RenderResourceAccess for TestRenderResources {
fn with_glyph_bank<R>(
&self,
bank_id: usize,
read: impl FnOnce(&GlyphBank) -> R,
) -> Option<R> {
self.glyph_banks.get(bank_id)?.as_ref().map(read)
}
fn glyph_bank_count(&self) -> usize {
self.glyph_banks.len()
}
fn with_scene_bank<R>(
&self,
bank_id: usize,
read: impl FnOnce(&SceneBank) -> R,
) -> Option<R> {
self.scene_banks.get(bank_id)?.as_ref().map(read)
}
fn scene_bank_count(&self) -> usize {
self.scene_banks.len()
}
}
fn scene_bank(tile_size: TileSize) -> SceneBank {
let layer = SceneLayer {
active: true,
glyph_asset_id: 7,
tile_size,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap {
width: 1,
height: 1,
tiles: vec![Tile {
active: true,
glyph: Glyph { glyph_id: 3, palette_id: 0 },
flip_x: false,
flip_y: false,
}],
},
};
SceneBank { layers: std::array::from_fn(|_| layer.clone()) }
}
#[test]
fn reads_resident_resources_by_id() {
let resources = TestRenderResources {
glyph_banks: vec![None, Some(GlyphBank::new(TileSize::Size16, 16, 16)), None],
scene_banks: vec![Some(scene_bank(TileSize::Size8)), None],
};
assert_eq!(resources.glyph_bank_count(), 3);
assert_eq!(resources.scene_bank_count(), 2);
assert_eq!(resources.with_glyph_bank(1, |bank| bank.tile_size), Some(TileSize::Size16));
assert_eq!(
resources.with_scene_bank(0, |bank| bank.layers[0].tile_size),
Some(TileSize::Size8)
);
}
#[test]
fn missing_resources_return_none() {
let resources = TestRenderResources { glyph_banks: vec![None], scene_banks: vec![None] };
assert_eq!(resources.with_glyph_bank(0, |bank| bank.width), None);
assert_eq!(resources.with_glyph_bank(9, |bank| bank.width), None);
assert_eq!(resources.with_scene_bank(0, |bank| bank.layers[0].active), None);
assert_eq!(resources.with_scene_bank(9, |bank| bank.layers[0].active), None);
}
}

View File

@ -25,16 +25,32 @@ impl FrameId {
pub struct RenderSubmission {
pub frame_id: FrameId,
pub app_mode: AppMode,
pub ownership: RenderOwnership,
pub packet: RenderSubmissionPacket,
}
impl RenderSubmission {
pub const fn game2d(frame_id: FrameId, packet: Game2DFramePacket) -> Self {
Self { frame_id, app_mode: AppMode::Game, packet: RenderSubmissionPacket::Game2D(packet) }
Self {
frame_id,
app_mode: AppMode::Game,
ownership: RenderOwnership::new(0, AppMode::Game, 0),
packet: RenderSubmissionPacket::Game2D(packet),
}
}
pub const fn shell_ui(frame_id: FrameId, packet: ShellUiFramePacket) -> Self {
Self { frame_id, app_mode: AppMode::Shell, packet: RenderSubmissionPacket::ShellUi(packet) }
Self {
frame_id,
app_mode: AppMode::Shell,
ownership: RenderOwnership::new(0, AppMode::Shell, 0),
packet: RenderSubmissionPacket::ShellUi(packet),
}
}
pub const fn with_ownership(mut self, ownership: RenderOwnership) -> Self {
self.ownership = ownership;
self
}
pub const fn is_coherent(&self) -> bool {
@ -46,6 +62,19 @@ impl RenderSubmission {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderOwnership {
pub epoch: u64,
pub app_mode: AppMode,
pub app_id: u32,
}
impl RenderOwnership {
pub const fn new(epoch: u64, app_mode: AppMode, app_id: u32) -> Self {
Self { epoch, app_mode, app_id }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenderSubmissionPacket {
Game2D(Game2DFramePacket),
@ -164,12 +193,21 @@ mod tests {
let submission = RenderSubmission {
frame_id: FrameId::new(1),
app_mode: AppMode::Game,
ownership: RenderOwnership::new(0, AppMode::Game, 0),
packet: RenderSubmissionPacket::ShellUi(ShellUiFramePacket::new(Vec::new())),
};
assert!(!submission.is_coherent());
}
#[test]
fn ownership_can_be_stamped_by_runtime() {
let submission = RenderSubmission::game2d(FrameId::new(3), Game2DFramePacket::default())
.with_ownership(RenderOwnership::new(9, AppMode::Game, 42));
assert_eq!(submission.ownership, RenderOwnership::new(9, AppMode::Game, 42));
}
#[test]
fn closed_submission_owns_command_payload() {
let mut commands = vec![Gfx2dCommand::DrawText {

View File

@ -0,0 +1,203 @@
use std::fmt;
use crate::{OwnedRgba8888Frame, RenderSubmission};
/// Typed failure surface for the real render worker and its backend.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderWorkerError {
BackendUnavailable,
RenderFailed,
PublishFailed,
PresentFailed,
ShutdownTimeout,
StaleSubmissionDiscarded,
WorkerPanic,
InternalFailure,
}
impl fmt::Display for RenderWorkerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BackendUnavailable => f.write_str("render backend unavailable"),
Self::RenderFailed => f.write_str("render failed"),
Self::PublishFailed => f.write_str("render publish failed"),
Self::PresentFailed => f.write_str("render present failed"),
Self::ShutdownTimeout => f.write_str("render worker shutdown timed out"),
Self::StaleSubmissionDiscarded => f.write_str("stale render submission discarded"),
Self::WorkerPanic => f.write_str("render worker panicked"),
Self::InternalFailure => f.write_str("internal render worker failure"),
}
}
}
impl std::error::Error for RenderWorkerError {}
pub trait RenderWorkerBackend: Send + 'static {
fn render(
&self,
submission: &RenderSubmission,
) -> Result<OwnedRgba8888Frame, RenderWorkerError>;
}
pub trait RenderWorkerFrameSink: Send + 'static {
fn publish(&self, frame: OwnedRgba8888Frame) -> Result<(), RenderWorkerError>;
}
/// Snapshot of render worker handoff, render, publish, and shutdown counters.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RenderWorkerTelemetry {
pub produced_submissions: u64,
pub replaced_before_consume: u64,
pub consumed_submissions: u64,
pub published_frames: u64,
pub presented_frames: u64,
pub repeated_frames: u64,
pub stale_submission_discards: u64,
pub render_failures: u64,
pub publish_failures: u64,
pub present_failures: u64,
pub shutdown_timeouts: u64,
pub worker_panics: u64,
pub last_produced_frame_id: u64,
pub last_consumed_frame_id: u64,
pub last_published_frame_id: u64,
pub last_presented_frame_id: u64,
pub last_dropped_frame_id: u64,
pub last_error_frame_id: u64,
pub active_render_epoch: u64,
}
impl RenderWorkerTelemetry {
pub fn record_produced(&mut self, frame_id: u64) {
self.produced_submissions += 1;
self.last_produced_frame_id = frame_id;
}
pub fn record_replaced_before_consume(&mut self, frame_id: u64) {
self.replaced_before_consume += 1;
self.last_dropped_frame_id = frame_id;
}
pub fn record_consumed(&mut self, frame_id: u64) {
self.consumed_submissions += 1;
self.last_consumed_frame_id = frame_id;
}
pub fn record_published(&mut self, frame_id: u64) {
self.published_frames += 1;
self.last_published_frame_id = frame_id;
}
pub fn record_presented(&mut self, frame_id: u64) {
self.presented_frames += 1;
self.last_presented_frame_id = frame_id;
}
pub fn record_repeated_frame(&mut self, frame_id: u64) {
self.repeated_frames += 1;
self.last_presented_frame_id = frame_id;
}
pub fn record_stale_submission_discarded(&mut self, frame_id: u64) {
self.stale_submission_discards += 1;
self.last_dropped_frame_id = frame_id;
}
pub fn record_error(&mut self, error: RenderWorkerError, frame_id: u64) {
match error {
RenderWorkerError::BackendUnavailable | RenderWorkerError::RenderFailed => {
self.render_failures += 1;
}
RenderWorkerError::PublishFailed => {
self.publish_failures += 1;
}
RenderWorkerError::PresentFailed => {
self.present_failures += 1;
}
RenderWorkerError::ShutdownTimeout => {
self.shutdown_timeouts += 1;
}
RenderWorkerError::WorkerPanic => {
self.worker_panics += 1;
}
RenderWorkerError::StaleSubmissionDiscarded => {
self.stale_submission_discards += 1;
self.last_dropped_frame_id = frame_id;
}
RenderWorkerError::InternalFailure => {
self.render_failures += 1;
}
}
self.last_error_frame_id = frame_id;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_worker_error_is_copy_equatable_and_debuggable() {
let error = RenderWorkerError::ShutdownTimeout;
let copied = error;
assert_eq!(copied, RenderWorkerError::ShutdownTimeout);
assert_eq!(format!("{:?}", error), "ShutdownTimeout");
assert_eq!(error.to_string(), "render worker shutdown timed out");
}
#[test]
fn render_worker_telemetry_defaults_to_zero() {
assert_eq!(
RenderWorkerTelemetry::default(),
RenderWorkerTelemetry {
produced_submissions: 0,
replaced_before_consume: 0,
consumed_submissions: 0,
published_frames: 0,
presented_frames: 0,
repeated_frames: 0,
stale_submission_discards: 0,
render_failures: 0,
publish_failures: 0,
present_failures: 0,
shutdown_timeouts: 0,
worker_panics: 0,
last_produced_frame_id: 0,
last_consumed_frame_id: 0,
last_published_frame_id: 0,
last_presented_frame_id: 0,
last_dropped_frame_id: 0,
last_error_frame_id: 0,
active_render_epoch: 0,
}
);
}
#[test]
fn render_worker_telemetry_records_counters_and_frame_ids() {
let mut telemetry = RenderWorkerTelemetry::default();
telemetry.record_produced(10);
telemetry.record_replaced_before_consume(10);
telemetry.record_consumed(11);
telemetry.record_published(11);
telemetry.record_presented(11);
telemetry.record_repeated_frame(11);
telemetry.record_error(RenderWorkerError::PublishFailed, 12);
telemetry.record_error(RenderWorkerError::WorkerPanic, 13);
telemetry.record_stale_submission_discarded(14);
assert_eq!(telemetry.produced_submissions, 1);
assert_eq!(telemetry.replaced_before_consume, 1);
assert_eq!(telemetry.consumed_submissions, 1);
assert_eq!(telemetry.published_frames, 1);
assert_eq!(telemetry.presented_frames, 1);
assert_eq!(telemetry.repeated_frames, 1);
assert_eq!(telemetry.publish_failures, 1);
assert_eq!(telemetry.worker_panics, 1);
assert_eq!(telemetry.stale_submission_discards, 1);
assert_eq!(telemetry.last_error_frame_id, 13);
assert_eq!(telemetry.last_dropped_frame_id, 14);
}
}

View File

@ -69,10 +69,7 @@ fn resolver_rejects_removed_legacy_gfx_set_sprite_identity() {
let requested = [SyscallIdentity { module: "gfx2d", name: "set_sprite", version: 1 }];
let err = resolve_program_syscalls(&requested, caps::ALL).unwrap_err();
assert_eq!(
err,
LoadError::UnknownSyscall { module: "gfx2d".into(), name: "set_sprite".into(), version: 1 }
);
assert_eq!(err, LoadError::UnknownSyscall { module: "gfx2d", name: "set_sprite", version: 1 });
}
#[test]
@ -272,10 +269,7 @@ fn resolver_rejects_removed_bank_slot_info_identity() {
let requested = [SyscallIdentity { module: "bank", name: "slot_info", version: 1 }];
let err = resolve_program_syscalls(&requested, caps::ALL).unwrap_err();
assert_eq!(
err,
LoadError::UnknownSyscall { module: "bank".into(), name: "slot_info".into(), version: 1 }
);
assert_eq!(err, LoadError::UnknownSyscall { module: "bank", name: "slot_info", version: 1 });
}
#[test]

View File

@ -1,4 +1,5 @@
use crate::log::{LogLevel, LogService, LogSource};
use crate::render_worker::RenderWorkerTelemetry;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
@ -29,6 +30,54 @@ pub struct TelemetryFrame {
// Log Pressure from the last completed logical frame
pub logs_count: u32,
// Render handoff/presentation telemetry
pub produced_submissions: u64,
pub replaced_before_consume: u64,
pub consumed_submissions: u64,
pub published_frames: u64,
pub presented_frames: u64,
pub repeated_presents: u64,
pub render_errors: u64,
pub publish_errors: u64,
pub present_errors: u64,
pub stale_epoch_discards: u64,
pub shutdown_discards: u64,
pub shutdown_timeouts: u64,
pub worker_panics: u64,
pub last_produced_frame_id: u64,
pub last_consumed_frame_id: u64,
pub last_published_frame_id: u64,
pub last_presented_frame_id: u64,
pub last_dropped_frame_id: u64,
pub last_error_frame_id: u64,
pub active_render_epoch: u64,
}
impl TelemetryFrame {
pub fn render_worker_telemetry(&self) -> RenderWorkerTelemetry {
RenderWorkerTelemetry {
produced_submissions: self.produced_submissions,
replaced_before_consume: self.replaced_before_consume,
consumed_submissions: self.consumed_submissions,
published_frames: self.published_frames,
presented_frames: self.presented_frames,
repeated_frames: self.repeated_presents,
stale_submission_discards: self.stale_epoch_discards,
render_failures: self.render_errors,
publish_failures: self.publish_errors,
present_failures: self.present_errors,
shutdown_timeouts: self.shutdown_timeouts,
worker_panics: self.worker_panics,
last_produced_frame_id: self.last_produced_frame_id,
last_consumed_frame_id: self.last_consumed_frame_id,
last_published_frame_id: self.last_published_frame_id,
last_presented_frame_id: self.last_presented_frame_id,
last_dropped_frame_id: self.last_dropped_frame_id,
last_error_frame_id: self.last_error_frame_id,
active_render_epoch: self.active_render_epoch,
}
}
}
/// Thread-safe, atomic telemetry storage for real-time monitoring by the host.
@ -62,6 +111,28 @@ pub struct AtomicTelemetry {
pub current_logs_count: Arc<AtomicU32>,
// Persisted log count from the last completed logical frame
pub logs_count: AtomicU32,
// Render handoff/presentation telemetry
pub produced_submissions: AtomicU64,
pub replaced_before_consume: AtomicU64,
pub consumed_submissions: AtomicU64,
pub published_frames: AtomicU64,
pub presented_frames: AtomicU64,
pub repeated_presents: AtomicU64,
pub render_errors: AtomicU64,
pub publish_errors: AtomicU64,
pub present_errors: AtomicU64,
pub stale_epoch_discards: AtomicU64,
pub shutdown_discards: AtomicU64,
pub shutdown_timeouts: AtomicU64,
pub worker_panics: AtomicU64,
pub last_produced_frame_id: AtomicU64,
pub last_consumed_frame_id: AtomicU64,
pub last_published_frame_id: AtomicU64,
pub last_presented_frame_id: AtomicU64,
pub last_dropped_frame_id: AtomicU64,
pub last_error_frame_id: AtomicU64,
pub active_render_epoch: AtomicU64,
}
impl AtomicTelemetry {
@ -91,6 +162,26 @@ impl AtomicTelemetry {
vm_string_materializations: self.vm_string_materializations.load(Ordering::Relaxed),
logs_count: self.logs_count.load(Ordering::Relaxed),
vm_steps: self.vm_steps.load(Ordering::Relaxed),
produced_submissions: self.produced_submissions.load(Ordering::Relaxed),
replaced_before_consume: self.replaced_before_consume.load(Ordering::Relaxed),
consumed_submissions: self.consumed_submissions.load(Ordering::Relaxed),
published_frames: self.published_frames.load(Ordering::Relaxed),
presented_frames: self.presented_frames.load(Ordering::Relaxed),
repeated_presents: self.repeated_presents.load(Ordering::Relaxed),
render_errors: self.render_errors.load(Ordering::Relaxed),
publish_errors: self.publish_errors.load(Ordering::Relaxed),
present_errors: self.present_errors.load(Ordering::Relaxed),
stale_epoch_discards: self.stale_epoch_discards.load(Ordering::Relaxed),
shutdown_discards: self.shutdown_discards.load(Ordering::Relaxed),
shutdown_timeouts: self.shutdown_timeouts.load(Ordering::Relaxed),
worker_panics: self.worker_panics.load(Ordering::Relaxed),
last_produced_frame_id: self.last_produced_frame_id.load(Ordering::Relaxed),
last_consumed_frame_id: self.last_consumed_frame_id.load(Ordering::Relaxed),
last_published_frame_id: self.last_published_frame_id.load(Ordering::Relaxed),
last_presented_frame_id: self.last_presented_frame_id.load(Ordering::Relaxed),
last_dropped_frame_id: self.last_dropped_frame_id.load(Ordering::Relaxed),
last_error_frame_id: self.last_error_frame_id.load(Ordering::Relaxed),
active_render_epoch: self.active_render_epoch.load(Ordering::Relaxed),
}
}
@ -113,6 +204,26 @@ impl AtomicTelemetry {
self.vm_steps.store(0, Ordering::Relaxed);
self.logs_count.store(0, Ordering::Relaxed);
self.current_logs_count.store(0, Ordering::Relaxed);
self.produced_submissions.store(0, Ordering::Relaxed);
self.replaced_before_consume.store(0, Ordering::Relaxed);
self.consumed_submissions.store(0, Ordering::Relaxed);
self.published_frames.store(0, Ordering::Relaxed);
self.presented_frames.store(0, Ordering::Relaxed);
self.repeated_presents.store(0, Ordering::Relaxed);
self.render_errors.store(0, Ordering::Relaxed);
self.publish_errors.store(0, Ordering::Relaxed);
self.present_errors.store(0, Ordering::Relaxed);
self.stale_epoch_discards.store(0, Ordering::Relaxed);
self.shutdown_discards.store(0, Ordering::Relaxed);
self.shutdown_timeouts.store(0, Ordering::Relaxed);
self.worker_panics.store(0, Ordering::Relaxed);
self.last_produced_frame_id.store(0, Ordering::Relaxed);
self.last_consumed_frame_id.store(0, Ordering::Relaxed);
self.last_published_frame_id.store(0, Ordering::Relaxed);
self.last_presented_frame_id.store(0, Ordering::Relaxed);
self.last_dropped_frame_id.store(0, Ordering::Relaxed);
self.last_error_frame_id.store(0, Ordering::Relaxed);
self.active_render_epoch.store(0, Ordering::Relaxed);
}
}
@ -294,11 +405,13 @@ mod tests {
};
let cert = Certifier::new(config);
let mut tel = TelemetryFrame::default();
tel.cycles_used = 150;
tel.syscalls = 10;
tel.host_cpu_time_us = 500;
tel.glyph_slots_used = 2;
let tel = TelemetryFrame {
cycles_used: 150,
syscalls: 10,
host_cpu_time_us: 500,
glyph_slots_used: 2,
..Default::default()
};
let violations = cert.evaluate(&tel, &mut ls, 1000);
assert_eq!(violations, 3);
@ -334,4 +447,51 @@ mod tests {
assert_eq!(snapshot.vm_heap_allocations, 2);
assert_eq!(snapshot.vm_string_materializations, 5);
}
#[test]
fn snapshot_maps_render_worker_telemetry() {
let current = Arc::new(AtomicU32::new(0));
let tel = AtomicTelemetry::new(current);
tel.produced_submissions.store(2, Ordering::Relaxed);
tel.replaced_before_consume.store(1, Ordering::Relaxed);
tel.consumed_submissions.store(1, Ordering::Relaxed);
tel.published_frames.store(1, Ordering::Relaxed);
tel.presented_frames.store(1, Ordering::Relaxed);
tel.repeated_presents.store(3, Ordering::Relaxed);
tel.render_errors.store(4, Ordering::Relaxed);
tel.publish_errors.store(5, Ordering::Relaxed);
tel.present_errors.store(6, Ordering::Relaxed);
tel.stale_epoch_discards.store(7, Ordering::Relaxed);
tel.shutdown_timeouts.store(8, Ordering::Relaxed);
tel.worker_panics.store(9, Ordering::Relaxed);
tel.last_produced_frame_id.store(10, Ordering::Relaxed);
tel.last_consumed_frame_id.store(11, Ordering::Relaxed);
tel.last_published_frame_id.store(12, Ordering::Relaxed);
tel.last_presented_frame_id.store(13, Ordering::Relaxed);
tel.last_dropped_frame_id.store(14, Ordering::Relaxed);
tel.last_error_frame_id.store(15, Ordering::Relaxed);
tel.active_render_epoch.store(16, Ordering::Relaxed);
let worker = tel.snapshot().render_worker_telemetry();
assert_eq!(worker.produced_submissions, 2);
assert_eq!(worker.replaced_before_consume, 1);
assert_eq!(worker.consumed_submissions, 1);
assert_eq!(worker.published_frames, 1);
assert_eq!(worker.presented_frames, 1);
assert_eq!(worker.repeated_frames, 3);
assert_eq!(worker.render_failures, 4);
assert_eq!(worker.publish_failures, 5);
assert_eq!(worker.present_failures, 6);
assert_eq!(worker.stale_submission_discards, 7);
assert_eq!(worker.shutdown_timeouts, 8);
assert_eq!(worker.worker_panics, 9);
assert_eq!(worker.last_produced_frame_id, 10);
assert_eq!(worker.last_consumed_frame_id, 11);
assert_eq!(worker.last_published_frame_id, 12);
assert_eq!(worker.last_presented_frame_id, 13);
assert_eq!(worker.last_dropped_frame_id, 14);
assert_eq!(worker.last_error_frame_id, 15);
assert_eq!(worker.active_render_epoch, 16);
}
}

View File

@ -6,8 +6,12 @@ mod services;
pub use crash_report::CrashReport;
pub use os::{LifecycleError, LifecycleOperation, SystemOS};
pub use programs::{NativeShellApp, PrometeuHub, SystemProfileAction, SystemProfileUpdate};
pub use prometeu_hal::{RenderWorkerBackend, RenderWorkerFrameSink};
pub use services::fs;
pub use services::process;
pub use services::task;
pub use services::vm_runtime::VirtualMachineRuntime;
pub use services::vm_runtime::{
LatestRenderFrameStore, RenderWorkerConfig, RenderWorkerController, RenderWorkerHandoff,
RenderWorkerHandoffWait, RenderWorkerOwnership, VirtualMachineRuntime,
};
pub use services::windows;

View File

@ -1,9 +1,13 @@
use crate::CrashReport;
use crate::os::SystemOS;
use crate::{RenderWorkerConfig, RenderWorkerController};
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::cartridge::Cartridge;
use prometeu_hal::telemetry::{CertificationConfig, TelemetryFrame};
use prometeu_hal::{HardwareBridge, InputSignals};
use prometeu_hal::{
FrameId, InputSignals, RenderWorkerBackend, RenderWorkerError, RenderWorkerFrameSink,
RuntimePlatform,
};
use prometeu_vm::VirtualMachine;
use std::sync::atomic::Ordering;
@ -25,7 +29,7 @@ impl<'a> VmFacade<'a> {
&mut self,
vm: &mut VirtualMachine,
signals: &InputSignals,
hw: &mut dyn HardwareBridge,
platform: &mut dyn RuntimePlatform,
) -> Option<CrashReport> {
self.os.vm_runtime.tick(
&mut self.os.log_service,
@ -36,7 +40,7 @@ impl<'a> VmFacade<'a> {
&mut self.os.next_handle,
vm,
signals,
hw,
platform,
)
}
@ -47,9 +51,9 @@ impl<'a> VmFacade<'a> {
pub fn debug_step_instruction(
&mut self,
vm: &mut VirtualMachine,
hw: &mut dyn HardwareBridge,
platform: &mut dyn RuntimePlatform,
) -> Option<CrashReport> {
self.os.vm_runtime.debug_step_instruction(&mut self.os.log_service, vm, hw)
self.os.vm_runtime.debug_step_instruction(&mut self.os.log_service, vm, platform)
}
pub fn paused(&self) -> bool {
@ -96,6 +100,26 @@ impl<'a> VmFacade<'a> {
self.os.vm_runtime.atomic_telemetry.snapshot()
}
pub fn start_render_worker<B, S>(&mut self, config: RenderWorkerConfig, backend: B, sink: S)
where
B: RenderWorkerBackend,
S: RenderWorkerFrameSink,
{
self.os.vm_runtime.start_render_worker(config, backend, sink);
}
pub fn stop_render_worker(&mut self) -> Result<(), RenderWorkerError> {
self.os.vm_runtime.stop_render_worker()
}
pub fn render_worker_controller(&self) -> Option<&RenderWorkerController> {
self.os.vm_runtime.render_worker_controller.as_ref()
}
pub fn record_repeated_render_worker_frame(&self, frame_id: FrameId) {
self.os.vm_runtime.record_repeated_render_worker_frame(frame_id);
}
pub fn cert_config(&self) -> &CertificationConfig {
&self.os.vm_runtime.certifier.config
}

View File

@ -2,7 +2,8 @@ use crate::{CrashReport, SystemOS};
use prometeu_hal::color::Color;
use prometeu_hal::primitives::Rect;
use prometeu_hal::{
FrameId, GfxUiCommand, HardwareBridge, InputSignals, RenderSubmission, ShellUiFramePacket,
FrameId, GfxUiCommand, InputPlatform, InputSignals, RenderSubmission, RuntimePlatform,
ShellUiFramePacket,
};
use prometeu_vm::VirtualMachine;
@ -100,31 +101,35 @@ impl PrometeuHub {
pub fn gui_update(
&mut self,
os: &mut SystemOS,
hw: &mut dyn HardwareBridge,
platform: &mut dyn RuntimePlatform,
) -> Option<SystemProfileAction> {
let in_shell = os.windows().focused_window().is_some();
if !in_shell {
if !self.home_input_armed {
self.home_input_armed = !activation_input_down(hw);
self.home_input_armed = !activation_input_down(platform.input());
return None;
}
} else {
self.home_input_armed = false;
}
if let Some(action) =
action_for_click(in_shell, hw.touch().x(), hw.touch().y(), hw.touch().f().pressed)
{
let input = platform.input();
if let Some(action) = action_for_click(
in_shell,
input.touch().x(),
input.touch().y(),
input.touch().f().pressed,
) {
return Some(action);
}
None
}
pub fn render(&mut self, os: &mut SystemOS, hw: &mut dyn HardwareBridge) {
let pointer_x = hw.touch().x();
let pointer_y = hw.touch().y();
pub fn render(&mut self, os: &mut SystemOS, platform: &mut dyn RuntimePlatform) {
let pointer_x = platform.input().touch().x();
let pointer_y = platform.input().touch().y();
let packet = if os.windows().window_count() == 0 {
render_home_packet(pointer_x, pointer_y)
@ -142,7 +147,10 @@ impl PrometeuHub {
}
ShellUiFramePacket::new(commands)
};
hw.publish_render_submission(&RenderSubmission::shell_ui(FrameId::ZERO, packet));
platform
.render_submission_sink()
.submit_render_submission(RenderSubmission::shell_ui(FrameId::ZERO, packet))
.expect("hub render submission should publish");
}
pub fn update_shell_profile(
@ -150,20 +158,20 @@ impl PrometeuHub {
os: &mut SystemOS,
vm: &mut VirtualMachine,
signals: &InputSignals,
hw: &mut dyn HardwareBridge,
platform: &mut dyn RuntimePlatform,
) -> SystemProfileUpdate {
let mut crash = None;
let mut action = self.gui_update(os, hw);
let mut action = self.gui_update(os, platform);
if os.windows().focused_window().is_some() {
if hw.pad().start().down {
if platform.input().pad().start().down {
action = Some(SystemProfileAction::CloseShell);
} else if action != Some(SystemProfileAction::CloseShell) {
crash = os.vm().tick(vm, signals, hw);
crash = os.vm().tick(vm, signals, platform);
}
}
self.render(os, hw);
self.render(os, platform);
SystemProfileUpdate { crash, action }
}
@ -188,8 +196,11 @@ fn rect_contains(rect: Rect, x: i32, y: i32) -> bool {
x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h
}
fn activation_input_down(hw: &dyn HardwareBridge) -> bool {
hw.touch().f().down || hw.pad().a().down || hw.pad().b().down || hw.pad().start().down
fn activation_input_down(input: &dyn InputPlatform) -> bool {
input.touch().f().down
|| input.pad().a().down
|| input.pad().b().down
|| input.pad().start().down
}
fn render_home_packet(pointer_x: i32, pointer_y: i32) -> ShellUiFramePacket {

View File

@ -0,0 +1,236 @@
use super::FrameScheduler;
use super::frame_scheduler::FrameAuthorizationError;
use super::render_manager::{
LocalRenderWorker, RenderConsumeOutcome, RenderConsumerPath, RenderExecutionPolicy,
RenderPacingPolicy, RenderPolicy, RenderRuntimeCapabilities, RenderSurface,
};
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::telemetry::AtomicTelemetry;
use prometeu_hal::{
FrameId, Game2DFramePacket, RenderOwnership, RenderSubmission, RenderSubmissionPacket,
ShellUiFramePacket,
};
use std::sync::Arc;
use std::sync::atomic::AtomicU32;
use super::render_manager::RenderManager;
struct ContractMatrixRow {
invariant: &'static str,
validations: &'static [&'static str],
}
const ASYNC_RENDER_CONTRACT_MATRIX: &[ContractMatrixRow] = &[
ContractMatrixRow {
invariant: "complete packet and AppMode policy are explicit",
validations: &["matrix_packet_policy_and_pacing_are_mode_specific"],
},
ContractMatrixRow {
invariant: "handoff is single-slot latest-wins in sync and worker-capable paths",
validations: &["matrix_sync_and_worker_paths_share_latest_wins_handoff_contract"],
},
ContractMatrixRow {
invariant: "render consumers see resource banks through read-only access",
validations: &[
"prometeu-drivers::render_resource_access_traits_are_thread_safe",
"prometeu-drivers::render_resource_access_exposes_read_only_glyph_and_scene_banks",
],
},
ContractMatrixRow {
invariant: "Game pacing is frame scheduled and does not catch up skipped frames",
validations: &["matrix_packet_policy_and_pacing_are_mode_specific"],
},
ContractMatrixRow {
invariant: "ownership epoch prevents obsolete work from presenting",
validations: &["matrix_epoch_stale_discard_and_telemetry_sync_remain_consistent"],
},
ContractMatrixRow {
invariant: "telemetry exposes production, drops, consumes, presents, repeats, and errors",
validations: &[
"matrix_epoch_stale_discard_and_telemetry_sync_remain_consistent",
"matrix_shutdown_failure_and_repeat_are_observable",
],
},
ContractMatrixRow {
invariant: "shutdown discards pending work and bounded local worker failure is contained",
validations: &["matrix_shutdown_failure_and_repeat_are_observable"],
},
ContractMatrixRow {
invariant: "active-scene Game2D overlay is composed after layer blit",
validations: &[
"prometeu-drivers::publish_game2d_submission_applies_gfx2d_overlay_after_active_scene",
],
},
];
#[derive(Default)]
struct RecordingSurface {
seen: Vec<FrameId>,
}
impl RenderSurface for RecordingSurface {
fn consume_submission(&mut self, submission: &RenderSubmission) {
self.seen.push(submission.frame_id);
}
}
struct PanickingSurface;
impl RenderSurface for PanickingSurface {
fn consume_submission(&mut self, _submission: &RenderSubmission) {
panic!("matrix present failure");
}
}
fn close_game(manager: &mut RenderManager) {
manager
.close_frame_with_packet(RenderSubmissionPacket::Game2D(Game2DFramePacket::default()))
.expect("game packet must match Game mode");
}
fn close_shell(manager: &mut RenderManager) {
manager
.close_frame_with_packet(RenderSubmissionPacket::ShellUi(ShellUiFramePacket::new(
Vec::new(),
)))
.expect("shell packet must match Shell mode");
}
#[test]
fn async_render_contract_matrix_rows_have_validation() {
assert!(ASYNC_RENDER_CONTRACT_MATRIX.len() >= 7);
for row in ASYNC_RENDER_CONTRACT_MATRIX {
assert!(!row.invariant.is_empty());
assert!(!row.validations.is_empty(), "missing validation for {}", row.invariant);
}
}
#[test]
fn matrix_packet_policy_and_pacing_are_mode_specific() {
let game_policy = RenderPolicy::for_app_mode(AppMode::Game);
assert_eq!(game_policy.pacing, RenderPacingPolicy::FrameScheduled);
assert_eq!(game_policy.execution, RenderExecutionPolicy::WorkerCapableWithLocalFallback);
assert_eq!(
game_policy.resolve_consumer_path(RenderRuntimeCapabilities { game_render_worker: true }),
RenderConsumerPath::RealWorker
);
let shell_policy = RenderPolicy::for_app_mode(AppMode::Shell);
assert_eq!(shell_policy.pacing, RenderPacingPolicy::LifecycleDriven);
assert_eq!(shell_policy.execution, RenderExecutionPolicy::LocalSynchronous);
assert_eq!(
shell_policy.resolve_consumer_path(RenderRuntimeCapabilities { game_render_worker: true }),
RenderConsumerPath::LocalSynchronous
);
let mut game_manager = RenderManager::new(AppMode::Game);
close_game(&mut game_manager);
assert!(
game_manager
.close_frame_with_packet(RenderSubmissionPacket::ShellUi(ShellUiFramePacket::new(
Vec::new()
)))
.is_err()
);
let mut shell_manager = RenderManager::new(AppMode::Shell);
close_shell(&mut shell_manager);
assert!(
shell_manager
.close_frame_with_packet(RenderSubmissionPacket::Game2D(Game2DFramePacket::default()))
.is_err()
);
let mut scheduler = FrameScheduler::default();
assert_eq!(scheduler.authorize_game_frame(), Ok(FrameId::ZERO));
scheduler.record_logical_frame_overrun();
assert_eq!(
scheduler.authorize_game_frame(),
Err(FrameAuthorizationError::GameFrameAlreadyActive { active_frame_id: FrameId::ZERO })
);
assert_eq!(scheduler.complete_game_frame(), Some(FrameId::ZERO));
assert_eq!(scheduler.authorize_game_frame(), Ok(FrameId::new(1)));
}
#[test]
fn matrix_sync_and_worker_paths_share_latest_wins_handoff_contract() {
fn consume_with(path: RenderConsumerPath) -> (RenderConsumeOutcome, Vec<FrameId>, u64) {
let mut manager = RenderManager::new(AppMode::Game);
let mut surface = RecordingSurface::default();
close_game(&mut manager);
close_game(&mut manager);
let outcome = match path {
RenderConsumerPath::LocalSynchronous => manager.consume_latest(&mut surface),
RenderConsumerPath::RealWorker => {
let mut worker = LocalRenderWorker;
worker.consume_latest(&mut manager, &mut surface)
}
};
(outcome, surface.seen, manager.render_telemetry().replaced_before_consume)
}
for path in [RenderConsumerPath::LocalSynchronous, RenderConsumerPath::RealWorker] {
let (outcome, seen, replaced) = consume_with(path);
assert_eq!(outcome, RenderConsumeOutcome::Presented { frame_id: FrameId::new(1) });
assert_eq!(seen, vec![FrameId::new(1)]);
assert_eq!(replaced, 1);
}
}
#[test]
fn matrix_epoch_stale_discard_and_telemetry_sync_remain_consistent() {
let mut manager = RenderManager::new(AppMode::Game);
let mut surface = RecordingSurface::default();
let atomic = AtomicTelemetry::new(Arc::new(AtomicU32::new(0)));
manager.transition_render_owner(AppMode::Game, 7);
close_game(&mut manager);
manager.transition_render_owner(AppMode::Game, 8);
assert_eq!(
manager.consume_latest(&mut surface),
RenderConsumeOutcome::DiscardedStaleEpoch { frame_id: FrameId::ZERO }
);
assert!(surface.seen.is_empty());
manager.sync_telemetry(&atomic);
let snapshot = atomic.snapshot();
assert_eq!(snapshot.produced_submissions, 1);
assert_eq!(snapshot.stale_epoch_discards, 1);
assert_eq!(snapshot.presented_frames, 0);
assert_eq!(snapshot.last_dropped_frame_id, 0);
assert_eq!(snapshot.active_render_epoch, 2);
assert_eq!(manager.active_ownership(), RenderOwnership::new(2, AppMode::Game, 8));
}
#[test]
fn matrix_shutdown_failure_and_repeat_are_observable() {
let mut manager = RenderManager::new(AppMode::Game);
close_game(&mut manager);
let discarded = manager.request_shutdown().expect("pending frame discarded");
assert_eq!(discarded.frame_id, FrameId::ZERO);
manager.resume_consumer();
close_game(&mut manager);
let mut surface = PanickingSurface;
assert_eq!(
manager.consume_latest(&mut surface),
RenderConsumeOutcome::PresentFailed {
frame_id: FrameId::new(1),
message: "matrix present failure".to_string()
}
);
manager.record_repeated_present(FrameId::new(1));
let telemetry = manager.render_telemetry();
assert_eq!(telemetry.shutdown_discards, 1);
assert_eq!(telemetry.present_errors, 1);
assert_eq!(telemetry.presented_frames, 0);
assert_eq!(telemetry.repeated_presents, 1);
assert_eq!(telemetry.last_error_frame_id, 1);
assert_eq!(telemetry.last_presented_frame_id, 1);
}

View File

@ -179,7 +179,7 @@ impl NativeInterface for VmRuntimeHost<'_> {
self.ensure_game_profile_syscall(syscall)?;
let hw = ctx.require_hw()?;
let platform = ctx.require_platform()?;
match syscall {
Syscall::SystemHasCart => unreachable!(),
@ -187,7 +187,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
Syscall::GfxClear => {
let color = self.get_color(expect_int(args, 0)?)?;
self.gfx2d_commands.push(Gfx2dCommand::Clear { color });
hw.gfx_mut().clear(color);
Ok(())
}
Syscall::GfxUiClear => {
@ -199,7 +198,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
}
let color = self.get_color(expect_int(args, 0)?)?;
self.gfxui_commands.push(GfxUiCommand::Clear { color });
hw.gfx_mut().clear(color);
Ok(())
}
Syscall::GfxFillRect => {
@ -210,7 +208,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
let color = self.get_color(expect_int(args, 4)?)?;
self.gfx2d_commands
.push(Gfx2dCommand::FillRect { rect: Rect { x, y, w, h }, color });
hw.gfx_mut().fill_rect(x, y, w, h, color);
Ok(())
}
Syscall::GfxUiFillRect => {
@ -227,7 +224,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
let color = self.get_color(expect_int(args, 4)?)?;
self.gfxui_commands
.push(GfxUiCommand::FillRect { rect: Rect { x, y, w, h }, color });
hw.gfx_mut().fill_rect(x, y, w, h, color);
Ok(())
}
Syscall::GfxDrawLine => {
@ -243,7 +239,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
y1: y2,
color,
});
hw.gfx_mut().draw_line(x1, y1, x2, y2, color);
Ok(())
}
Syscall::GfxUiDrawLine => {
@ -265,7 +260,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
y1: y2,
color,
});
hw.gfx_mut().draw_line(x1, y1, x2, y2, color);
Ok(())
}
Syscall::GfxDrawCircle => {
@ -274,7 +268,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
let r = expect_int(args, 2)? as i32;
let color = self.get_color(expect_int(args, 3)?)?;
self.gfx2d_commands.push(Gfx2dCommand::DrawCircle { x, y, radius: r, color });
hw.gfx_mut().draw_circle(x, y, r, color);
Ok(())
}
Syscall::GfxUiDrawCircle => {
@ -289,7 +282,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
let r = expect_int(args, 2)? as i32;
let color = self.get_color(expect_int(args, 3)?)?;
self.gfxui_commands.push(GfxUiCommand::DrawCircle { x, y, radius: r, color });
hw.gfx_mut().draw_circle(x, y, r, color);
Ok(())
}
Syscall::GfxDrawDisc => {
@ -305,7 +297,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
border_color,
fill_color,
});
hw.gfx_mut().draw_disc(x, y, r, border_color, fill_color);
Ok(())
}
Syscall::GfxUiDrawDisc => {
@ -327,7 +318,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
border_color,
fill_color,
});
hw.gfx_mut().draw_disc(x, y, r, border_color, fill_color);
Ok(())
}
Syscall::GfxDrawSquare => {
@ -342,7 +332,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
border_color,
fill_color,
});
hw.gfx_mut().draw_square(x, y, w, h, border_color, fill_color);
Ok(())
}
Syscall::GfxUiDrawSquare => {
@ -363,7 +352,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
border_color,
fill_color,
});
hw.gfx_mut().draw_square(x, y, w, h, border_color, fill_color);
Ok(())
}
Syscall::GfxDrawText => {
@ -372,7 +360,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
let msg = expect_string(args, 2, "message")?;
let color = self.get_color(expect_int(args, 3)?)?;
self.gfx2d_commands.push(Gfx2dCommand::DrawText { x, y, text: msg.clone(), color });
hw.gfx_mut().draw_text(x, y, &msg, color);
Ok(())
}
Syscall::GfxUiDrawText => {
@ -387,7 +374,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
let msg = expect_string(args, 2, "message")?;
let color = self.get_color(expect_int(args, 3)?)?;
self.gfxui_commands.push(GfxUiCommand::DrawText { x, y, text: msg.clone(), color });
hw.gfx_mut().draw_text(x, y, &msg, color);
Ok(())
}
Syscall::ComposerBindScene => {
@ -399,23 +385,23 @@ impl NativeInterface for VmRuntimeHost<'_> {
}
};
let bound = catch_unwind(AssertUnwindSafe(|| hw.bind_scene(scene_bank_id)))
.map_err(|payload| VmFault::Panic(Self::panic_payload_to_string(payload)))?;
let status = catch_unwind(AssertUnwindSafe(|| {
platform.game2d_frame_composer().bind_scene(scene_bank_id)
}))
.map_err(|payload| VmFault::Panic(Self::panic_payload_to_string(payload)))?;
let status =
if bound { ComposerOpStatus::Ok } else { ComposerOpStatus::SceneUnavailable };
ret.push_int(status as i64);
Ok(())
}
Syscall::ComposerUnbindScene => {
hw.unbind_scene();
platform.game2d_frame_composer().unbind_scene();
ret.push_int(ComposerOpStatus::Ok as i64);
Ok(())
}
Syscall::ComposerSetCamera => {
let x = Self::int_arg_to_i32_trap(expect_int(args, 0)?, "camera x")?;
let y = Self::int_arg_to_i32_trap(expect_int(args, 1)?, "camera y")?;
hw.set_camera(x, y);
platform.game2d_frame_composer().set_camera(x, y);
Ok(())
}
Syscall::ComposerEmitSprite => {
@ -463,12 +449,7 @@ impl NativeInterface for VmRuntimeHost<'_> {
}
};
if !hw.has_glyph_bank(bank_id as usize) {
ret.push_int(ComposerOpStatus::BankInvalid as i64);
return Ok(());
}
let emitted = hw.emit_sprite(Sprite {
let status = platform.game2d_frame_composer().emit_sprite(Sprite {
glyph: Glyph { glyph_id, palette_id },
x,
y,
@ -479,8 +460,6 @@ impl NativeInterface for VmRuntimeHost<'_> {
flip_y,
priority,
});
let status =
if emitted { ComposerOpStatus::Ok } else { ComposerOpStatus::SpriteOverflow };
ret.push_int(status as i64);
Ok(())
}
@ -507,7 +486,7 @@ impl NativeInterface for VmRuntimeHost<'_> {
return Ok(());
}
let status = hw.audio_mut().play(
let status = platform.audio_mut().play(
0,
sample_id_raw as u16,
voice_id_raw as usize,
@ -537,7 +516,8 @@ impl NativeInterface for VmRuntimeHost<'_> {
return Ok(());
}
if hw.assets().slot_info(SlotRef::audio(bank_id as usize)).asset_id.is_none() {
if platform.assets().slot_info(SlotRef::audio(bank_id as usize)).asset_id.is_none()
{
ret.push_int(AudioOpStatus::BankInvalid as i64);
return Ok(());
}
@ -553,7 +533,7 @@ impl NativeInterface for VmRuntimeHost<'_> {
return Ok(());
}
let status = hw.audio_mut().play(
let status = platform.audio_mut().play(
bank_id,
sample_id_raw as u16,
voice_id_raw as usize,
@ -750,7 +730,7 @@ impl NativeInterface for VmRuntimeHost<'_> {
})?;
let slot_index = expect_int(args, 1)? as usize;
match hw.assets().load(asset_id, slot_index) {
match platform.assets().load(asset_id, slot_index) {
Ok(handle) => {
ret.push_int(AssetOpStatus::Ok as i64);
ret.push_int(handle as i64);
@ -764,16 +744,16 @@ impl NativeInterface for VmRuntimeHost<'_> {
}
}
Syscall::AssetStatus => {
ret.push_int(hw.assets().status(expect_int(args, 0)? as u32) as i64);
ret.push_int(platform.assets().status(expect_int(args, 0)? as u32) as i64);
Ok(())
}
Syscall::AssetCommit => {
let status = hw.assets().commit(expect_int(args, 0)? as u32);
let status = platform.assets().commit(expect_int(args, 0)? as u32);
ret.push_int(status as i64);
Ok(())
}
Syscall::AssetCancel => {
let status = hw.assets().cancel(expect_int(args, 0)? as u32);
let status = platform.assets().cancel(expect_int(args, 0)? as u32);
ret.push_int(status as i64);
Ok(())
}
@ -783,7 +763,7 @@ impl NativeInterface for VmRuntimeHost<'_> {
1 => BankType::SOUNDS,
_ => return Err(VmFault::Trap(TRAP_TYPE, "Invalid asset type".to_string())),
};
let telemetry = hw
let telemetry = platform
.assets()
.bank_telemetry()
.into_iter()

View File

@ -0,0 +1,97 @@
use prometeu_hal::FrameId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameAuthorizationError {
GameFrameAlreadyActive { active_frame_id: FrameId },
}
#[derive(Debug, Clone, Default)]
pub struct FrameScheduler {
next_game_frame_id: FrameId,
active_game_frame_id: Option<FrameId>,
completed_game_frames: u64,
logical_frame_overruns: u64,
}
impl FrameScheduler {
pub fn authorize_game_frame(&mut self) -> Result<FrameId, FrameAuthorizationError> {
if let Some(active_frame_id) = self.active_game_frame_id {
return Err(FrameAuthorizationError::GameFrameAlreadyActive { active_frame_id });
}
let frame_id = self.next_game_frame_id;
self.next_game_frame_id = self.next_game_frame_id.next();
self.active_game_frame_id = Some(frame_id);
Ok(frame_id)
}
pub fn complete_game_frame(&mut self) -> Option<FrameId> {
let completed = self.active_game_frame_id.take();
if completed.is_some() {
self.completed_game_frames = self.completed_game_frames.wrapping_add(1);
}
completed
}
pub fn record_logical_frame_overrun(&mut self) {
self.logical_frame_overruns = self.logical_frame_overruns.wrapping_add(1);
}
pub fn active_game_frame_id(&self) -> Option<FrameId> {
self.active_game_frame_id
}
pub fn completed_game_frames(&self) -> u64 {
self.completed_game_frames
}
pub fn logical_frame_overruns(&self) -> u64 {
self.logical_frame_overruns
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn authorizes_one_game_frame_until_completion() {
let mut scheduler = FrameScheduler::default();
assert_eq!(scheduler.authorize_game_frame(), Ok(FrameId::ZERO));
assert_eq!(
scheduler.authorize_game_frame(),
Err(FrameAuthorizationError::GameFrameAlreadyActive { active_frame_id: FrameId::ZERO })
);
assert_eq!(scheduler.complete_game_frame(), Some(FrameId::ZERO));
assert_eq!(scheduler.authorize_game_frame(), Ok(FrameId::new(1)));
}
#[test]
fn completion_is_sequential_without_catch_up_frames() {
let mut scheduler = FrameScheduler::default();
for expected in 0..3 {
let frame_id = scheduler.authorize_game_frame().expect("frame authorization");
assert_eq!(frame_id, FrameId::new(expected));
assert_eq!(scheduler.complete_game_frame(), Some(FrameId::new(expected)));
}
assert_eq!(scheduler.completed_game_frames(), 3);
}
#[test]
fn records_logical_frame_overrun_without_advancing_frame_id() {
let mut scheduler = FrameScheduler::default();
assert_eq!(scheduler.authorize_game_frame(), Ok(FrameId::ZERO));
scheduler.record_logical_frame_overrun();
scheduler.record_logical_frame_overrun();
assert_eq!(scheduler.logical_frame_overruns(), 2);
assert_eq!(scheduler.active_game_frame_id(), Some(FrameId::ZERO));
assert_eq!(scheduler.complete_game_frame(), Some(FrameId::ZERO));
assert_eq!(scheduler.authorize_game_frame(), Ok(FrameId::new(1)));
}
}

View File

@ -3,6 +3,7 @@ use crate::CrashReport;
use crate::fs::{FsBackend, FsState, VirtualFS};
use prometeu_hal::cartridge::Cartridge;
use prometeu_hal::log::{LogLevel, LogSource};
use prometeu_hal::{FrameId, RenderWorkerBackend, RenderWorkerError, RenderWorkerFrameSink};
use std::collections::HashMap;
impl VirtualMachineRuntime {
@ -22,7 +23,12 @@ impl VirtualMachineRuntime {
current_cartridge_title: String::new(),
current_cartridge_app_version: String::new(),
current_cartridge_app_mode: AppMode::Game,
frame_scheduler: FrameScheduler::default(),
render_capabilities: RenderRuntimeCapabilities::default(),
render_manager: RenderManager::new(AppMode::Game),
render_worker_handoff: Arc::new(RenderWorkerHandoff::default()),
render_worker_ownership: RenderWorkerOwnership::default(),
render_worker_controller: None,
gfx2d_commands: Vec::new(),
gfxui_commands: Vec::new(),
logs_written_this_frame: HashMap::new(),
@ -119,7 +125,13 @@ impl VirtualMachineRuntime {
self.current_cartridge_title.clear();
self.current_cartridge_app_version.clear();
self.current_cartridge_app_mode = AppMode::Game;
self.frame_scheduler = FrameScheduler::default();
self.render_capabilities = RenderRuntimeCapabilities::default();
self.render_manager = RenderManager::new(AppMode::Game);
let _ = self.stop_render_worker();
self.render_worker_handoff = Arc::new(RenderWorkerHandoff::default());
self.render_worker_ownership =
RenderWorkerOwnership::new(self.render_manager.active_ownership());
self.gfx2d_commands.clear();
self.gfxui_commands.clear();
self.logs_written_this_frame.clear();
@ -139,6 +151,46 @@ impl VirtualMachineRuntime {
self.clear_cartridge_state();
}
pub fn set_render_runtime_capabilities(&mut self, capabilities: RenderRuntimeCapabilities) {
self.render_capabilities = capabilities;
}
pub fn start_render_worker<B, S>(&mut self, config: RenderWorkerConfig, backend: B, sink: S)
where
B: RenderWorkerBackend,
S: RenderWorkerFrameSink,
{
let _ = self.stop_render_worker();
self.render_worker_handoff = Arc::new(RenderWorkerHandoff::default());
self.render_worker_ownership.set(self.render_manager.active_ownership());
self.render_worker_controller = Some(RenderWorkerController::start(
config,
Arc::clone(&self.render_worker_handoff),
self.render_worker_ownership.clone(),
backend,
sink,
));
self.render_capabilities.game_render_worker = true;
}
pub fn stop_render_worker(&mut self) -> Result<(), RenderWorkerError> {
if let Some(mut controller) = self.render_worker_controller.take() {
controller.stop()
} else {
Ok(())
}
}
pub(crate) fn sync_render_worker_ownership(&self) {
self.render_worker_ownership.set(self.render_manager.active_ownership());
}
pub fn record_repeated_render_worker_frame(&self, frame_id: FrameId) {
if let Some(controller) = self.render_worker_controller.as_ref() {
controller.record_repeated_frame(frame_id);
}
}
pub fn initialize_vm(
&mut self,
log_service: &mut LogService,
@ -154,7 +206,7 @@ impl VirtualMachineRuntime {
self.current_cartridge_title = cartridge.title.clone();
self.current_cartridge_app_version = cartridge.app_version.clone();
self.current_cartridge_app_mode = cartridge.app_mode;
self.render_manager.set_active_app_mode(cartridge.app_mode);
self.render_manager.transition_render_owner(cartridge.app_mode, cartridge.app_id);
Ok(())
}
Err(e) => {

View File

@ -1,18 +1,30 @@
#[cfg(test)]
mod async_render_contract_tests;
mod dispatch;
mod frame_scheduler;
mod lifecycle;
pub mod render_manager;
mod render_worker;
pub mod render_worker_handoff;
#[cfg(test)]
pub(crate) mod render_worker_test_harness;
#[cfg(test)]
mod tests;
mod tick;
use crate::CrashReport;
pub use frame_scheduler::FrameScheduler;
use prometeu_bytecode::string_materialization_count;
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::log::LogService;
use prometeu_hal::telemetry::{AtomicTelemetry, CertificationConfig, Certifier};
use prometeu_hal::{Gfx2dCommand, GfxUiCommand};
use prometeu_vm::VirtualMachine;
pub use render_manager::RenderManager;
pub use render_manager::{RenderManager, RenderRuntimeCapabilities};
pub use render_worker::{
LatestRenderFrameStore, RenderWorkerConfig, RenderWorkerController, RenderWorkerOwnership,
};
pub use render_worker_handoff::{RenderWorkerHandoff, RenderWorkerHandoffWait};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicU32;
@ -28,7 +40,12 @@ pub struct VirtualMachineRuntime {
pub current_cartridge_title: String,
pub current_cartridge_app_version: String,
pub current_cartridge_app_mode: AppMode,
pub frame_scheduler: FrameScheduler,
pub render_capabilities: RenderRuntimeCapabilities,
pub render_manager: RenderManager,
pub render_worker_handoff: Arc<RenderWorkerHandoff>,
pub render_worker_ownership: RenderWorkerOwnership,
pub render_worker_controller: Option<RenderWorkerController>,
pub gfx2d_commands: Vec<Gfx2dCommand>,
pub gfxui_commands: Vec<GfxUiCommand>,
pub logs_written_this_frame: HashMap<u32, u32>,

View File

@ -1,12 +1,16 @@
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::telemetry::AtomicTelemetry;
use prometeu_hal::{
FrameId, Game2DFramePacket, RenderSubmission, RenderSubmissionPacket, ShellUiFramePacket,
FrameId, Game2DFramePacket, RenderOwnership, RenderSubmission, RenderSubmissionPacket,
ShellUiFramePacket,
};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::Ordering;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderTransitionState {
Idle,
Pending { from: AppMode, to: AppMode },
Pending { from: RenderOwnership, to: RenderOwnership },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -14,25 +18,264 @@ pub enum RenderSubmissionError {
PacketAppModeMismatch { active: AppMode },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderConsumerState {
Running,
Stopped,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenderConsumeOutcome {
NoSubmission,
Presented { frame_id: FrameId },
DiscardedStaleEpoch { frame_id: FrameId },
DiscardedShutdown { frame_id: FrameId },
PresentFailed { frame_id: FrameId, message: String },
}
impl RenderConsumeOutcome {
pub fn presented(&self) -> bool {
matches!(self, Self::Presented { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderPacingPolicy {
FrameScheduled,
LifecycleDriven,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderExecutionPolicy {
LocalSynchronous,
WorkerCapableWithLocalFallback,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RenderRuntimeCapabilities {
pub game_render_worker: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderConsumerPath {
LocalSynchronous,
RealWorker,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderPolicy {
pub app_mode: AppMode,
pub pacing: RenderPacingPolicy,
pub execution: RenderExecutionPolicy,
}
impl RenderPolicy {
pub const fn for_app_mode(app_mode: AppMode) -> Self {
match app_mode {
AppMode::Game => Self {
app_mode,
pacing: RenderPacingPolicy::FrameScheduled,
execution: RenderExecutionPolicy::WorkerCapableWithLocalFallback,
},
AppMode::Shell => Self {
app_mode,
pacing: RenderPacingPolicy::LifecycleDriven,
execution: RenderExecutionPolicy::LocalSynchronous,
},
}
}
pub const fn uses_frame_scheduler(self) -> bool {
matches!(self.pacing, RenderPacingPolicy::FrameScheduled)
}
pub const fn resolve_consumer_path(
self,
capabilities: RenderRuntimeCapabilities,
) -> RenderConsumerPath {
match (self.execution, capabilities.game_render_worker) {
(RenderExecutionPolicy::WorkerCapableWithLocalFallback, true) => {
RenderConsumerPath::RealWorker
}
_ => RenderConsumerPath::LocalSynchronous,
}
}
}
pub trait RenderSurface {
fn consume_submission(&mut self, submission: &RenderSubmission);
}
#[derive(Debug, Default)]
#[cfg(test)]
pub struct LocalRenderWorker;
#[cfg(test)]
impl LocalRenderWorker {
pub fn consume_latest<S: RenderSurface>(
&mut self,
manager: &mut RenderManager,
surface: &mut S,
) -> RenderConsumeOutcome {
manager.consume_latest(surface)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RenderTelemetrySnapshot {
pub produced_submissions: u64,
pub replaced_before_consume: u64,
pub consumed_submissions: u64,
pub presented_frames: u64,
pub repeated_presents: u64,
pub render_errors: u64,
pub present_errors: u64,
pub stale_epoch_discards: u64,
pub shutdown_discards: u64,
pub last_produced_frame_id: u64,
pub last_consumed_frame_id: u64,
pub last_presented_frame_id: u64,
pub last_dropped_frame_id: u64,
pub last_error_frame_id: u64,
pub active_render_epoch: u64,
}
#[derive(Debug, Clone, Default)]
struct RenderTelemetry {
snapshot: RenderTelemetrySnapshot,
}
impl RenderTelemetry {
fn record_produced(&mut self, frame_id: FrameId) {
self.snapshot.produced_submissions = self.snapshot.produced_submissions.wrapping_add(1);
self.snapshot.last_produced_frame_id = frame_id.get();
}
fn record_replaced_before_consume(&mut self, frame_id: FrameId) {
self.snapshot.replaced_before_consume =
self.snapshot.replaced_before_consume.wrapping_add(1);
self.snapshot.last_dropped_frame_id = frame_id.get();
}
fn record_consumed(&mut self, frame_id: FrameId) {
self.snapshot.consumed_submissions = self.snapshot.consumed_submissions.wrapping_add(1);
self.snapshot.last_consumed_frame_id = frame_id.get();
}
fn record_presented(&mut self, frame_id: FrameId) {
self.snapshot.presented_frames = self.snapshot.presented_frames.wrapping_add(1);
self.snapshot.last_presented_frame_id = frame_id.get();
}
fn record_repeated_present(&mut self, frame_id: FrameId) {
self.snapshot.repeated_presents = self.snapshot.repeated_presents.wrapping_add(1);
self.snapshot.last_presented_frame_id = frame_id.get();
}
fn record_render_error(&mut self, frame_id: FrameId) {
self.snapshot.render_errors = self.snapshot.render_errors.wrapping_add(1);
self.snapshot.last_error_frame_id = frame_id.get();
}
fn record_present_error(&mut self, frame_id: FrameId) {
self.snapshot.present_errors = self.snapshot.present_errors.wrapping_add(1);
self.snapshot.last_error_frame_id = frame_id.get();
}
fn record_stale_epoch_discard(&mut self, frame_id: FrameId) {
self.snapshot.stale_epoch_discards = self.snapshot.stale_epoch_discards.wrapping_add(1);
self.snapshot.last_dropped_frame_id = frame_id.get();
}
fn record_shutdown_discard(&mut self, frame_id: FrameId) {
self.snapshot.shutdown_discards = self.snapshot.shutdown_discards.wrapping_add(1);
self.snapshot.last_dropped_frame_id = frame_id.get();
}
}
#[derive(Debug, Clone, Default)]
pub struct RenderHandoff {
pending: Option<RenderSubmission>,
last_consumed: Option<RenderSubmission>,
replaced_before_consume: u64,
discarded_pending: u64,
stale_epoch_discards: u64,
}
impl RenderHandoff {
pub fn publish(&mut self, submission: RenderSubmission) {
if self.pending.replace(submission).is_some() {
self.replaced_before_consume = self.replaced_before_consume.wrapping_add(1);
}
}
pub fn pending_submission(&self) -> Option<&RenderSubmission> {
self.pending.as_ref()
}
pub fn latest_submission(&self) -> Option<&RenderSubmission> {
self.pending.as_ref().or(self.last_consumed.as_ref())
}
pub fn take_latest(&mut self) -> Option<RenderSubmission> {
self.pending.take()
}
pub fn record_consumed(&mut self, submission: RenderSubmission) {
self.last_consumed = Some(submission);
}
pub fn record_stale_epoch_discard(&mut self, submission: RenderSubmission) {
self.stale_epoch_discards = self.stale_epoch_discards.wrapping_add(1);
self.last_consumed = Some(submission);
}
pub fn discard_pending(&mut self) -> Option<RenderSubmission> {
let discarded = self.pending.take();
if discarded.is_some() {
self.discarded_pending = self.discarded_pending.wrapping_add(1);
}
discarded
}
pub fn replaced_before_consume(&self) -> u64 {
self.replaced_before_consume
}
pub fn discarded_pending(&self) -> u64 {
self.discarded_pending
}
pub fn stale_epoch_discards(&self) -> u64 {
self.stale_epoch_discards
}
}
#[derive(Debug, Clone)]
pub struct RenderManager {
active_app_mode: AppMode,
active_app_id: u32,
active_ownership: RenderOwnership,
active_policy: RenderPolicy,
next_frame_id: FrameId,
latest_complete_submission: Option<RenderSubmission>,
handoff: RenderHandoff,
telemetry: RenderTelemetry,
transition_state: RenderTransitionState,
consumer_state: RenderConsumerState,
}
impl RenderManager {
pub fn new(active_app_mode: AppMode) -> Self {
Self {
active_app_mode,
active_app_id: 0,
active_ownership: RenderOwnership::new(0, active_app_mode, 0),
active_policy: RenderPolicy::for_app_mode(active_app_mode),
next_frame_id: FrameId::ZERO,
latest_complete_submission: None,
handoff: RenderHandoff::default(),
telemetry: RenderTelemetry::default(),
transition_state: RenderTransitionState::Idle,
consumer_state: RenderConsumerState::Running,
}
}
@ -40,22 +283,137 @@ impl RenderManager {
self.active_app_mode
}
pub fn active_ownership(&self) -> RenderOwnership {
self.active_ownership
}
pub fn active_render_policy(&self) -> RenderPolicy {
self.active_policy
}
pub fn policy_for_app_mode(app_mode: AppMode) -> RenderPolicy {
RenderPolicy::for_app_mode(app_mode)
}
pub fn transition_state(&self) -> RenderTransitionState {
self.transition_state
}
pub fn consumer_state(&self) -> RenderConsumerState {
self.consumer_state
}
pub fn latest_complete_submission(&self) -> Option<&RenderSubmission> {
self.latest_complete_submission.as_ref()
self.handoff.latest_submission()
}
pub fn pending_submission(&self) -> Option<&RenderSubmission> {
self.handoff.pending_submission()
}
pub fn replaced_before_consume(&self) -> u64 {
self.handoff.replaced_before_consume()
}
pub fn discarded_pending(&self) -> u64 {
self.handoff.discarded_pending()
}
pub fn stale_epoch_discards(&self) -> u64 {
self.handoff.stale_epoch_discards()
}
pub fn discard_pending_submission(&mut self) -> Option<RenderSubmission> {
let discarded = self.handoff.discard_pending();
if let Some(submission) = discarded.as_ref() {
self.telemetry.record_shutdown_discard(submission.frame_id);
}
discarded
}
pub fn take_pending_for_worker(&mut self) -> Option<RenderSubmission> {
let submission = self.handoff.take_latest()?;
self.telemetry.record_consumed(submission.frame_id);
Some(submission)
}
pub fn request_shutdown(&mut self) -> Option<RenderSubmission> {
self.consumer_state = RenderConsumerState::Stopped;
self.discard_pending_submission()
}
pub fn resume_consumer(&mut self) {
self.consumer_state = RenderConsumerState::Running;
}
pub fn render_telemetry(&self) -> RenderTelemetrySnapshot {
let mut snapshot = self.telemetry.snapshot;
snapshot.active_render_epoch = self.active_ownership.epoch;
snapshot
}
pub fn sync_telemetry(&self, telemetry: &AtomicTelemetry) {
let snapshot = self.render_telemetry();
telemetry.produced_submissions.store(snapshot.produced_submissions, Ordering::Relaxed);
telemetry
.replaced_before_consume
.store(snapshot.replaced_before_consume, Ordering::Relaxed);
telemetry.consumed_submissions.store(snapshot.consumed_submissions, Ordering::Relaxed);
telemetry.presented_frames.store(snapshot.presented_frames, Ordering::Relaxed);
telemetry.repeated_presents.store(snapshot.repeated_presents, Ordering::Relaxed);
telemetry.render_errors.store(snapshot.render_errors, Ordering::Relaxed);
telemetry.present_errors.store(snapshot.present_errors, Ordering::Relaxed);
telemetry.stale_epoch_discards.store(snapshot.stale_epoch_discards, Ordering::Relaxed);
telemetry.shutdown_discards.store(snapshot.shutdown_discards, Ordering::Relaxed);
telemetry.last_produced_frame_id.store(snapshot.last_produced_frame_id, Ordering::Relaxed);
telemetry.last_consumed_frame_id.store(snapshot.last_consumed_frame_id, Ordering::Relaxed);
telemetry
.last_presented_frame_id
.store(snapshot.last_presented_frame_id, Ordering::Relaxed);
telemetry.last_dropped_frame_id.store(snapshot.last_dropped_frame_id, Ordering::Relaxed);
telemetry.last_error_frame_id.store(snapshot.last_error_frame_id, Ordering::Relaxed);
telemetry.active_render_epoch.store(snapshot.active_render_epoch, Ordering::Relaxed);
}
pub fn record_repeated_present(&mut self, frame_id: FrameId) {
self.telemetry.record_repeated_present(frame_id);
}
pub fn record_render_error(&mut self, frame_id: FrameId) {
self.telemetry.record_render_error(frame_id);
}
pub fn record_present_error(&mut self, frame_id: FrameId) {
self.telemetry.record_present_error(frame_id);
}
fn panic_payload_to_string(payload: Box<dyn std::any::Any + Send>) -> String {
if let Some(message) = payload.downcast_ref::<String>() {
return message.clone();
}
if let Some(message) = payload.downcast_ref::<&str>() {
return (*message).to_string();
}
"render panic without string payload".to_string()
}
pub fn set_active_app_mode(&mut self, app_mode: AppMode) {
if self.active_app_mode == app_mode {
self.transition_render_owner(app_mode, self.active_app_id);
}
pub fn transition_render_owner(&mut self, app_mode: AppMode, app_id: u32) {
if self.active_app_mode == app_mode && self.active_app_id == app_id {
return;
}
let previous = self.active_app_mode;
let previous = self.active_ownership;
self.active_app_mode = app_mode;
self.transition_state = RenderTransitionState::Pending { from: previous, to: app_mode };
self.active_app_id = app_id;
self.active_ownership =
RenderOwnership::new(self.active_ownership.epoch.wrapping_add(1), app_mode, app_id);
self.active_policy = RenderPolicy::for_app_mode(app_mode);
self.transition_state =
RenderTransitionState::Pending { from: previous, to: self.active_ownership };
}
pub fn acknowledge_transition(&mut self) {
@ -69,10 +427,10 @@ impl RenderManager {
let frame_id = self.next_frame_id;
let submission = match (self.active_app_mode, packet) {
(AppMode::Game, RenderSubmissionPacket::Game2D(packet)) => {
RenderSubmission::game2d(frame_id, packet)
RenderSubmission::game2d(frame_id, packet).with_ownership(self.active_ownership)
}
(AppMode::Shell, RenderSubmissionPacket::ShellUi(packet)) => {
RenderSubmission::shell_ui(frame_id, packet)
RenderSubmission::shell_ui(frame_id, packet).with_ownership(self.active_ownership)
}
(active, _) => {
return Err(RenderSubmissionError::PacketAppModeMismatch { active });
@ -80,8 +438,12 @@ impl RenderManager {
};
self.next_frame_id = self.next_frame_id.next();
self.latest_complete_submission = Some(submission);
Ok(self.latest_complete_submission.as_ref().expect("submission was just stored"))
if let Some(replaced) = self.handoff.pending_submission() {
self.telemetry.record_replaced_before_consume(replaced.frame_id);
}
self.telemetry.record_produced(frame_id);
self.handoff.publish(submission);
Ok(self.handoff.pending_submission().expect("submission was just stored"))
}
pub fn close_compat_frame(&mut self) -> &RenderSubmission {
@ -93,12 +455,43 @@ impl RenderManager {
self.close_frame_with_packet(packet).expect("compat packet matches active app mode")
}
pub fn publish_latest<S: RenderSurface>(&self, surface: &mut S) -> bool {
let Some(submission) = self.latest_complete_submission.as_ref() else {
return false;
pub fn consume_latest<S: RenderSurface>(&mut self, surface: &mut S) -> RenderConsumeOutcome {
if self.consumer_state == RenderConsumerState::Stopped {
if let Some(discarded) = self.discard_pending_submission() {
return RenderConsumeOutcome::DiscardedShutdown { frame_id: discarded.frame_id };
}
return RenderConsumeOutcome::NoSubmission;
}
let Some(submission) = self.handoff.take_latest() else {
return RenderConsumeOutcome::NoSubmission;
};
surface.consume_submission(submission);
true
if submission.ownership != self.active_ownership {
self.telemetry.record_stale_epoch_discard(submission.frame_id);
let frame_id = submission.frame_id;
self.handoff.record_stale_epoch_discard(submission);
return RenderConsumeOutcome::DiscardedStaleEpoch { frame_id };
}
self.telemetry.record_consumed(submission.frame_id);
let present_result =
catch_unwind(AssertUnwindSafe(|| surface.consume_submission(&submission)));
if let Err(payload) = present_result {
let frame_id = submission.frame_id;
self.telemetry.record_present_error(frame_id);
self.handoff.record_consumed(submission);
return RenderConsumeOutcome::PresentFailed {
frame_id,
message: Self::panic_payload_to_string(payload),
};
}
self.telemetry.record_presented(submission.frame_id);
let frame_id = submission.frame_id;
self.handoff.record_consumed(submission);
RenderConsumeOutcome::Presented { frame_id }
}
pub fn publish_latest<S: RenderSurface>(&mut self, surface: &mut S) -> bool {
self.consume_latest(surface).presented()
}
}
@ -111,6 +504,8 @@ impl Default for RenderManager {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::AtomicU32;
#[derive(Default)]
struct RecordingSurface {
@ -123,6 +518,15 @@ mod tests {
}
}
#[derive(Default)]
struct PanickingSurface;
impl RenderSurface for PanickingSurface {
fn consume_submission(&mut self, _submission: &RenderSubmission) {
panic!("present backend failed");
}
}
#[test]
fn frame_closure_assigns_monotonic_frame_ids() {
let mut manager = RenderManager::new(AppMode::Game);
@ -161,19 +565,98 @@ mod tests {
#[test]
fn app_mode_switch_records_noop_transition_placeholder() {
let mut manager = RenderManager::new(AppMode::Game);
let previous = manager.active_ownership();
manager.set_active_app_mode(AppMode::Shell);
assert_eq!(manager.active_app_mode(), AppMode::Shell);
assert_eq!(
manager.transition_state(),
RenderTransitionState::Pending { from: AppMode::Game, to: AppMode::Shell }
RenderTransitionState::Pending {
from: previous,
to: RenderOwnership::new(1, AppMode::Shell, 0)
}
);
manager.acknowledge_transition();
assert_eq!(manager.transition_state(), RenderTransitionState::Idle);
}
#[test]
fn transition_render_owner_increments_epoch_for_same_mode_app_swap() {
let mut manager = RenderManager::new(AppMode::Game);
manager.transition_render_owner(AppMode::Game, 7);
let first_owner = manager.active_ownership();
manager.transition_render_owner(AppMode::Game, 8);
assert_eq!(first_owner, RenderOwnership::new(1, AppMode::Game, 7));
assert_eq!(manager.active_ownership(), RenderOwnership::new(2, AppMode::Game, 8));
}
#[test]
fn close_frame_stamps_submission_with_active_ownership() {
let mut manager = RenderManager::new(AppMode::Game);
manager.transition_render_owner(AppMode::Game, 42);
let submission = manager.close_compat_frame();
assert_eq!(submission.ownership, RenderOwnership::new(1, AppMode::Game, 42));
}
#[test]
fn render_policy_maps_game_to_frame_scheduled_worker_capable() {
let policy = RenderPolicy::for_app_mode(AppMode::Game);
assert_eq!(policy.pacing, RenderPacingPolicy::FrameScheduled);
assert_eq!(policy.execution, RenderExecutionPolicy::WorkerCapableWithLocalFallback);
assert!(policy.uses_frame_scheduler());
}
#[test]
fn render_policy_maps_shell_to_lifecycle_driven_local_sync() {
let policy = RenderPolicy::for_app_mode(AppMode::Shell);
assert_eq!(policy.pacing, RenderPacingPolicy::LifecycleDriven);
assert_eq!(policy.execution, RenderExecutionPolicy::LocalSynchronous);
assert!(!policy.uses_frame_scheduler());
}
#[test]
fn render_policy_resolves_game_worker_only_when_capability_is_enabled() {
let policy = RenderPolicy::for_app_mode(AppMode::Game);
assert_eq!(
policy.resolve_consumer_path(RenderRuntimeCapabilities::default()),
RenderConsumerPath::LocalSynchronous
);
assert_eq!(
policy.resolve_consumer_path(RenderRuntimeCapabilities { game_render_worker: true }),
RenderConsumerPath::RealWorker
);
}
#[test]
fn render_policy_keeps_shell_local_even_when_worker_capability_is_enabled() {
let policy = RenderPolicy::for_app_mode(AppMode::Shell);
assert_eq!(
policy.resolve_consumer_path(RenderRuntimeCapabilities { game_render_worker: true }),
RenderConsumerPath::LocalSynchronous
);
}
#[test]
fn active_render_policy_follows_app_mode_transition() {
let mut manager = RenderManager::new(AppMode::Game);
assert_eq!(manager.active_render_policy(), RenderPolicy::for_app_mode(AppMode::Game));
manager.set_active_app_mode(AppMode::Shell);
assert_eq!(manager.active_render_policy(), RenderPolicy::for_app_mode(AppMode::Shell));
}
#[test]
fn publish_latest_hands_submission_to_surface() {
let mut manager = RenderManager::new(AppMode::Game);
@ -185,4 +668,212 @@ mod tests {
assert_eq!(surface.seen, vec![FrameId::new(0)]);
}
#[test]
fn local_render_worker_consumes_latest_game_submission() {
let mut manager = RenderManager::new(AppMode::Game);
let mut surface = RecordingSurface::default();
let mut worker = LocalRenderWorker;
manager.close_compat_frame();
assert!(worker.consume_latest(&mut manager, &mut surface).presented());
assert_eq!(surface.seen, vec![FrameId::ZERO]);
assert_eq!(manager.render_telemetry().consumed_submissions, 1);
assert_eq!(manager.render_telemetry().presented_frames, 1);
}
#[test]
fn request_shutdown_discards_pending_submission_without_presenting() {
let mut manager = RenderManager::new(AppMode::Game);
manager.close_compat_frame();
let discarded = manager.request_shutdown().expect("pending submission discarded");
assert_eq!(discarded.frame_id, FrameId::ZERO);
assert_eq!(manager.consumer_state(), RenderConsumerState::Stopped);
assert!(manager.pending_submission().is_none());
assert_eq!(manager.discarded_pending(), 1);
assert_eq!(manager.render_telemetry().shutdown_discards, 1);
assert_eq!(manager.render_telemetry().last_dropped_frame_id, 0);
}
#[test]
fn stopped_consumer_discards_new_pending_submission_before_present() {
let mut manager = RenderManager::new(AppMode::Game);
let mut surface = RecordingSurface::default();
manager.request_shutdown();
manager.close_compat_frame();
let outcome = manager.consume_latest(&mut surface);
assert_eq!(outcome, RenderConsumeOutcome::DiscardedShutdown { frame_id: FrameId::ZERO });
assert!(surface.seen.is_empty());
assert_eq!(manager.render_telemetry().shutdown_discards, 1);
assert_eq!(manager.render_telemetry().presented_frames, 0);
}
#[test]
fn resume_consumer_allows_presentation_after_shutdown() {
let mut manager = RenderManager::new(AppMode::Game);
let mut surface = RecordingSurface::default();
manager.request_shutdown();
manager.resume_consumer();
manager.close_compat_frame();
assert!(manager.consume_latest(&mut surface).presented());
assert_eq!(surface.seen, vec![FrameId::ZERO]);
}
#[test]
fn present_panic_is_reported_without_unwinding_to_vm() {
let mut manager = RenderManager::new(AppMode::Game);
let mut surface = PanickingSurface;
manager.close_compat_frame();
let outcome = manager.consume_latest(&mut surface);
assert_eq!(
outcome,
RenderConsumeOutcome::PresentFailed {
frame_id: FrameId::ZERO,
message: "present backend failed".to_string()
}
);
assert_eq!(manager.render_telemetry().consumed_submissions, 1);
assert_eq!(manager.render_telemetry().present_errors, 1);
assert_eq!(manager.render_telemetry().presented_frames, 0);
assert_eq!(manager.render_telemetry().last_error_frame_id, 0);
}
#[test]
fn render_handoff_takes_owned_latest_submission() {
let mut handoff = RenderHandoff::default();
assert!(handoff.take_latest().is_none());
handoff.publish(RenderSubmission::game2d(FrameId::new(7), Game2DFramePacket::default()));
let submission = handoff.take_latest().expect("pending submission");
assert_eq!(submission.frame_id, FrameId::new(7));
assert!(handoff.take_latest().is_none());
}
#[test]
fn render_handoff_counts_replacement_before_consume() {
let mut handoff = RenderHandoff::default();
handoff.publish(RenderSubmission::game2d(FrameId::new(1), Game2DFramePacket::default()));
handoff.publish(RenderSubmission::game2d(FrameId::new(2), Game2DFramePacket::default()));
assert_eq!(handoff.replaced_before_consume(), 1);
assert_eq!(handoff.take_latest().expect("latest").frame_id, FrameId::new(2));
}
#[test]
fn render_handoff_discards_pending_submission() {
let mut handoff = RenderHandoff::default();
assert!(handoff.discard_pending().is_none());
handoff.publish(RenderSubmission::game2d(FrameId::new(3), Game2DFramePacket::default()));
let discarded = handoff.discard_pending().expect("discarded pending");
assert_eq!(discarded.frame_id, FrameId::new(3));
assert_eq!(handoff.discarded_pending(), 1);
assert!(handoff.take_latest().is_none());
}
#[test]
fn publish_latest_consumes_pending_but_keeps_observable_last_submission() {
let mut manager = RenderManager::new(AppMode::Game);
let mut surface = RecordingSurface::default();
manager.close_compat_frame();
assert!(manager.pending_submission().is_some());
assert!(manager.publish_latest(&mut surface));
assert!(manager.pending_submission().is_none());
assert_eq!(
manager.latest_complete_submission().expect("last consumed").frame_id,
FrameId::ZERO
);
}
#[test]
fn publish_latest_discards_stale_epoch_before_present() {
let mut manager = RenderManager::new(AppMode::Game);
let mut surface = RecordingSurface::default();
manager.close_compat_frame();
manager.transition_render_owner(AppMode::Shell, 1);
assert!(!manager.publish_latest(&mut surface));
assert!(surface.seen.is_empty());
assert_eq!(manager.stale_epoch_discards(), 1);
assert_eq!(
manager.latest_complete_submission().expect("stale consumed for diagnostics").ownership,
RenderOwnership::new(0, AppMode::Game, 0)
);
}
#[test]
fn render_telemetry_counts_produced_replaced_consumed_and_presented() {
let mut manager = RenderManager::new(AppMode::Game);
let mut surface = RecordingSurface::default();
manager.close_compat_frame();
manager.close_compat_frame();
assert!(manager.publish_latest(&mut surface));
let telemetry = manager.render_telemetry();
assert_eq!(telemetry.produced_submissions, 2);
assert_eq!(telemetry.replaced_before_consume, 1);
assert_eq!(telemetry.consumed_submissions, 1);
assert_eq!(telemetry.presented_frames, 1);
assert_eq!(telemetry.last_produced_frame_id, 1);
assert_eq!(telemetry.last_dropped_frame_id, 0);
assert_eq!(telemetry.last_consumed_frame_id, 1);
assert_eq!(telemetry.last_presented_frame_id, 1);
}
#[test]
fn render_telemetry_counts_stale_repeat_and_error_hooks() {
let mut manager = RenderManager::new(AppMode::Game);
let mut surface = RecordingSurface::default();
manager.close_compat_frame();
manager.transition_render_owner(AppMode::Shell, 1);
assert!(!manager.publish_latest(&mut surface));
manager.record_repeated_present(FrameId::new(9));
manager.record_render_error(FrameId::new(10));
manager.record_present_error(FrameId::new(11));
let telemetry = manager.render_telemetry();
assert_eq!(telemetry.stale_epoch_discards, 1);
assert_eq!(telemetry.repeated_presents, 1);
assert_eq!(telemetry.render_errors, 1);
assert_eq!(telemetry.present_errors, 1);
assert_eq!(telemetry.last_presented_frame_id, 9);
assert_eq!(telemetry.last_error_frame_id, 11);
}
#[test]
fn render_telemetry_syncs_to_atomic_telemetry() {
let mut manager = RenderManager::new(AppMode::Game);
let mut surface = RecordingSurface::default();
let atomic = AtomicTelemetry::new(Arc::new(AtomicU32::new(0)));
manager.transition_render_owner(AppMode::Game, 5);
manager.close_compat_frame();
assert!(manager.publish_latest(&mut surface));
manager.sync_telemetry(&atomic);
let snapshot = atomic.snapshot();
assert_eq!(snapshot.produced_submissions, 1);
assert_eq!(snapshot.consumed_submissions, 1);
assert_eq!(snapshot.presented_frames, 1);
assert_eq!(snapshot.last_presented_frame_id, 0);
assert_eq!(snapshot.active_render_epoch, 1);
}
}

View File

@ -0,0 +1,509 @@
use crate::services::vm_runtime::{RenderWorkerHandoff, RenderWorkerHandoffWait};
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::{
FrameId, OwnedRgba8888Frame, RenderOwnership, RenderWorkerBackend, RenderWorkerError,
RenderWorkerFrameSink, RenderWorkerTelemetry,
};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderWorkerConfig {
pub shutdown_timeout: Duration,
}
impl Default for RenderWorkerConfig {
fn default() -> Self {
Self { shutdown_timeout: Duration::from_millis(250) }
}
}
#[derive(Debug, Default)]
struct RenderWorkerState {
telemetry: RenderWorkerTelemetry,
last_error: Option<RenderWorkerError>,
}
#[derive(Clone, Debug, Default)]
pub struct LatestRenderFrameStore {
latest: Arc<Mutex<Option<OwnedRgba8888Frame>>>,
}
impl LatestRenderFrameStore {
pub fn publish_frame(&self, frame: OwnedRgba8888Frame) {
*self.latest.lock().unwrap() = Some(frame);
}
pub fn latest_frame(&self) -> Option<OwnedRgba8888Frame> {
self.latest.lock().unwrap().clone()
}
}
impl RenderWorkerFrameSink for LatestRenderFrameStore {
fn publish(&self, frame: OwnedRgba8888Frame) -> Result<(), RenderWorkerError> {
self.publish_frame(frame);
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct RenderWorkerOwnership {
active: Arc<Mutex<RenderOwnership>>,
}
impl Default for RenderWorkerOwnership {
fn default() -> Self {
Self::new(RenderOwnership::new(0, AppMode::Game, 0))
}
}
impl RenderWorkerOwnership {
pub fn new(active: RenderOwnership) -> Self {
Self { active: Arc::new(Mutex::new(active)) }
}
pub fn snapshot(&self) -> RenderOwnership {
*self.active.lock().unwrap()
}
pub fn set(&self, active: RenderOwnership) {
*self.active.lock().unwrap() = active;
}
}
#[derive(Debug)]
pub struct RenderWorkerController {
config: RenderWorkerConfig,
handoff: Arc<RenderWorkerHandoff>,
ownership: RenderWorkerOwnership,
state: Arc<Mutex<RenderWorkerState>>,
handle: Option<JoinHandle<()>>,
done_rx: Receiver<Result<(), RenderWorkerError>>,
}
impl RenderWorkerController {
pub fn start<B, S>(
config: RenderWorkerConfig,
handoff: Arc<RenderWorkerHandoff>,
ownership: RenderWorkerOwnership,
backend: B,
sink: S,
) -> Self
where
B: RenderWorkerBackend,
S: RenderWorkerFrameSink,
{
let state = Arc::new(Mutex::new(RenderWorkerState::default()));
let worker_handoff = Arc::clone(&handoff);
let worker_ownership = ownership.clone();
let worker_state = Arc::clone(&state);
let panic_state = Arc::clone(&state);
let (done_tx, done_rx) = mpsc::channel();
let handle = thread::spawn(move || {
let result = catch_unwind(AssertUnwindSafe(|| {
run_worker_loop(worker_handoff, worker_ownership, worker_state, backend, sink);
}))
.map_err(|_| RenderWorkerError::WorkerPanic);
if let Err(error) = result {
record_error(&panic_state, error, FrameId::ZERO);
}
let _ = done_tx.send(result);
});
Self { config, handoff, ownership, state, handle: Some(handle), done_rx }
}
pub fn stop(&mut self) -> Result<(), RenderWorkerError> {
self.handoff.request_shutdown();
match self.done_rx.recv_timeout(self.config.shutdown_timeout) {
Ok(result) => {
if let Some(handle) = self.handle.take()
&& handle.join().is_err()
{
record_error(&self.state, RenderWorkerError::WorkerPanic, FrameId::ZERO);
return Err(RenderWorkerError::WorkerPanic);
}
result
}
Err(RecvTimeoutError::Timeout) => {
record_error(&self.state, RenderWorkerError::ShutdownTimeout, FrameId::ZERO);
Err(RenderWorkerError::ShutdownTimeout)
}
Err(RecvTimeoutError::Disconnected) => {
record_error(&self.state, RenderWorkerError::InternalFailure, FrameId::ZERO);
Err(RenderWorkerError::InternalFailure)
}
}
}
pub fn telemetry(&self) -> RenderWorkerTelemetry {
let mut telemetry = self.handoff.telemetry();
let state = self.state.lock().unwrap();
telemetry.published_frames = state.telemetry.published_frames;
telemetry.render_failures = state.telemetry.render_failures;
telemetry.publish_failures = state.telemetry.publish_failures;
telemetry.present_failures = state.telemetry.present_failures;
telemetry.stale_submission_discards = state.telemetry.stale_submission_discards;
telemetry.repeated_frames = state.telemetry.repeated_frames;
telemetry.shutdown_timeouts = state.telemetry.shutdown_timeouts;
telemetry.worker_panics = state.telemetry.worker_panics;
telemetry.last_presented_frame_id = state.telemetry.last_presented_frame_id;
telemetry.last_dropped_frame_id = state.telemetry.last_dropped_frame_id;
telemetry.last_published_frame_id = state.telemetry.last_published_frame_id;
telemetry.last_error_frame_id = state.telemetry.last_error_frame_id;
telemetry
}
pub fn last_error(&self) -> Option<RenderWorkerError> {
self.state.lock().unwrap().last_error
}
pub fn active_ownership(&self) -> RenderOwnership {
self.ownership.snapshot()
}
pub fn record_repeated_frame(&self, frame_id: FrameId) {
let mut state = self.state.lock().unwrap();
state.telemetry.record_repeated_frame(frame_id.get());
}
}
impl Drop for RenderWorkerController {
fn drop(&mut self) {
if self.handle.is_some() {
let _ = self.stop();
}
}
}
fn run_worker_loop<B, S>(
handoff: Arc<RenderWorkerHandoff>,
ownership: RenderWorkerOwnership,
state: Arc<Mutex<RenderWorkerState>>,
backend: B,
sink: S,
) where
B: RenderWorkerBackend,
S: RenderWorkerFrameSink,
{
loop {
let submission = match handoff.wait_take() {
RenderWorkerHandoffWait::Submission(submission) => submission,
RenderWorkerHandoffWait::Shutdown => return,
};
let frame_id = submission.frame_id;
let render_result = catch_unwind(AssertUnwindSafe(|| backend.render(&submission)))
.map_err(|_| RenderWorkerError::WorkerPanic);
let frame = match render_result {
Ok(Ok(frame)) => frame,
Ok(Err(error)) | Err(error) => {
record_error(&state, error, frame_id);
continue;
}
};
if frame.ownership != ownership.snapshot() {
record_stale_discard(&state, frame_id);
continue;
}
let publish_result = catch_unwind(AssertUnwindSafe(|| sink.publish(frame)))
.map_err(|_| RenderWorkerError::WorkerPanic);
match publish_result {
Ok(Ok(())) => record_published(&state, frame_id),
Ok(Err(error)) | Err(error) => record_error(&state, error, frame_id),
}
}
}
fn record_published(state: &Arc<Mutex<RenderWorkerState>>, frame_id: FrameId) {
let mut state = state.lock().unwrap();
state.telemetry.record_published(frame_id.get());
}
fn record_stale_discard(state: &Arc<Mutex<RenderWorkerState>>, frame_id: FrameId) {
state.lock().unwrap().telemetry.record_stale_submission_discarded(frame_id.get());
}
fn record_error(
state: &Arc<Mutex<RenderWorkerState>>,
error: RenderWorkerError,
frame_id: FrameId,
) {
let mut state = state.lock().unwrap();
state.last_error = Some(error);
state.telemetry.record_error(error, frame_id.get());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::vm_runtime::VirtualMachineRuntime;
use crate::services::vm_runtime::render_worker_test_harness::{
FakeRenderBackend, RenderGate, game_submission,
};
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::{
FrameId, Game2DFramePacket, OwnedRgba8888Frame, RenderOwnership, RenderSubmission,
};
#[derive(Clone, Debug)]
struct SharedFakeBackend(Arc<FakeRenderBackend>);
impl RenderWorkerBackend for SharedFakeBackend {
fn render(
&self,
submission: &RenderSubmission,
) -> Result<OwnedRgba8888Frame, RenderWorkerError> {
self.0.render(submission)
}
}
impl RenderWorkerFrameSink for SharedFakeBackend {
fn publish(&self, frame: OwnedRgba8888Frame) -> Result<(), RenderWorkerError> {
self.0.publish(frame);
Ok(())
}
}
#[derive(Debug, Default)]
struct PanickingBackend;
impl RenderWorkerBackend for PanickingBackend {
fn render(
&self,
_submission: &RenderSubmission,
) -> Result<OwnedRgba8888Frame, RenderWorkerError> {
panic!("backend panic")
}
}
#[derive(Debug, Default)]
struct NoopSink;
impl RenderWorkerFrameSink for NoopSink {
fn publish(&self, _frame: OwnedRgba8888Frame) -> Result<(), RenderWorkerError> {
Ok(())
}
}
#[test]
fn latest_render_frame_store_is_sink_and_observable_publication_source() {
let store = LatestRenderFrameStore::default();
let first = OwnedRgba8888Frame::packed(
FrameId::new(1),
RenderOwnership::new(1, AppMode::Game, 1),
1,
1,
vec![0xFF0000FF],
)
.expect("first frame");
let second = OwnedRgba8888Frame::packed(
FrameId::new(2),
RenderOwnership::new(1, AppMode::Game, 1),
1,
1,
vec![0x00FF00FF],
)
.expect("second frame");
store.publish(first).expect("publish first");
assert_eq!(store.latest_frame().expect("first latest").frame_id, FrameId::new(1));
store.publish(second).expect("publish second");
let latest = store.latest_frame().expect("second latest");
assert_eq!(latest.frame_id, FrameId::new(2));
assert_eq!(latest.pixels, vec![0x00FF00FF]);
}
#[test]
fn render_worker_controller_starts_and_stops_waiting_worker() {
let handoff = Arc::new(RenderWorkerHandoff::default());
let backend = Arc::new(FakeRenderBackend::default());
let mut controller = RenderWorkerController::start(
RenderWorkerConfig::default(),
Arc::clone(&handoff),
RenderWorkerOwnership::default(),
SharedFakeBackend(Arc::clone(&backend)),
SharedFakeBackend(backend),
);
assert_eq!(controller.stop(), Ok(()));
assert_eq!(controller.last_error(), None);
}
#[test]
fn render_worker_controller_renders_and_publishes_submission() {
let handoff = Arc::new(RenderWorkerHandoff::default());
let backend = Arc::new(FakeRenderBackend::default());
let mut controller = RenderWorkerController::start(
RenderWorkerConfig::default(),
Arc::clone(&handoff),
RenderWorkerOwnership::new(RenderOwnership::new(1, AppMode::Game, 1)),
SharedFakeBackend(Arc::clone(&backend)),
SharedFakeBackend(Arc::clone(&backend)),
);
handoff.publish(game_submission(11, 1, 1));
assert_eq!(controller.stop(), Ok(()));
let frames = backend.published_frames();
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].frame_id, FrameId::new(11));
assert_eq!(controller.telemetry().published_frames, 1);
}
#[test]
fn render_worker_controller_reports_shutdown_timeout() {
let handoff = Arc::new(RenderWorkerHandoff::default());
let gate = RenderGate::default();
let backend = Arc::new(FakeRenderBackend::with_gate(gate.clone()));
let mut controller = RenderWorkerController::start(
RenderWorkerConfig { shutdown_timeout: Duration::from_millis(1) },
Arc::clone(&handoff),
RenderWorkerOwnership::default(),
SharedFakeBackend(Arc::clone(&backend)),
SharedFakeBackend(Arc::clone(&backend)),
);
handoff.publish(game_submission(12, 1, 1));
gate.wait_for_entries(1);
assert_eq!(controller.stop(), Err(RenderWorkerError::ShutdownTimeout));
assert_eq!(controller.last_error(), Some(RenderWorkerError::ShutdownTimeout));
assert_eq!(controller.telemetry().shutdown_timeouts, 1);
gate.release_one();
controller.config.shutdown_timeout = Duration::from_millis(250);
assert_eq!(controller.stop(), Ok(()));
}
#[test]
fn render_worker_controller_captures_backend_panic_as_typed_failure() {
let handoff = Arc::new(RenderWorkerHandoff::default());
let mut controller = RenderWorkerController::start(
RenderWorkerConfig::default(),
Arc::clone(&handoff),
RenderWorkerOwnership::default(),
PanickingBackend,
NoopSink,
);
handoff.publish(game_submission(13, 1, 1));
assert_eq!(controller.stop(), Ok(()));
assert_eq!(controller.last_error(), Some(RenderWorkerError::WorkerPanic));
assert_eq!(controller.telemetry().worker_panics, 1);
assert_eq!(controller.telemetry().last_error_frame_id, 13);
}
#[test]
fn render_worker_controller_discards_stale_frame_before_publish() {
let handoff = Arc::new(RenderWorkerHandoff::default());
let gate = RenderGate::default();
let backend = Arc::new(FakeRenderBackend::with_gate(gate.clone()));
let ownership = RenderWorkerOwnership::new(RenderOwnership::new(1, AppMode::Game, 1));
let mut controller = RenderWorkerController::start(
RenderWorkerConfig::default(),
Arc::clone(&handoff),
ownership.clone(),
SharedFakeBackend(Arc::clone(&backend)),
SharedFakeBackend(Arc::clone(&backend)),
);
handoff.publish(game_submission(21, 1, 1));
gate.wait_for_entries(1);
ownership.set(RenderOwnership::new(2, AppMode::Game, 1));
gate.release_one();
assert_eq!(controller.stop(), Ok(()));
assert!(backend.published_frames().is_empty());
assert_eq!(controller.telemetry().stale_submission_discards, 1);
}
#[test]
fn render_worker_controller_repeats_latest_published_frame_without_rendering() {
let handoff = Arc::new(RenderWorkerHandoff::default());
let backend = Arc::new(FakeRenderBackend::default());
let store = LatestRenderFrameStore::default();
let mut controller = RenderWorkerController::start(
RenderWorkerConfig::default(),
Arc::clone(&handoff),
RenderWorkerOwnership::new(RenderOwnership::new(1, AppMode::Game, 1)),
SharedFakeBackend(Arc::clone(&backend)),
store.clone(),
);
handoff.publish(game_submission(22, 1, 1));
assert_eq!(controller.stop(), Ok(()));
let latest = store.latest_frame().expect("latest frame");
controller.record_repeated_frame(latest.frame_id);
let repeated = store.latest_frame().expect("repeated frame");
assert_eq!(latest, repeated);
assert!(backend.published_frames().is_empty());
assert_eq!(controller.telemetry().repeated_frames, 1);
}
#[test]
fn runtime_publishes_game_submissions_to_worker_without_waiting_for_render() {
fn publish_pending(runtime: &mut VirtualMachineRuntime) -> bool {
let Some(submission) = runtime.render_manager.take_pending_for_worker() else {
return false;
};
runtime.render_worker_handoff.publish(submission);
true
}
let gate = RenderGate::default();
let backend = Arc::new(FakeRenderBackend::with_gate(gate.clone()));
let mut runtime = VirtualMachineRuntime::new(None);
runtime.start_render_worker(
RenderWorkerConfig::default(),
SharedFakeBackend(Arc::clone(&backend)),
SharedFakeBackend(Arc::clone(&backend)),
);
runtime
.render_manager
.close_frame_with_packet(prometeu_hal::RenderSubmissionPacket::Game2D(
Game2DFramePacket::default(),
))
.expect("game packet should close");
assert!(publish_pending(&mut runtime));
gate.wait_for_entries(1);
runtime
.render_manager
.close_frame_with_packet(prometeu_hal::RenderSubmissionPacket::Game2D(
Game2DFramePacket::default(),
))
.expect("second game packet should close");
assert!(publish_pending(&mut runtime));
runtime
.render_manager
.close_frame_with_packet(prometeu_hal::RenderSubmissionPacket::Game2D(
Game2DFramePacket::default(),
))
.expect("third game packet should close");
assert!(publish_pending(&mut runtime));
assert_eq!(runtime.render_worker_handoff.telemetry().replaced_before_consume, 1);
gate.release_one();
gate.wait_for_entries(2);
gate.release_one();
assert_eq!(runtime.stop_render_worker(), Ok(()));
let frames = backend.published_frames();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].frame_id, FrameId::ZERO);
assert_eq!(frames[1].frame_id, FrameId::new(2));
}
}

View File

@ -0,0 +1,172 @@
use prometeu_hal::{RenderSubmission, RenderWorkerTelemetry};
use std::sync::{Condvar, Mutex};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenderWorkerHandoffWait {
Submission(RenderSubmission),
Shutdown,
}
#[derive(Debug, Default)]
struct RenderWorkerHandoffState {
pending: Option<RenderSubmission>,
shutdown_requested: bool,
telemetry: RenderWorkerTelemetry,
}
#[derive(Debug, Default)]
pub struct RenderWorkerHandoff {
state: Mutex<RenderWorkerHandoffState>,
ready: Condvar,
}
impl RenderWorkerHandoff {
pub fn publish(&self, submission: RenderSubmission) {
let mut state = self.state.lock().unwrap();
if let Some(replaced) = state.pending.replace(submission) {
state.telemetry.record_replaced_before_consume(replaced.frame_id.get());
}
let frame_id = state.pending.as_ref().expect("submission was just stored").frame_id;
state.telemetry.record_produced(frame_id.get());
self.ready.notify_one();
}
pub fn take_latest(&self) -> Option<RenderSubmission> {
let mut state = self.state.lock().unwrap();
let submission = state.pending.take()?;
state.telemetry.record_consumed(submission.frame_id.get());
Some(submission)
}
pub fn wait_take(&self) -> RenderWorkerHandoffWait {
self.wait_take_with_hook(|| {})
}
pub fn request_shutdown(&self) {
let mut state = self.state.lock().unwrap();
state.shutdown_requested = true;
self.ready.notify_all();
}
pub fn telemetry(&self) -> RenderWorkerTelemetry {
self.state.lock().unwrap().telemetry
}
fn wait_take_with_hook(&self, mut before_wait: impl FnMut()) -> RenderWorkerHandoffWait {
let mut state = self.state.lock().unwrap();
loop {
if let Some(submission) = state.pending.take() {
state.telemetry.record_consumed(submission.frame_id.get());
return RenderWorkerHandoffWait::Submission(submission);
}
if state.shutdown_requested {
return RenderWorkerHandoffWait::Shutdown;
}
before_wait();
state = self.ready.wait(state).unwrap();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use prometeu_hal::{FrameId, Game2DFramePacket};
use std::sync::Arc;
use std::sync::mpsc;
use std::thread;
fn submission(frame_id: u64) -> RenderSubmission {
RenderSubmission::game2d(FrameId::new(frame_id), Game2DFramePacket::default())
}
#[test]
fn render_worker_handoff_takes_owned_submission() {
let handoff = RenderWorkerHandoff::default();
assert!(handoff.take_latest().is_none());
handoff.publish(submission(7));
let taken = handoff.take_latest().expect("pending submission");
assert_eq!(taken.frame_id, FrameId::new(7));
assert!(handoff.take_latest().is_none());
assert_eq!(handoff.telemetry().produced_submissions, 1);
assert_eq!(handoff.telemetry().consumed_submissions, 1);
}
#[test]
fn render_worker_handoff_wait_take_returns_pending_without_blocking() {
let handoff = RenderWorkerHandoff::default();
handoff.publish(submission(8));
assert_eq!(handoff.wait_take(), RenderWorkerHandoffWait::Submission(submission(8)));
assert!(handoff.take_latest().is_none());
}
#[test]
fn render_worker_handoff_is_latest_wins_and_counts_replacements() {
let handoff = RenderWorkerHandoff::default();
handoff.publish(submission(1));
handoff.publish(submission(2));
handoff.publish(submission(3));
let taken = handoff.take_latest().expect("latest submission");
let telemetry = handoff.telemetry();
assert_eq!(taken.frame_id, FrameId::new(3));
assert_eq!(telemetry.produced_submissions, 3);
assert_eq!(telemetry.replaced_before_consume, 2);
assert_eq!(telemetry.consumed_submissions, 1);
assert_eq!(telemetry.last_produced_frame_id, 3);
assert_eq!(telemetry.last_dropped_frame_id, 2);
}
#[test]
fn render_worker_handoff_waits_for_publish_without_timing_delay() {
let handoff = Arc::new(RenderWorkerHandoff::default());
let consumer_handoff = Arc::clone(&handoff);
let (waiting_tx, waiting_rx) = mpsc::channel();
let consumer = thread::spawn(move || {
let mut waiting_tx = Some(waiting_tx);
consumer_handoff.wait_take_with_hook(|| {
if let Some(tx) = waiting_tx.take() {
tx.send(()).unwrap();
}
})
});
waiting_rx.recv().expect("consumer reached condvar wait");
handoff.publish(submission(42));
assert_eq!(
consumer.join().expect("consumer thread should finish"),
RenderWorkerHandoffWait::Submission(submission(42))
);
assert_eq!(handoff.telemetry().consumed_submissions, 1);
}
#[test]
fn render_worker_handoff_wait_returns_shutdown_signal() {
let handoff = Arc::new(RenderWorkerHandoff::default());
let consumer_handoff = Arc::clone(&handoff);
let (waiting_tx, waiting_rx) = mpsc::channel();
let consumer = thread::spawn(move || {
let mut waiting_tx = Some(waiting_tx);
consumer_handoff.wait_take_with_hook(|| {
if let Some(tx) = waiting_tx.take() {
tx.send(()).unwrap();
}
})
});
waiting_rx.recv().expect("consumer reached condvar wait");
handoff.request_shutdown();
assert_eq!(
consumer.join().expect("consumer thread should finish"),
RenderWorkerHandoffWait::Shutdown
);
}
}

View File

@ -0,0 +1,173 @@
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::{
FrameId, Game2DFramePacket, OwnedRgba8888Frame, RenderOwnership, RenderSubmission,
RenderWorkerError, ShellUiFramePacket,
};
use std::sync::{Arc, Condvar, Mutex};
#[derive(Debug, Default)]
struct RenderGateState {
entered: u64,
released: u64,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct RenderGate {
state: Arc<(Mutex<RenderGateState>, Condvar)>,
}
impl RenderGate {
pub(crate) fn block_until_released(&self) {
let (lock, condvar) = &*self.state;
let mut state = lock.lock().unwrap();
state.entered = state.entered.wrapping_add(1);
let entry = state.entered;
condvar.notify_all();
while state.released < entry {
state = condvar.wait(state).unwrap();
}
}
pub(crate) fn wait_for_entries(&self, count: u64) {
let (lock, condvar) = &*self.state;
let mut state = lock.lock().unwrap();
while state.entered < count {
state = condvar.wait(state).unwrap();
}
}
pub(crate) fn release_one(&self) {
let (lock, condvar) = &*self.state;
let mut state = lock.lock().unwrap();
state.released = state.released.wrapping_add(1);
condvar.notify_all();
}
}
#[derive(Debug, Default)]
pub(crate) struct FakeRenderBackend {
gate: Option<RenderGate>,
next_error: Mutex<Option<RenderWorkerError>>,
published_frames: Mutex<Vec<OwnedRgba8888Frame>>,
}
impl FakeRenderBackend {
pub(crate) fn with_gate(gate: RenderGate) -> Self {
Self { gate: Some(gate), ..Default::default() }
}
pub(crate) fn fail_next(&self, error: RenderWorkerError) {
*self.next_error.lock().unwrap() = Some(error);
}
pub(crate) fn render(
&self,
submission: &RenderSubmission,
) -> Result<OwnedRgba8888Frame, RenderWorkerError> {
if let Some(gate) = &self.gate {
gate.block_until_released();
}
if let Some(error) = self.next_error.lock().unwrap().take() {
return Err(error);
}
OwnedRgba8888Frame::packed(
submission.frame_id,
submission.ownership,
1,
1,
vec![0xff00_0000 | submission.frame_id.get() as u32],
)
.map_err(|_| RenderWorkerError::InternalFailure)
}
pub(crate) fn publish(&self, frame: OwnedRgba8888Frame) {
self.published_frames.lock().unwrap().push(frame);
}
pub(crate) fn published_frames(&self) -> Vec<OwnedRgba8888Frame> {
self.published_frames.lock().unwrap().clone()
}
}
pub(crate) fn game_submission(frame_id: u64, epoch: u64, app_id: u32) -> RenderSubmission {
RenderSubmission::game2d(FrameId::new(frame_id), Game2DFramePacket::default())
.with_ownership(RenderOwnership::new(epoch, AppMode::Game, app_id))
}
pub(crate) fn shell_submission(frame_id: u64, epoch: u64, app_id: u32) -> RenderSubmission {
RenderSubmission::shell_ui(FrameId::new(frame_id), ShellUiFramePacket::new(Vec::new()))
.with_ownership(RenderOwnership::new(epoch, AppMode::Shell, app_id))
}
pub(crate) fn owned_frame(frame_id: FrameId, pixel: u32) -> OwnedRgba8888Frame {
OwnedRgba8888Frame::packed(
frame_id,
RenderOwnership::new(0, AppMode::Game, 0),
1,
1,
vec![pixel],
)
.expect("test frame should be valid")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::thread;
#[test]
fn render_worker_harness_blocks_and_releases_render_without_timing_delay() {
let gate = RenderGate::default();
let backend = Arc::new(FakeRenderBackend::with_gate(gate.clone()));
let render_backend = Arc::clone(&backend);
let submission = game_submission(4, 1, 9);
let (done_tx, done_rx) = mpsc::channel();
let worker = thread::spawn(move || {
let frame = render_backend.render(&submission).expect("render should succeed");
done_tx.send(frame.frame_id).unwrap();
});
gate.wait_for_entries(1);
assert!(done_rx.try_recv().is_err());
gate.release_one();
assert_eq!(done_rx.recv().expect("render should finish"), FrameId::new(4));
worker.join().expect("worker thread should finish");
}
#[test]
fn render_worker_harness_records_published_owned_frames() {
let backend = FakeRenderBackend::default();
let frame = owned_frame(FrameId::new(8), 0xff00_ff00);
backend.publish(frame.clone());
assert_eq!(backend.published_frames(), vec![frame]);
}
#[test]
fn render_worker_harness_injects_backend_errors() {
let backend = FakeRenderBackend::default();
backend.fail_next(RenderWorkerError::RenderFailed);
let error = backend.render(&game_submission(5, 1, 1)).expect_err("render should fail");
assert_eq!(error, RenderWorkerError::RenderFailed);
assert!(backend.render(&game_submission(6, 1, 1)).is_ok());
}
#[test]
fn render_worker_harness_builds_game_and_shell_submissions() {
let game = game_submission(1, 2, 3);
let shell = shell_submission(4, 5, 6);
assert_eq!(game.frame_id, FrameId::new(1));
assert_eq!(game.ownership, RenderOwnership::new(2, AppMode::Game, 3));
assert_eq!(shell.frame_id, FrameId::new(4));
assert_eq!(shell.ownership, RenderOwnership::new(5, AppMode::Shell, 6));
}
}

View File

@ -5,11 +5,10 @@ use prometeu_bytecode::TRAP_TYPE;
use prometeu_bytecode::Value;
use prometeu_bytecode::assembler::assemble;
use prometeu_bytecode::model::{BytecodeModule, ConstantPoolEntry, FunctionMeta, SyscallDecl};
use prometeu_drivers::hardware::Hardware;
use prometeu_drivers::TestPlatform;
use prometeu_drivers::{GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller};
use prometeu_hal::AudioOpStatus;
use prometeu_hal::ComposerOpStatus;
use prometeu_hal::InputSignals;
use prometeu_hal::asset::{
AssetCodec, AssetEntry, AssetLoadError, AssetOpStatus, BankType, LoadStatus,
};
@ -22,6 +21,7 @@ use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::syscalls::caps;
use prometeu_hal::tile::Tile;
use prometeu_hal::tilemap::TileMap;
use prometeu_hal::{InputSignals, RuntimePlatform};
use prometeu_vm::VmInitError;
use std::collections::HashMap;
use std::sync::Arc;
@ -247,7 +247,7 @@ fn tick_returns_error_when_vm_ends_slice_with_trap() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_BOOL 1\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -274,7 +274,7 @@ fn tick_returns_error_when_vm_ends_slice_with_trap() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
)
.expect("trap must surface as runtime error");
@ -298,7 +298,7 @@ fn tick_system_profile_rejects_gfx_game_surface() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 0\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -324,7 +324,7 @@ fn tick_system_profile_rejects_gfx_game_surface() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
)
.expect("system gfx call must trap");
@ -347,7 +347,7 @@ fn tick_system_profile_rejects_composer_game_surface() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 0\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -373,7 +373,7 @@ fn tick_system_profile_rejects_composer_game_surface() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
)
.expect("system composer call must trap");
@ -398,7 +398,7 @@ fn tick_game_profile_rejects_gfxui_shell_surface() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 0\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -424,7 +424,7 @@ fn tick_game_profile_rejects_gfxui_shell_surface() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
)
.expect("game gfxui call must trap");
@ -447,7 +447,7 @@ fn tick_shell_profile_closes_gfxui_commands_into_shell_packet() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 287454020\nHOSTCALL 0\nFRAME_SYNC\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -472,7 +472,7 @@ fn tick_shell_profile_closes_gfxui_commands_into_shell_packet() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none());
@ -483,7 +483,108 @@ fn tick_shell_profile_closes_gfxui_commands_into_shell_packet() {
};
assert_eq!(packet.commands.len(), 1);
assert!(matches!(packet.commands[0], prometeu_hal::GfxUiCommand::Clear { .. }));
assert_eq!(hardware.gfx.front_buffer()[0], Color::from_raw(0x11223344).raw());
assert_eq!(platform.local_hardware().gfx.front_buffer()[0], Color::from_raw(0x11223344).raw());
assert_eq!(runtime.frame_scheduler.completed_game_frames(), 0);
assert_eq!(runtime.frame_scheduler.active_game_frame_id(), None);
}
#[test]
fn tick_gfx2d_clear_buffers_without_immediate_pixel_mutation_before_frame_close() {
let mut runtime = VirtualMachineRuntime::new(None);
let mut log_service = LogService::new(4096);
let mut fs = VirtualFS::new();
let mut fs_state = FsState::Unmounted;
let mut memcard = MemcardService::new();
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 287454020\nHOSTCALL 0\nJMP 0").expect("assemble");
let program = serialized_single_function_module(
code,
vec![SyscallDecl {
module: "gfx2d".into(),
name: "clear".into(),
version: 1,
arg_slots: 1,
ret_slots: 0,
}],
);
let cartridge = cartridge_with_program(program, caps::GFX);
runtime.initialize_vm(&mut log_service, &mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(
&mut log_service,
&mut fs,
&mut fs_state,
&mut memcard,
&mut open_files,
&mut next_handle,
&mut vm,
&signals,
&mut platform,
);
assert!(report.is_none());
assert!(runtime.render_manager.latest_complete_submission().is_none());
assert!(matches!(
runtime.gfx2d_commands.first(),
Some(prometeu_hal::Gfx2dCommand::Clear { color })
if *color == Color::from_raw(0x11223344)
));
assert_ne!(platform.local_hardware().gfx.front_buffer()[0], Color::from_raw(0x11223344).raw());
}
#[test]
fn tick_gfx2d_draw_text_buffers_without_immediate_pixel_mutation_before_frame_close() {
let mut runtime = VirtualMachineRuntime::new(None);
let mut log_service = LogService::new(4096);
let mut fs = VirtualFS::new();
let mut fs_state = FsState::Unmounted;
let mut memcard = MemcardService::new();
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code =
assemble("PUSH_I32 0\nPUSH_I32 0\nPUSH_CONST 0\nPUSH_I32 287454020\nHOSTCALL 0\nJMP 0")
.expect("assemble");
let program = serialized_single_function_module_with_consts(
code,
vec![ConstantPoolEntry::String("I".into())],
vec![SyscallDecl {
module: "gfx2d".into(),
name: "draw_text".into(),
version: 1,
arg_slots: 4,
ret_slots: 0,
}],
);
let cartridge = cartridge_with_program(program, caps::GFX);
runtime.initialize_vm(&mut log_service, &mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(
&mut log_service,
&mut fs,
&mut fs_state,
&mut memcard,
&mut open_files,
&mut next_handle,
&mut vm,
&signals,
&mut platform,
);
assert!(report.is_none());
assert!(runtime.render_manager.latest_complete_submission().is_none());
assert!(matches!(
runtime.gfx2d_commands.first(),
Some(prometeu_hal::Gfx2dCommand::DrawText { x: 0, y: 0, text, color })
if text == "I" && *color == Color::from_raw(0x11223344)
));
assert_ne!(platform.local_hardware().gfx.front_buffer()[0], Color::from_raw(0x11223344).raw());
}
#[test]
@ -496,7 +597,7 @@ fn tick_system_profile_rejects_bank_game_surface() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 0\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -522,7 +623,7 @@ fn tick_system_profile_rejects_bank_game_surface() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
)
.expect("system bank call must trap");
@ -545,7 +646,7 @@ fn tick_system_profile_can_use_shared_log_transport() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 2\nPUSH_CONST 0\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module_with_consts(
@ -571,7 +672,7 @@ fn tick_system_profile_can_use_shared_log_transport() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "system log transport must remain internally shared");
@ -588,7 +689,7 @@ fn tick_returns_panic_report_distinct_from_trap() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::new(assemble("HOSTCALL 0\nHALT").expect("assemble"), vec![]);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let report = runtime
@ -601,7 +702,7 @@ fn tick_returns_panic_report_distinct_from_trap() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
)
.expect("panic must surface as runtime error");
@ -633,12 +734,12 @@ fn tick_renders_bound_eight_pixel_scene_through_frame_composer_path() {
let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 2, Color::BLUE)));
banks.install_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8)));
let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks));
let glyph_slot_index = hardware.assets.glyph_asset_slot_index();
let mut platform = TestPlatform::new_with_memory_banks(Arc::clone(&banks));
let glyph_slot_index = platform.local_hardware().assets.glyph_asset_slot_index();
let mut slots = [None; 16];
slots[0] = Some(0);
glyph_slot_index.rebuild_from_slots(&slots);
assert!(hardware.frame_composer.bind_scene(0));
assert!(platform.local_hardware_mut().frame_composer.bind_scene(0));
runtime.initialize_vm(&mut log_service, &mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(
@ -650,11 +751,11 @@ fn tick_renders_bound_eight_pixel_scene_through_frame_composer_path() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "frame render path must not crash");
assert_eq!(hardware.gfx.front_buffer()[0], Color::BLUE.raw());
assert_eq!(platform.local_hardware().gfx.front_buffer()[0], Color::BLUE.raw());
}
#[test]
@ -706,8 +807,8 @@ fn tick_renders_scene_through_public_composer_syscalls() {
let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 2, Color::BLUE)));
banks.install_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8)));
let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks));
let glyph_slot_index = hardware.assets.glyph_asset_slot_index();
let mut platform = TestPlatform::new_with_memory_banks(Arc::clone(&banks));
let glyph_slot_index = platform.local_hardware().assets.glyph_asset_slot_index();
let mut slots = [None; 16];
slots[0] = Some(0);
glyph_slot_index.rebuild_from_slots(&slots);
@ -722,12 +823,12 @@ fn tick_renders_scene_through_public_composer_syscalls() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "public composer path must not crash");
assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(ComposerOpStatus::Ok as i64)]);
assert_eq!(hardware.gfx.front_buffer()[0], Color::BLUE.raw());
assert_eq!(platform.local_hardware().gfx.front_buffer()[0], Color::BLUE.raw());
}
#[test]
@ -757,7 +858,7 @@ fn tick_draw_text_survives_no_scene_frame_path() {
}],
);
let cartridge = cartridge_with_program(program, caps::GFX);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
runtime.initialize_vm(&mut log_service, &mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(
@ -769,7 +870,7 @@ fn tick_draw_text_survives_no_scene_frame_path() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "no-scene overlay text must not crash");
@ -780,7 +881,7 @@ fn tick_draw_text_survives_no_scene_frame_path() {
};
assert_eq!(packet.gfx2d.len(), 1);
assert!(matches!(packet.gfx2d[0], prometeu_hal::Gfx2dCommand::DrawText { .. }));
assert_eq!(hardware.gfx.front_buffer()[0], Color::from_raw(0x11223344).raw());
assert_eq!(platform.local_hardware().gfx.front_buffer()[0], Color::from_raw(0x11223344).raw());
}
#[test]
@ -806,7 +907,7 @@ fn tick_game_submissions_are_immutable_and_latest_complete_wins() {
}],
);
let cartridge = cartridge_with_program(program, caps::GFX);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
runtime.initialize_vm(&mut log_service, &mut vm, &cartridge).expect("runtime must initialize");
let first_report = runtime.tick(
@ -818,7 +919,7 @@ fn tick_game_submissions_are_immutable_and_latest_complete_wins() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(first_report.is_none());
let first_submission =
@ -832,7 +933,8 @@ fn tick_game_submissions_are_immutable_and_latest_complete_wins() {
first_packet.gfx2d[0],
prometeu_hal::Gfx2dCommand::Clear { color } if color == Color::from_raw(0x11223344)
));
assert_eq!(hardware.gfx.front_buffer()[0], Color::from_raw(0x11223344).raw());
assert_eq!(platform.local_hardware().gfx.front_buffer()[0], Color::from_raw(0x11223344).raw());
assert_eq!(runtime.frame_scheduler.completed_game_frames(), 1);
let second_packet = prometeu_hal::Game2DFramePacket::new(
Default::default(),
@ -855,8 +957,11 @@ fn tick_game_submissions_are_immutable_and_latest_complete_wins() {
first_packet.gfx2d[0],
prometeu_hal::Gfx2dCommand::Clear { color } if color == Color::from_raw(0x11223344)
));
prometeu_hal::HardwareBridge::publish_render_submission(&mut hardware, latest);
assert_eq!(hardware.gfx.front_buffer()[0], Color::from_raw(0x55667788).raw());
platform
.render_submission_sink()
.submit_render_submission(latest.clone())
.expect("local sink should publish latest submission");
assert_eq!(platform.local_hardware().gfx.front_buffer()[0], Color::from_raw(0x55667788).raw());
}
#[test]
@ -958,7 +1063,7 @@ fn tick_numeric_happy_path_records_zero_internal_allocations() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code =
assemble("PUSH_I32 1\nPUSH_I32 2\nADD\nPOP_N 1\nFRAME_SYNC\nHALT").expect("assemble");
@ -975,7 +1080,7 @@ fn tick_numeric_happy_path_records_zero_internal_allocations() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none());
@ -994,7 +1099,7 @@ fn tick_already_materialized_string_path_records_zero_internal_allocations() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_CONST 0\nSET_GLOBAL 0\nGET_GLOBAL 0\nPOP_N 1\nFRAME_SYNC\nHALT")
.expect("assemble");
@ -1015,7 +1120,7 @@ fn tick_already_materialized_string_path_records_zero_internal_allocations() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none());
@ -1090,7 +1195,7 @@ fn tick_composer_bind_scene_operational_error_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 99\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -1115,7 +1220,7 @@ fn tick_composer_bind_scene_operational_error_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "operational error must not crash");
assert!(vm.is_halted());
@ -1135,7 +1240,7 @@ fn tick_composer_emit_sprite_operational_error_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT",
@ -1163,7 +1268,7 @@ fn tick_composer_emit_sprite_operational_error_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "operational error must not crash");
assert!(vm.is_halted());
@ -1180,7 +1285,7 @@ fn tick_composer_emit_sprite_invalid_layer_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 4\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT",
@ -1208,7 +1313,7 @@ fn tick_composer_emit_sprite_invalid_layer_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "invalid layer must not crash");
assert!(vm.is_halted());
@ -1225,7 +1330,7 @@ fn tick_composer_emit_sprite_invalid_range_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 0\nPUSH_I32 64\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT",
@ -1244,7 +1349,7 @@ fn tick_composer_emit_sprite_invalid_range_returns_status_not_crash() {
let cartridge = cartridge_with_program(program, caps::GFX);
let asset_data = test_glyph_asset_data();
hardware.assets.initialize_for_cartridge(
platform.local_hardware().assets.initialize_for_cartridge(
vec![test_glyph_asset_entry("tile_asset", asset_data.len())],
vec![prometeu_hal::asset::PreloadEntry { asset_id: 7, slot: 0 }],
AssetsPayloadSource::from_bytes(asset_data),
@ -1260,7 +1365,7 @@ fn tick_composer_emit_sprite_invalid_range_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "invalid composer parameter range must not crash");
assert!(vm.is_halted());
@ -1280,7 +1385,7 @@ fn tick_audio_play_sample_operational_error_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 -1\nPUSH_I32 0\nPUSH_I32 255\nPUSH_I32 128\nPUSH_I32 1\nHOSTCALL 0\nHALT",
@ -1308,7 +1413,7 @@ fn tick_audio_play_sample_operational_error_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "operational error must not crash");
assert!(vm.is_halted());
@ -1325,7 +1430,7 @@ fn tick_audio_play_voice_invalid_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 16\nPUSH_I32 255\nPUSH_I32 128\nPUSH_I32 1\nPUSH_I32 0\nHOSTCALL 0\nHALT",
@ -1353,7 +1458,7 @@ fn tick_audio_play_voice_invalid_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "invalid voice must not crash");
assert!(vm.is_halted());
@ -1370,7 +1475,7 @@ fn tick_audio_play_missing_asset_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 255\nPUSH_I32 128\nPUSH_I32 1\nPUSH_I32 0\nHOSTCALL 0\nHALT",
@ -1398,7 +1503,7 @@ fn tick_audio_play_missing_asset_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "missing audio asset must not crash");
assert!(vm.is_halted());
@ -1415,7 +1520,7 @@ fn tick_audio_play_type_mismatch_surfaces_trap_not_panic() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble(
"PUSH_BOOL 1\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 255\nPUSH_I32 128\nPUSH_I32 1\nPUSH_I32 0\nHOSTCALL 0\nHALT",
@ -1444,7 +1549,7 @@ fn tick_audio_play_type_mismatch_surfaces_trap_not_panic() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
)
.expect("type mismatch must surface as trap");
match report {
@ -1467,7 +1572,7 @@ fn tick_composer_emit_sprite_type_mismatch_surfaces_trap_not_panic() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble(
"PUSH_BOOL 1\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT",
@ -1496,7 +1601,7 @@ fn tick_composer_emit_sprite_type_mismatch_surfaces_trap_not_panic() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
)
.expect("type mismatch must surface as trap");
match report {

View File

@ -10,7 +10,7 @@ fn tick_asset_commit_operational_error_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 999\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -35,7 +35,7 @@ fn tick_asset_commit_operational_error_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "operational error must not crash");
assert!(vm.is_halted());
@ -52,7 +52,7 @@ fn tick_asset_load_missing_asset_returns_status_and_zero_handle() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 999\nPUSH_I32 0\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -77,7 +77,7 @@ fn tick_asset_load_missing_asset_returns_status_and_zero_handle() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "missing asset must not crash");
assert!(vm.is_halted());
@ -97,10 +97,10 @@ fn tick_asset_load_invalid_slot_returns_status_and_zero_handle() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let asset_data = test_glyph_asset_data();
hardware.assets.initialize_for_cartridge(
platform.local_hardware().assets.initialize_for_cartridge(
vec![test_glyph_asset_entry("tile_asset", asset_data.len())],
vec![],
AssetsPayloadSource::from_bytes(asset_data),
@ -128,7 +128,7 @@ fn tick_asset_load_invalid_slot_returns_status_and_zero_handle() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "invalid slot must not crash");
assert!(vm.is_halted());
@ -148,7 +148,7 @@ fn tick_asset_status_unknown_handle_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 999\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -173,7 +173,7 @@ fn tick_asset_status_unknown_handle_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "unknown asset handle must not crash");
assert!(vm.is_halted());
@ -190,10 +190,10 @@ fn tick_bank_info_returns_slot_summary_not_json() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let asset_data = test_glyph_asset_data();
hardware.assets.initialize_for_cartridge(
platform.local_hardware().assets.initialize_for_cartridge(
vec![test_glyph_asset_entry("tile_asset", asset_data.len())],
vec![prometeu_hal::asset::PreloadEntry { asset_id: 7, slot: 0 }],
AssetsPayloadSource::from_bytes(asset_data),
@ -221,7 +221,7 @@ fn tick_bank_info_returns_slot_summary_not_json() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "bank summary must not crash");
assert!(vm.is_halted());
@ -261,7 +261,7 @@ fn tick_asset_commit_invalid_transition_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 1\nHOSTCALL 0\nPOP_N 1\nPUSH_I32 1\nHOSTCALL 1\nHALT")
.expect("assemble");
@ -287,12 +287,13 @@ fn tick_asset_commit_invalid_transition_returns_status_not_crash() {
let cartridge = cartridge_with_program(program, caps::ASSET);
let asset_data = test_glyph_asset_data();
hardware.assets.initialize_for_cartridge(
platform.local_hardware().assets.initialize_for_cartridge(
vec![test_glyph_asset_entry("tile_asset", asset_data.len())],
vec![],
AssetsPayloadSource::from_bytes(asset_data),
);
let handle = hardware.assets.load(7, 0).expect("asset handle must be allocated");
let handle =
platform.local_hardware().assets.load(7, 0).expect("asset handle must be allocated");
runtime.initialize_vm(&mut log_service, &mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(
@ -304,7 +305,7 @@ fn tick_asset_commit_invalid_transition_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "invalid transition must not crash");
assert!(vm.is_halted());
@ -322,7 +323,7 @@ fn tick_asset_cancel_unknown_handle_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 999\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -347,7 +348,7 @@ fn tick_asset_cancel_unknown_handle_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "unknown handle cancel must not crash");
assert!(vm.is_halted());
@ -364,7 +365,7 @@ fn tick_asset_cancel_invalid_transition_returns_status_not_crash() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("PUSH_I32 1\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -380,28 +381,34 @@ fn tick_asset_cancel_invalid_transition_returns_status_not_crash() {
let cartridge = cartridge_with_program(program, caps::ASSET);
let asset_data = test_glyph_asset_data();
hardware.assets.initialize_for_cartridge(
platform.local_hardware().assets.initialize_for_cartridge(
vec![test_glyph_asset_entry("tile_asset", asset_data.len())],
vec![],
AssetsPayloadSource::from_bytes(asset_data),
);
let handle = hardware.assets.load(7, 0).expect("asset handle must be allocated");
let handle =
platform.local_hardware().assets.load(7, 0).expect("asset handle must be allocated");
runtime.initialize_vm(&mut log_service, &mut vm, &cartridge).expect("runtime must initialize");
loop {
match hardware.assets.status(handle) {
LoadStatus::READY => break,
let mut asset_ready = false;
for _ in 0..1_000 {
match platform.local_hardware().assets.status(handle) {
LoadStatus::READY => {
asset_ready = true;
break;
}
LoadStatus::PENDING | LoadStatus::LOADING => {
std::thread::sleep(std::time::Duration::from_millis(1));
std::thread::yield_now();
}
other => panic!("unexpected asset status before commit: {:?}", other),
}
}
assert!(asset_ready, "asset did not become ready before commit");
assert_eq!(hardware.assets.commit(handle), AssetOpStatus::Ok);
hardware.assets.apply_commits();
assert_eq!(hardware.assets.status(handle), LoadStatus::COMMITTED);
assert_eq!(platform.local_hardware().assets.commit(handle), AssetOpStatus::Ok);
platform.local_hardware().assets.apply_commits();
assert_eq!(platform.local_hardware().assets.status(handle), LoadStatus::COMMITTED);
let report = runtime.tick(
&mut log_service,
@ -412,7 +419,7 @@ fn tick_asset_cancel_invalid_transition_returns_status_not_crash() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "cancel after commit must not crash");
assert!(vm.is_halted());
@ -429,7 +436,7 @@ fn tick_status_first_surface_smoke_across_composer_audio_and_asset() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\n\
@ -476,7 +483,7 @@ fn tick_status_first_surface_smoke_across_composer_audio_and_asset() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "mixed status-first surface must not crash");
assert!(vm.is_halted());

View File

@ -11,7 +11,7 @@ fn tick_memcard_slot_roundtrip_for_game_profile() {
let mut next_handle = 1;
runtime.mount_fs(&mut log_service, &mut fs, &mut fs_state, Box::new(MemFsBackend::default()));
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 0\nPUSH_I32 0\nPUSH_CONST 0\nHOSTCALL 0\nPOP_N 2\nPUSH_I32 0\nHOSTCALL 1\nPOP_N 1\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 10\nHOSTCALL 2\nHALT",
@ -66,7 +66,7 @@ fn tick_memcard_slot_roundtrip_for_game_profile() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "memcard roundtrip must not crash");
assert!(vm.is_halted());
@ -86,7 +86,7 @@ fn tick_memcard_access_is_denied_for_non_game_profile() {
let mut open_files: HashMap<u32, String> = HashMap::new();
let mut next_handle = 1;
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
let signals = InputSignals::default();
let code = assemble("HOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
@ -121,7 +121,7 @@ fn tick_memcard_access_is_denied_for_non_game_profile() {
&mut next_handle,
&mut vm,
&signals,
&mut hardware,
&mut platform,
);
assert!(report.is_none(), "non-game memcard call must return status");
assert!(vm.is_halted());

View File

@ -1,5 +1,5 @@
use super::dispatch::VmRuntimeHost;
use super::render_manager::RenderSurface;
use super::render_manager::{RenderConsumeOutcome, RenderConsumerPath, RenderSurface};
use super::*;
use crate::CrashReport;
use crate::fs::{FsState, VirtualFS};
@ -7,8 +7,8 @@ use crate::services::memcard::MemcardService;
use prometeu_hal::asset::{BankTelemetry, BankType};
use prometeu_hal::log::{LogLevel, LogService, LogSource};
use prometeu_hal::{
HardwareBridge, HostContext, InputSignals, RenderSubmission, RenderSubmissionPacket,
ShellUiFramePacket,
FrameId, HostContext, InputSignals, RenderSubmission, RenderSubmissionPacket,
RenderSubmissionSink, RuntimePlatform, ShellUiFramePacket,
};
use prometeu_vm::LogicalFrameEndingReason;
use std::collections::HashMap;
@ -16,12 +16,14 @@ use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::Ordering;
struct HardwareRenderSurface<'a> {
hw: &'a mut dyn HardwareBridge,
sink: &'a mut dyn RenderSubmissionSink,
}
impl RenderSurface for HardwareRenderSurface<'_> {
fn consume_submission(&mut self, submission: &RenderSubmission) {
self.hw.publish_render_submission(submission);
self.sink
.submit_render_submission(submission.clone())
.expect("local render submission sink should publish");
}
}
@ -49,9 +51,9 @@ impl VirtualMachineRuntime {
}
fn bank_telemetry_summary(
hw: &dyn HardwareBridge,
platform: &dyn RuntimePlatform,
) -> (BankTelemetry, BankTelemetry, BankTelemetry) {
let telemetry = hw.assets().bank_telemetry();
let telemetry = platform.assets().bank_telemetry();
let glyph =
telemetry.iter().find(|entry| entry.bank_type == BankType::GLYPH).cloned().unwrap_or(
BankTelemetry { bank_type: BankType::GLYPH, used_slots: 0, total_slots: 0 },
@ -72,10 +74,10 @@ impl VirtualMachineRuntime {
&mut self,
log_service: &mut LogService,
vm: &mut VirtualMachine,
hw: &mut dyn HardwareBridge,
platform: &mut dyn RuntimePlatform,
) -> Option<CrashReport> {
let step_result = {
let mut ctx = HostContext::new(Some(hw));
let mut ctx = HostContext::new_platform(Some(platform));
let mut fs = VirtualFS::new();
let mut fs_state = FsState::Unmounted;
let mut memcard = MemcardService::new();
@ -119,6 +121,7 @@ impl VirtualMachineRuntime {
}
}
#[allow(clippy::too_many_arguments)]
pub fn tick(
&mut self,
log_service: &mut LogService,
@ -129,7 +132,7 @@ impl VirtualMachineRuntime {
next_handle: &mut u32,
vm: &mut VirtualMachine,
signals: &InputSignals,
hw: &mut dyn HardwareBridge,
platform: &mut dyn RuntimePlatform,
) -> Option<CrashReport> {
let start = Instant::now();
self.tick_index += 1;
@ -139,11 +142,17 @@ impl VirtualMachineRuntime {
}
self.update_fs(log_service, fs, fs_state);
if !self.logical_frame_active {
if RenderManager::policy_for_app_mode(self.current_cartridge_app_mode)
.uses_frame_scheduler()
{
self.frame_scheduler
.authorize_game_frame()
.expect("logical frame cannot start while another Game frame is active");
}
self.logical_frame_active = true;
self.logical_frame_remaining_cycles = Self::CYCLES_PER_LOGICAL_FRAME;
self.begin_logical_frame(signals, hw);
self.begin_logical_frame(signals, platform);
if self.needs_prepare_entry_call || vm.call_stack_is_empty() {
vm.prepare_boot_call();
@ -171,7 +180,7 @@ impl VirtualMachineRuntime {
if budget > 0 {
let run_result = {
let mut ctx = HostContext::new(Some(hw));
let mut ctx = HostContext::new_platform(Some(platform));
let mut host = VmRuntimeHost {
runtime: self,
log_service,
@ -234,9 +243,13 @@ impl VirtualMachineRuntime {
if run.reason == LogicalFrameEndingReason::FrameSync
|| run.reason == LogicalFrameEndingReason::EndOfRom
{
self.render_manager.set_active_app_mode(self.current_cartridge_app_mode);
self.render_manager.transition_render_owner(
self.current_cartridge_app_mode,
self.current_app_id,
);
self.sync_render_worker_ownership();
if self.current_cartridge_app_mode == AppMode::Game {
let mut packet = hw.close_game2d_packet();
let mut packet = platform.game2d_frame_composer().close_game2d_packet();
packet.gfx2d = std::mem::take(&mut self.gfx2d_commands);
self.render_manager
.close_frame_with_packet(RenderSubmissionPacket::Game2D(packet))
@ -249,25 +262,57 @@ impl VirtualMachineRuntime {
.expect("shell packet must match Shell app mode");
}
if let Err(payload) = catch_unwind(AssertUnwindSafe(|| {
let mut surface = HardwareRenderSurface { hw };
self.render_manager.publish_latest(&mut surface);
})) {
let message = Self::host_panic_payload_to_string(payload);
let report = CrashReport::VmPanic { message, pc: Some(vm.pc() as u32) };
let render_outcome = catch_unwind(AssertUnwindSafe(|| {
let mut surface =
HardwareRenderSurface { sink: platform.render_submission_sink() };
match self
.render_manager
.active_render_policy()
.resolve_consumer_path(self.render_capabilities)
{
RenderConsumerPath::LocalSynchronous => {
self.render_manager.consume_latest(&mut surface)
}
RenderConsumerPath::RealWorker
if self.render_worker_controller.is_some()
&& self.current_cartridge_app_mode == AppMode::Game =>
{
RenderConsumeOutcome::NoSubmission
}
RenderConsumerPath::RealWorker => {
self.render_manager.consume_latest(&mut surface)
}
}
}));
let render_outcome = match render_outcome {
Ok(outcome) => outcome,
Err(payload) => {
let message = Self::host_panic_payload_to_string(payload);
let frame_id = self
.render_manager
.pending_submission()
.map(|submission| submission.frame_id)
.unwrap_or(FrameId::ZERO);
self.render_manager.record_render_error(frame_id);
RenderConsumeOutcome::PresentFailed { frame_id, message }
}
};
if let RenderConsumeOutcome::PresentFailed { message, .. } = &render_outcome
{
self.log(
log_service,
LogLevel::Error,
LogSource::Vm,
report.log_tag(),
report.summary(),
0,
format!("Render publication failed: {}", message),
);
self.last_crash_report = Some(report.clone());
return Some(report);
}
// 1. Snapshot full telemetry at logical frame end
let (glyph_bank, sound_bank, scene_bank) = Self::bank_telemetry_summary(hw);
let (glyph_bank, sound_bank, scene_bank) =
Self::bank_telemetry_summary(platform);
self.atomic_telemetry
.glyph_slots_used
.store(glyph_bank.used_slots as u32, Ordering::Relaxed);
@ -294,6 +339,7 @@ impl VirtualMachineRuntime {
self.atomic_telemetry
.host_cpu_time_us
.store(start.elapsed().as_micros() as u64, Ordering::Relaxed);
self.render_manager.sync_telemetry(&self.atomic_telemetry);
let current_frame_logs =
self.atomic_telemetry.current_logs_count.load(Ordering::Relaxed);
@ -317,6 +363,11 @@ impl VirtualMachineRuntime {
self.logical_frame_index += 1;
self.logical_frame_active = false;
self.logical_frame_remaining_cycles = 0;
if RenderManager::policy_for_app_mode(self.current_cartridge_app_mode)
.uses_frame_scheduler()
{
self.frame_scheduler.complete_game_frame();
}
if run.reason == LogicalFrameEndingReason::FrameSync {
self.needs_prepare_entry_call = true;
@ -326,6 +377,11 @@ impl VirtualMachineRuntime {
self.paused = true;
self.debug_step_request = false;
}
} else if run.reason == LogicalFrameEndingReason::BudgetExhausted
&& RenderManager::policy_for_app_mode(self.current_cartridge_app_mode)
.uses_frame_scheduler()
{
self.frame_scheduler.record_logical_frame_overrun();
}
}
Err(e) => {
@ -347,7 +403,7 @@ impl VirtualMachineRuntime {
// 2. High-frequency telemetry update (only if inspection is active)
if self.inspection_active {
let (glyph_bank, sound_bank, scene_bank) = Self::bank_telemetry_summary(hw);
let (glyph_bank, sound_bank, scene_bank) = Self::bank_telemetry_summary(platform);
self.atomic_telemetry
.glyph_slots_used
.store(glyph_bank.used_slots as u32, Ordering::Relaxed);
@ -384,10 +440,10 @@ impl VirtualMachineRuntime {
pub(crate) fn begin_logical_frame(
&mut self,
_signals: &InputSignals,
hw: &mut dyn HardwareBridge,
platform: &mut dyn RuntimePlatform,
) {
hw.begin_frame();
hw.audio_mut().clear_commands();
platform.game2d_frame_composer().begin_frame();
platform.audio_mut().clear_commands();
self.logs_written_this_frame.clear();
}
}

View File

@ -1350,8 +1350,9 @@ fn input_touch_x(
ctx: &mut HostContext<'_>,
) -> Result<Vec<Value>, IntrinsicExecutionError> {
expect_touch_handle(args, "x")?;
let hw = ctx.require_hw().map_err(|_| IntrinsicExecutionError::HardwareUnavailable)?;
Ok(vec![Value::Int64(hw.touch().x() as i64)])
let platform =
ctx.require_platform().map_err(|_| IntrinsicExecutionError::HardwareUnavailable)?;
Ok(vec![Value::Int64(platform.input().touch().x() as i64)])
}
fn input_touch_y(
@ -1359,8 +1360,9 @@ fn input_touch_y(
ctx: &mut HostContext<'_>,
) -> Result<Vec<Value>, IntrinsicExecutionError> {
expect_touch_handle(args, "y")?;
let hw = ctx.require_hw().map_err(|_| IntrinsicExecutionError::HardwareUnavailable)?;
Ok(vec![Value::Int64(hw.touch().y() as i64)])
let platform =
ctx.require_platform().map_err(|_| IntrinsicExecutionError::HardwareUnavailable)?;
Ok(vec![Value::Int64(platform.input().touch().y() as i64)])
}
fn input_button_pressed(
@ -1435,9 +1437,11 @@ fn resolve_button(
ctx: &mut HostContext<'_>,
) -> Result<prometeu_hal::button::Button, IntrinsicExecutionError> {
let carrier = expect_builtin_carrier(args, 0)?;
let hw = ctx.require_hw().map_err(|_| IntrinsicExecutionError::HardwareUnavailable)?;
let platform =
ctx.require_platform().map_err(|_| IntrinsicExecutionError::HardwareUnavailable)?;
let input = platform.input();
if carrier == TOUCH_BUTTON_CARRIER {
return Ok(*hw.touch().f());
return Ok(*input.touch().f());
}
let Some(index) = carrier.checked_sub(PAD_BUTTON_BASE) else {
@ -1448,18 +1452,18 @@ fn resolve_button(
});
};
let button = match index {
0 => *hw.pad().up(),
1 => *hw.pad().down(),
2 => *hw.pad().left(),
3 => *hw.pad().right(),
4 => *hw.pad().a(),
5 => *hw.pad().b(),
6 => *hw.pad().x(),
7 => *hw.pad().y(),
8 => *hw.pad().l(),
9 => *hw.pad().r(),
10 => *hw.pad().start(),
11 => *hw.pad().select(),
0 => *input.pad().up(),
1 => *input.pad().down(),
2 => *input.pad().left(),
3 => *input.pad().right(),
4 => *input.pad().a(),
5 => *input.pad().b(),
6 => *input.pad().x(),
7 => *input.pad().y(),
8 => *input.pad().l(),
9 => *input.pad().r(),
10 => *input.pad().start(),
11 => *input.pad().select(),
_ => {
return Err(IntrinsicExecutionError::InvalidBuiltinCarrier {
owner: "input.button",

View File

@ -548,17 +548,17 @@ mod tests {
// replace with arrays containing cross-references. Since our simple
// heap doesn't support in-place element edits via API, simulate by
// directly editing stored objects.
if let Some(slot) = heap.objects.get_mut(a.0 as usize) {
if let Some(obj) = slot.as_mut() {
obj.array_elems = Some(vec![Value::HeapRef(b)]);
obj.header.payload_len = 1;
}
if let Some(slot) = heap.objects.get_mut(a.0 as usize)
&& let Some(obj) = slot.as_mut()
{
obj.array_elems = Some(vec![Value::HeapRef(b)]);
obj.header.payload_len = 1;
}
if let Some(slot) = heap.objects.get_mut(b.0 as usize) {
if let Some(obj) = slot.as_mut() {
obj.array_elems = Some(vec![Value::HeapRef(a)]);
obj.header.payload_len = 1;
}
if let Some(slot) = heap.objects.get_mut(b.0 as usize)
&& let Some(obj) = slot.as_mut()
{
obj.array_elems = Some(vec![Value::HeapRef(a)]);
obj.header.payload_len = 1;
}
// Mark from A; should terminate and mark both.
@ -720,17 +720,17 @@ mod tests {
let b = heap.allocate_array(vec![]);
// Make A point to B and B point to A.
if let Some(slot) = heap.objects.get_mut(a.0 as usize) {
if let Some(obj) = slot.as_mut() {
obj.array_elems = Some(vec![Value::HeapRef(b)]);
obj.header.payload_len = 1;
}
if let Some(slot) = heap.objects.get_mut(a.0 as usize)
&& let Some(obj) = slot.as_mut()
{
obj.array_elems = Some(vec![Value::HeapRef(b)]);
obj.header.payload_len = 1;
}
if let Some(slot) = heap.objects.get_mut(b.0 as usize) {
if let Some(obj) = slot.as_mut() {
obj.array_elems = Some(vec![Value::HeapRef(a)]);
obj.header.payload_len = 1;
}
if let Some(slot) = heap.objects.get_mut(b.0 as usize)
&& let Some(obj) = slot.as_mut()
{
obj.array_elems = Some(vec![Value::HeapRef(a)]);
obj.header.payload_len = 1;
}
// No roots: perform sweep directly; both should be reclaimed.

View File

@ -1052,12 +1052,13 @@ mod tests {
// 21: Jmp 27
// 27: Nop
let mut code = Vec::new();
code.push(OpCode::PushBool as u8);
code.push(0x00);
code.push(1); // 0: PushBool (3 bytes)
code.push(OpCode::JmpIfTrue as u8);
code.push(0x00);
let mut code = vec![
OpCode::PushBool as u8,
0x00,
1, // 0: PushBool (3 bytes)
OpCode::JmpIfTrue as u8,
0x00,
];
code.extend_from_slice(&15u32.to_le_bytes()); // 3: JmpIfTrue (6 bytes)
code.push(OpCode::Jmp as u8);
code.push(0x00);
@ -1247,9 +1248,7 @@ mod tests {
#[test]
fn test_function_without_terminator_is_rejected() {
// Single NOP with no RET/JMP/TRAP/HALT at the end → fallthrough to end
let mut code = Vec::new();
code.push(OpCode::Nop as u8);
code.push(0x00);
let code = vec![OpCode::Nop as u8, 0x00];
let functions = vec![FunctionMeta { code_offset: 0, code_len: 2, ..Default::default() }];
let res = Verifier::verify(&code, &functions);
@ -1259,9 +1258,7 @@ mod tests {
#[test]
fn test_function_with_proper_terminator_passes() {
// Minimal function that returns immediately
let mut code = Vec::new();
code.push(OpCode::Ret as u8);
code.push(0x00);
let code = vec![OpCode::Ret as u8, 0x00];
let functions = vec![FunctionMeta {
code_offset: 0,
@ -1277,9 +1274,7 @@ mod tests {
fn test_verifier_ret_too_few_slots() {
// Function declares 1 return slot but returns nothing
// 0: Ret
let mut code = Vec::new();
code.push(OpCode::Ret as u8);
code.push(0x00);
let code = vec![OpCode::Ret as u8, 0x00];
let functions = vec![FunctionMeta {
code_offset: 0,

View File

@ -1596,9 +1596,10 @@ mod tests {
#[test]
fn test_push_f64_immediate() {
let value = 3.125f64;
let mut rom = Vec::new();
rom.extend_from_slice(&(OpCode::PushF64 as u16).to_le_bytes());
rom.extend_from_slice(&3.14f64.to_le_bytes());
rom.extend_from_slice(&value.to_le_bytes());
rom.extend_from_slice(&(OpCode::Halt as u16).to_le_bytes());
let mut vm = new_test_vm(rom.clone(), vec![]);
@ -1606,7 +1607,7 @@ mod tests {
let mut ctx = HostContext::new(None);
vm.step(&mut native, &mut ctx).unwrap();
assert_eq!(vm.peek().unwrap(), &Value::Float(3.14));
assert_eq!(vm.peek().unwrap(), &Value::Float(value));
}
#[test]
@ -2537,9 +2538,8 @@ mod tests {
let report = vm.run_budget(100, &mut native, &mut ctx).unwrap();
// Any non-trap outcome is considered success here
match report.reason {
LogicalFrameEndingReason::Trap(trap) => panic!("Unexpected trap: {:?}", trap),
_ => {}
if let LogicalFrameEndingReason::Trap(trap) = report.reason {
panic!("Unexpected trap: {:?}", trap);
}
}
@ -2672,8 +2672,7 @@ mod tests {
#[test]
fn test_loader_hardening_successful_init() {
let mut vm = VirtualMachine::default();
vm.pc = 123; // Pollution
let mut vm = VirtualMachine { pc: 123, ..Default::default() };
let code = assemble("HALT").expect("assemble");
let header = prometeu_bytecode::model::BytecodeModule {
@ -3978,26 +3977,26 @@ mod tests {
let mut a_href = None;
let mut b_href = None;
// Consider currently running coroutine
if let Some(cur) = vm.current_coro {
if let Some(f) = vm.call_stack.last() {
if f.func_idx == 1 {
a_href = Some(cur);
}
if f.func_idx == 2 {
b_href = Some(cur);
}
if let Some(cur) = vm.current_coro
&& let Some(f) = vm.call_stack.last()
{
if f.func_idx == 1 {
a_href = Some(cur);
}
if f.func_idx == 2 {
b_href = Some(cur);
}
}
// And also consider suspended (Ready/Sleeping) coroutines
for h in vm.heap.suspended_coroutine_handles() {
if let Some(co) = vm.heap.coroutine_data(h) {
if let Some(f) = co.frames.last() {
if f.func_idx == 1 {
a_href = Some(h);
}
if f.func_idx == 2 {
b_href = Some(h);
}
if let Some(co) = vm.heap.coroutine_data(h)
&& let Some(f) = co.frames.last()
{
if f.func_idx == 1 {
a_href = Some(h);
}
if f.func_idx == 2 {
b_href = Some(h);
}
}
}
@ -4108,25 +4107,25 @@ mod tests {
// Identify A and B coroutine handles (consider both running and suspended)
let mut a_href = None;
let mut b_href = None;
if let Some(cur) = vm.current_coro {
if let Some(f) = vm.call_stack.last() {
if f.func_idx == 1 {
a_href = Some(cur);
}
if f.func_idx == 2 {
b_href = Some(cur);
}
if let Some(cur) = vm.current_coro
&& let Some(f) = vm.call_stack.last()
{
if f.func_idx == 1 {
a_href = Some(cur);
}
if f.func_idx == 2 {
b_href = Some(cur);
}
}
for h in vm.heap.suspended_coroutine_handles() {
if let Some(co) = vm.heap.coroutine_data(h) {
if let Some(f) = co.frames.last() {
if f.func_idx == 1 {
a_href = Some(h);
}
if f.func_idx == 2 {
b_href = Some(h);
}
if let Some(co) = vm.heap.coroutine_data(h)
&& let Some(f) = co.frames.last()
{
if f.func_idx == 1 {
a_href = Some(h);
}
if f.func_idx == 2 {
b_href = Some(h);
}
}
}
@ -4253,26 +4252,26 @@ mod tests {
let mut a = None;
let mut b = None;
// running
if let Some(cur) = vm.current_coro {
if let Some(f) = vm.call_stack.last() {
if f.func_idx == 1 {
a = Some(cur);
}
if f.func_idx == 2 {
b = Some(cur);
}
if let Some(cur) = vm.current_coro
&& let Some(f) = vm.call_stack.last()
{
if f.func_idx == 1 {
a = Some(cur);
}
if f.func_idx == 2 {
b = Some(cur);
}
}
// suspended
for h in vm.heap.suspended_coroutine_handles() {
if let Some(co) = vm.heap.coroutine_data(h) {
if let Some(f) = co.frames.last() {
if f.func_idx == 1 {
a = Some(h);
}
if f.func_idx == 2 {
b = Some(h);
}
if let Some(co) = vm.heap.coroutine_data(h)
&& let Some(f) = co.frames.last()
{
if f.func_idx == 1 {
a = Some(h);
}
if f.func_idx == 2 {
b = Some(h);
}
}
}

View File

@ -168,7 +168,7 @@ fn path_segment_violation(path: &Path) -> Option<String> {
}
fn is_forbidden_ident(tok: &str) -> bool {
FORBIDDEN_IDENT_TOKENS.iter().any(|&bad| bad == tok)
FORBIDDEN_IDENT_TOKENS.contains(&tok)
}
fn tokenize_identifiers(text: &str) -> Vec<(String, usize, usize)> {

View File

@ -1,5 +1,5 @@
use prometeu_drivers::hardware::Hardware;
use prometeu_firmware::{BootTarget, Firmware};
use prometeu_hal::RuntimePlatform;
use prometeu_hal::cartridge_loader::CartridgeLoader;
use prometeu_hal::debugger_protocol::*;
use prometeu_hal::telemetry::{CertificationConfig, TelemetryFrame};
@ -99,7 +99,7 @@ impl HostDebugger {
/// Main maintenance method called by the HostRunner every iteration.
/// It handles new connections and processes incoming commands.
pub fn check_commands(&mut self, firmware: &mut Firmware, hardware: &mut Hardware) {
pub fn check_commands(&mut self, firmware: &mut Firmware, platform: &mut dyn RuntimePlatform) {
// 1. Accept new client connections.
if let Some(listener) = &self.listener
&& let Ok((stream, _addr)) = listener.accept()
@ -154,7 +154,7 @@ impl HostDebugger {
continue;
}
if let Ok(cmd) = serde_json::from_str::<DebugCommand>(trimmed) {
self.handle_command(cmd, firmware, hardware);
self.handle_command(cmd, firmware, platform);
}
}
}
@ -182,7 +182,7 @@ impl HostDebugger {
&mut self,
cmd: DebugCommand,
firmware: &mut Firmware,
hardware: &mut Hardware,
platform: &mut dyn RuntimePlatform,
) {
match cmd {
DebugCommand::Ok | DebugCommand::Start => {
@ -201,7 +201,7 @@ impl HostDebugger {
DebugCommand::Step => {
// Execute exactly one instruction and keep paused.
firmware.os.vm().set_paused(true);
let _ = firmware.os.vm().debug_step_instruction(&mut firmware.vm, hardware);
let _ = firmware.os.vm().debug_step_instruction(&mut firmware.vm, platform);
}
DebugCommand::StepFrame => {
// Execute until the end of the current logical frame.
@ -372,7 +372,7 @@ impl HostDebugger {
#[cfg(test)]
mod tests {
use super::*;
use prometeu_drivers::hardware::Hardware;
use prometeu_drivers::TestPlatform;
use prometeu_hal::debugger_protocol::DebugCommand;
#[test]
@ -396,15 +396,15 @@ mod tests {
fn handle_command_updates_pause_and_step_flags_without_host_io() {
let mut debugger = HostDebugger::new();
let mut firmware = Firmware::new(None);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
debugger.handle_command(DebugCommand::Pause, &mut firmware, &mut hardware);
debugger.handle_command(DebugCommand::Pause, &mut firmware, &mut platform);
assert!(firmware.os.vm().paused());
debugger.handle_command(DebugCommand::Resume, &mut firmware, &mut hardware);
debugger.handle_command(DebugCommand::Resume, &mut firmware, &mut platform);
assert!(!firmware.os.vm().paused());
debugger.handle_command(DebugCommand::StepFrame, &mut firmware, &mut hardware);
debugger.handle_command(DebugCommand::StepFrame, &mut firmware, &mut platform);
assert!(!firmware.os.vm().paused());
assert!(firmware.os.vm().debug_step_requested());
}
@ -413,12 +413,12 @@ mod tests {
fn handle_command_start_leaves_waiting_for_start_mode() {
let mut debugger = HostDebugger::new();
let mut firmware = Firmware::new(None);
let mut hardware = Hardware::new();
let mut platform = TestPlatform::new();
debugger.waiting_for_start = true;
firmware.os.vm().set_paused(true);
debugger.handle_command(DebugCommand::Start, &mut firmware, &mut hardware);
debugger.handle_command(DebugCommand::Start, &mut firmware, &mut platform);
assert!(!debugger.waiting_for_start);
assert!(!firmware.os.vm().paused());

View File

@ -1,4 +1,3 @@
use prometeu_drivers::hardware::Hardware;
use prometeu_hal::InputSignals;
use winit::event::{ElementState, MouseButton, WindowEvent};
use winit::keyboard::{KeyCode, PhysicalKey};
@ -14,7 +13,7 @@ struct FbViewport {
}
impl FbViewport {
fn framebuffer_viewport(window: &Window) -> Option<FbViewport> {
fn framebuffer_viewport(window: &Window, display_size: (usize, usize)) -> Option<FbViewport> {
let size = window.inner_size();
let win_w = size.width as f32;
@ -24,8 +23,8 @@ impl FbViewport {
return None;
}
let fb_w = Hardware::W as f32;
let fb_h = Hardware::H as f32;
let fb_w = display_size.0 as f32;
let fb_h = display_size.1 as f32;
let scale_x = (win_w / fb_w).floor();
let scale_y = (win_h / fb_h).floor();
@ -44,17 +43,18 @@ impl FbViewport {
pub struct HostInputHandler {
pub signals: InputSignals,
display_size: (usize, usize),
}
impl Default for HostInputHandler {
fn default() -> Self {
Self::new()
Self::new((480, 270))
}
}
impl HostInputHandler {
pub fn new() -> Self {
Self { signals: InputSignals::default() }
pub fn new(display_size: (usize, usize)) -> Self {
Self { signals: InputSignals::default(), display_size }
}
pub fn handle_event(&mut self, event: &WindowEvent, window: &Window) {
@ -87,7 +87,9 @@ impl HostInputHandler {
}
WindowEvent::CursorMoved { position, .. } => {
if let Some((x, y)) = window_to_fb(position.x as f32, position.y as f32, window) {
if let Some((x, y)) =
window_to_fb(position.x as f32, position.y as f32, window, self.display_size)
{
self.signals.x_pos = x;
self.signals.y_pos = y;
}
@ -107,8 +109,13 @@ impl HostInputHandler {
_ => {}
}
fn window_to_fb(wx: f32, wy: f32, window: &Window) -> Option<(i32, i32)> {
let viewport = FbViewport::framebuffer_viewport(window)?;
fn window_to_fb(
wx: f32,
wy: f32,
window: &Window,
display_size: (usize, usize),
) -> Option<(i32, i32)> {
let viewport = FbViewport::framebuffer_viewport(window, display_size)?;
let local_x = wx - viewport.x;
let local_y = wy - viewport.y;
@ -120,7 +127,10 @@ impl HostInputHandler {
let fb_x = (local_x / viewport.scale).floor() as i32;
let fb_y = (local_y / viewport.scale).floor() as i32;
Some((fb_x.clamp(0, Hardware::W as i32 - 1), fb_y.clamp(0, Hardware::H as i32 - 1)))
Some((
fb_x.clamp(0, display_size.0 as i32 - 1),
fb_y.clamp(0, display_size.1 as i32 - 1),
))
}
}
}

View File

@ -4,13 +4,16 @@ use crate::fs_backend::HostDirBackend;
use crate::input::HostInputHandler;
use crate::log_sink::HostConsoleSink;
use crate::stats::HostStats;
use crate::utilities::draw_rgba8888_to_rgba8;
use crate::utilities::{draw_owned_rgba8888_frame_to_rgba8, draw_rgba8888_to_rgba8};
use pixels::wgpu::PresentMode;
use pixels::{Pixels, PixelsBuilder, SurfaceTexture};
use prometeu_drivers::AudioCommand;
use prometeu_drivers::hardware::Hardware;
use prometeu_drivers::{AudioCommand, LocalFramebufferRenderBackend, MemoryBanks};
use prometeu_firmware::{BootTarget, Firmware, FirmwareState};
use prometeu_hal::RuntimePlatform;
use prometeu_hal::telemetry::CertificationConfig;
use prometeu_system::{LatestRenderFrameStore, RenderWorkerConfig};
use std::sync::Arc;
use std::time::{Duration, Instant};
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
@ -85,6 +88,10 @@ fn desired_control_flow(
}
}
fn should_present_worker_frame(state: &FirmwareState) -> bool {
matches!(state, FirmwareState::GameRunning(_))
}
/// The Desktop implementation of the PROMETEU Runtime.
///
/// This struct acts as the physical "chassis" of the virtual console. It is
@ -131,6 +138,8 @@ pub struct HostRunner {
audio: HostAudio,
/// Last known pause state to sync with audio.
last_paused_state: bool,
/// Worker-published frame store used as the single observable publication source.
render_frame_store: LatestRenderFrameStore,
/// Tracks whether a new logical frame or host-only surface invalidation requires presentation.
presentation: PresentationState,
}
@ -152,12 +161,28 @@ impl HostRunner {
firmware.os.fs().mount(Box::new(backend));
}
let memory_banks = Arc::new(MemoryBanks::new());
let hardware = Hardware::new_with_memory_banks(Arc::clone(&memory_banks));
let display_size = hardware.display_size();
let worker_backend = LocalFramebufferRenderBackend::new_with_memory_banks(
display_size.0,
display_size.1,
memory_banks,
hardware.assets.glyph_asset_slot_index(),
);
let render_frame_store = LatestRenderFrameStore::default();
firmware.os.vm().start_render_worker(
RenderWorkerConfig::default(),
worker_backend,
render_frame_store.clone(),
);
Self {
window: None,
pixels: None,
hardware: Hardware::new(),
hardware,
firmware,
input: HostInputHandler::new(),
input: HostInputHandler::new(display_size),
fs_root,
log_sink: HostConsoleSink::new(),
frame_target_dt: Duration::from_nanos(1_000_000_000 / target_fps),
@ -168,6 +193,7 @@ impl HostRunner {
debugger: HostDebugger::new(),
audio: HostAudio::new(),
last_paused_state: false,
render_frame_store,
presentation: PresentationState::default(),
}
}
@ -221,18 +247,20 @@ impl ApplicationHandler for HostRunner {
let window = event_loop.create_window(attrs).expect("failed to create window");
// 🔥 Leak: Window becomes &'static Window (bootstrap)
// `pixels` stores a surface tied to the window lifetime. The current
// winit runner owns one process-lifetime window, so we intentionally
// promote it to a static reference for the duration of the host process.
let window: &'static Window = Box::leak(Box::new(window));
self.window = Some(window);
let size = window.inner_size();
let surface_texture = SurfaceTexture::new(size.width, size.height, window);
let mut pixels =
PixelsBuilder::new(Hardware::W as u32, Hardware::H as u32, surface_texture)
.present_mode(PresentMode::Fifo) // activate vsync
.build()
.expect("failed to create Pixels");
let (display_w, display_h) = self.hardware.display_size();
let mut pixels = PixelsBuilder::new(display_w as u32, display_h as u32, surface_texture)
.present_mode(PresentMode::Fifo)
.build()
.expect("failed to create Pixels");
pixels.frame_mut().fill(0);
@ -267,18 +295,24 @@ impl ApplicationHandler for HostRunner {
}
WindowEvent::RedrawRequested => {
// Get Pixels directly from the field (not via helper that gets the entire &mut self)
let pixels = self.pixels.as_mut().expect("pixels not initialized");
{
// Mutable borrow of the frame (lasts only within this block)
let frame = pixels.frame_mut();
// Immutable borrow of prometeu-core (different field, ok)
let src = self.hardware.gfx.front_buffer();
draw_rgba8888_to_rgba8(src, frame);
} // <- frame borrow ends here
if should_present_worker_frame(&self.firmware.state)
&& let Some(worker_frame) = self.render_frame_store.latest_frame()
{
self.firmware
.os
.vm()
.record_repeated_render_worker_frame(worker_frame.frame_id);
draw_owned_rgba8888_frame_to_rgba8(&worker_frame, frame);
} else {
let src = self.hardware.gfx.front_buffer();
draw_rgba8888_to_rgba8(src, frame);
}
}
if pixels.render().is_err() {
event_loop.exit();
@ -342,7 +376,7 @@ impl ApplicationHandler for HostRunner {
self.last_frame_time = now;
self.accumulator += frame_delta;
// 🔥 Logic Update Loop: consumes time in exact 60Hz (16.66ms) slices.
// Consume time in exact 60Hz slices.
while self.accumulator >= self.frame_target_dt {
// Unless the debugger is waiting for a 'start' command, advance the system.
if !self.debugger.waiting_for_start {
@ -368,7 +402,13 @@ impl ApplicationHandler for HostRunner {
self.stats.record_frame();
}
self.presentation.note_published_frame(self.firmware.os.frame_index());
if should_present_worker_frame(&self.firmware.state)
&& let Some(worker_frame) = self.render_frame_store.latest_frame()
{
self.presentation.note_published_frame(worker_frame.frame_id.get());
} else {
self.presentation.note_published_frame(self.firmware.os.frame_index());
}
if was_debugger_connected != self.debugger.stream.is_some()
|| was_waiting_for_start != self.debugger.waiting_for_start
@ -381,7 +421,7 @@ impl ApplicationHandler for HostRunner {
self.audio.update_stats(&mut self.stats);
// Update technical statistics displayed in the window title.
self.stats.update(now, self.window, &self.hardware, &mut self.firmware);
self.stats.update(now, self.window, &mut self.firmware);
// Synchronize system logs to the host console.
let last_seq = self.log_sink.last_seq().unwrap_or(u64::MAX);
@ -408,8 +448,14 @@ impl ApplicationHandler for HostRunner {
mod tests {
use super::*;
use prometeu_firmware::BootTarget;
use prometeu_firmware::firmware::firmware_state::{
GameRunningStep, HubHomeStep, ShellRunningStep,
};
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::debugger_protocol::DEVTOOLS_PROTOCOL_VERSION;
use prometeu_hal::telemetry::{CertificationConfig, TelemetryFrame};
use prometeu_hal::{FrameId, OwnedRgba8888Frame, RenderOwnership, RenderWorkerFrameSink};
use prometeu_system::task::TaskId;
use std::io::{Read, Write};
use std::net::TcpStream;
@ -477,6 +523,41 @@ mod tests {
}
}
#[test]
fn worker_frame_presentation_is_game_only() {
assert!(should_present_worker_frame(&FirmwareState::GameRunning(GameRunningStep::new(
TaskId(1),
))));
assert!(!should_present_worker_frame(&FirmwareState::ShellRunning(ShellRunningStep::new(
TaskId(2)
),)));
assert!(!should_present_worker_frame(&FirmwareState::HubHome(HubHomeStep)));
}
#[test]
fn host_presentation_observes_worker_publication_store() {
let store = LatestRenderFrameStore::default();
let frame = OwnedRgba8888Frame::packed(
FrameId::new(9),
RenderOwnership::new(1, AppMode::Game, 1),
1,
1,
vec![0xFF00FFFF],
)
.expect("test frame");
store.publish(frame).expect("store publish");
let mut presentation = PresentationState::default();
presentation.mark_presented();
let published = store.latest_frame().expect("published frame");
presentation.note_published_frame(published.frame_id.get());
assert!(presentation.should_request_redraw());
presentation.mark_presented();
assert_eq!(presentation.last_presented_frame, Some(9));
}
#[test]
fn host_debugger_maps_cert_events_from_host_owned_sources() {
let telemetry = TelemetryFrame {

View File

@ -1,4 +1,3 @@
use prometeu_drivers::hardware::Hardware;
use prometeu_firmware::Firmware;
use std::time::{Duration, Instant};
use winit::window::Window;
@ -60,13 +59,7 @@ impl HostStats {
}
}
pub fn update(
&mut self,
now: Instant,
window: Option<&Window>,
_hardware: &Hardware,
firmware: &mut Firmware,
) {
pub fn update(&mut self, now: Instant, window: Option<&Window>, firmware: &mut Firmware) {
let stats_elapsed = now.duration_since(self.last_stats_update);
if stats_elapsed >= Duration::from_secs(1) {
self.current_fps = self.frames_since_last_update as f64 / stats_elapsed.as_secs_f64();

View File

@ -1,3 +1,5 @@
use prometeu_hal::OwnedRgba8888Frame;
/// Copies RGBA8888 pixels in canonical RGBA raw order into the `pixels` RGBA8 frame.
pub fn draw_rgba8888_to_rgba8(src: &[u32], dst_rgba: &mut [u8]) {
for (i, &px) in src.iter().enumerate() {
@ -9,9 +11,15 @@ pub fn draw_rgba8888_to_rgba8(src: &[u32], dst_rgba: &mut [u8]) {
}
}
pub fn draw_owned_rgba8888_frame_to_rgba8(frame: &OwnedRgba8888Frame, dst_rgba: &mut [u8]) {
draw_rgba8888_to_rgba8(&frame.pixels[..frame.required_pixel_len()], dst_rgba);
}
#[cfg(test)]
mod tests {
use super::draw_rgba8888_to_rgba8;
use super::{draw_owned_rgba8888_frame_to_rgba8, draw_rgba8888_to_rgba8};
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::{FrameId, OwnedRgba8888Frame, RenderOwnership};
#[test]
fn draw_rgba8888_to_rgba8_preserves_channel_order_and_alpha() {
@ -22,4 +30,21 @@ mod tests {
assert_eq!(dst, [0x12, 0x34, 0x56, 0x78, 0xAB, 0xCD, 0xEF, 0x01]);
}
#[test]
fn draw_owned_frame_to_rgba8_uses_owned_rgba8888_pixels() {
let frame = OwnedRgba8888Frame::packed(
FrameId::new(3),
RenderOwnership::new(1, AppMode::Game, 7),
1,
1,
vec![0x10203040],
)
.expect("test frame should be valid");
let mut dst = [0; 4];
draw_owned_rgba8888_frame_to_rgba8(&frame, &mut dst);
assert_eq!(dst, [0x10, 0x20, 0x30, 0x40]);
}
}

View File

@ -41,13 +41,6 @@ fn asm(s: &str) -> Vec<u8> {
pub fn generate() -> Result<()> {
let mut rom: Vec<u8> = Vec::new();
let syscalls = vec![
SyscallDecl {
module: "gfx2d".into(),
name: "clear".into(),
version: 1,
arg_slots: 1,
ret_slots: 0,
},
SyscallDecl {
module: "gfx2d".into(),
name: "draw_text".into(),
@ -143,17 +136,15 @@ fn heavy_load(rom: &mut Vec<u8>) {
let jif_bind_done_offset = rom.len() + 2;
rom.extend(asm("JMP_IF_FALSE 0"));
rom.extend(asm("PUSH_I32 0\nHOSTCALL 3\nPOP_N 1\nPUSH_I32 1\nSET_GLOBAL 1"));
rom.extend(asm("PUSH_I32 0\nHOSTCALL 2\nPOP_N 1\nPUSH_I32 1\nSET_GLOBAL 1"));
let bind_done_target = rom.len() as u32;
rom.extend(asm("PUSH_I32 0\nHOSTCALL 0"));
let scene_width_px = (STRESS_SCENE_TILE_W * 8) as i32;
let scene_height_px = (STRESS_SCENE_TILE_H * 8) as i32;
let camera_x_span = scene_width_px - STRESS_VIEWPORT_W;
let camera_y_span = scene_height_px - STRESS_VIEWPORT_H;
rom.extend(asm(&format!(
"GET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 {camera_x_span}\nMOD\nGET_GLOBAL 0\nPUSH_I32 {camera_y_span}\nMOD\nHOSTCALL 4"
"GET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 {camera_x_span}\nMOD\nGET_GLOBAL 0\nPUSH_I32 {camera_y_span}\nMOD\nHOSTCALL 3"
)));
rom.extend(asm("PUSH_I32 0\nSET_LOCAL 0"));
@ -178,7 +169,7 @@ fn heavy_load(rom: &mut Vec<u8>) {
GET_LOCAL 1\nGET_GLOBAL 0\nADD\nPUSH_I32 1\nBIT_AND\nPUSH_I32 0\nNEQ\n\
GET_LOCAL 0\nGET_GLOBAL 0\nADD\nPUSH_I32 1\nBIT_AND\nPUSH_I32 0\nNEQ\n\
GET_LOCAL 0\nGET_LOCAL 1\nADD\nPUSH_I32 4\nMOD\n\
HOSTCALL 5\nPOP_N 1"
HOSTCALL 4\nPOP_N 1"
)));
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 1\nADD\nSET_LOCAL 1"));
@ -195,27 +186,27 @@ fn heavy_load(rom: &mut Vec<u8>) {
PUSH_I32 8\n\
PUSH_CONST 0\n\
GET_GLOBAL 0\nPUSH_I32 2047\nMUL\nPUSH_I32 65535\nBIT_AND\n\
HOSTCALL 1"));
HOSTCALL 0"));
rom.extend(asm("PUSH_I32 16\n\
GET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 186\nMOD\nPUSH_I32 32\nADD\n\
PUSH_CONST 1\n\
GET_GLOBAL 0\nPUSH_I32 4093\nMUL\nPUSH_I32 65535\nBIT_AND\n\
HOSTCALL 1"));
HOSTCALL 0"));
rom.extend(asm("PUSH_I32 336\n\
GET_GLOBAL 0\nPUSH_I32 5\nMUL\nPUSH_I32 194\nMOD\n\
PUSH_CONST 2\n\
GET_GLOBAL 0\nPUSH_I32 1237\nMUL\nPUSH_I32 65535\nBIT_AND\n\
HOSTCALL 1"));
HOSTCALL 0"));
rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 4\nMUL\nPUSH_I32 240\nMOD\nPUSH_I32 112\nADD\n\
GET_GLOBAL 0\nPUSH_I32 3\nMUL\nPUSH_I32 120\nMOD\nPUSH_I32 88\nADD\n\
PUSH_CONST 3\n\
GET_GLOBAL 0\nPUSH_I32 3001\nMUL\nPUSH_I32 65535\nBIT_AND\n\
HOSTCALL 1"));
HOSTCALL 0"));
rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 60\nMOD\nPUSH_I32 0\nEQ"));
let jif_log_offset = rom.len() + 2;
rom.extend(asm("JMP_IF_FALSE 0"));
rom.extend(asm("PUSH_I32 2\nPUSH_CONST 0\nHOSTCALL 2"));
rom.extend(asm("PUSH_I32 2\nPUSH_CONST 0\nHOSTCALL 1"));
let after_log = rom.len() as u32;
rom.extend(asm("FRAME_SYNC\nRET"));

View File

@ -1,9 +1,13 @@
{"type":"meta","next_id":{"DSC":39,"AGD":39,"DEC":31,"PLN":87,"LSN":47,"CLSN":1}}
{"type":"discussion","id":"DSC-0038","status":"in_progress","ticket":"render-frame-packet-boundary","title":"Logical Render Pipelines and Command Packets","created_at":"2026-05-25","updated_at":"2026-05-25","tags":["gfx","renderer","runtime","frame-composer","architecture","ui","pipeline"],"agendas":[{"id":"AGD-0038","file":"AGD-0038-renderframepacket-boundary-for-classic-2d-renderer.md","status":"accepted","created_at":"2026-05-25","updated_at":"2026-05-25"}],"decisions":[{"id":"DEC-0030","file":"DEC-0030-logical-render-pipeline-command-boundary.md","status":"accepted","created_at":"2026-05-25","updated_at":"2026-05-25","ref_agenda":"AGD-0038"}],"plans":[{"id":"PLN-0073","file":"PLN-0073-render-contract-specs.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0074","file":"PLN-0074-hal-render-submission-types.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0075","file":"PLN-0075-rendermanager-core.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0076","file":"PLN-0076-capabilities-and-abi-domain-split.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0077","file":"PLN-0077-composer-buffer-and-game2d-packet.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0078","file":"PLN-0078-classic2d-game-renderer-consumer.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0079","file":"PLN-0079-gfx2d-primitive-domain.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0080","file":"PLN-0080-shell-ui-packet-and-gfxui-domain.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0081","file":"PLN-0081-shell-hub-migration.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0082","file":"PLN-0082-frame-publication-and-present-boundary.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0083","file":"PLN-0083-fade-removal.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0084","file":"PLN-0084-host-debug-overlay-removal.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0085","file":"PLN-0085-pbs-stdlib-and-syscall-declarations.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]},{"id":"PLN-0086","file":"PLN-0086-end-to-end-render-boundary-validation.md","status":"done","created_at":"2026-05-25","updated_at":"2026-05-25","ref_decisions":["DEC-0030"]}],"lessons":[]}
{"type":"meta","next_id":{"DSC":43,"AGD":44,"DEC":34,"PLN":123,"LSN":50,"CLSN":1}}
{"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-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-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-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-0034","status":"done","ticket":"system-os-domain-facades","title":"Agenda - SystemOS Domain Facades","created_at":"2026-05-15","updated_at":"2026-05-15","tags":["runtime","os","services","api-surface","lifecycle","fs"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0043","file":"discussion/lessons/DSC-0034-system-os-domain-facades/LSN-0043-systemos-domain-facades.md","status":"done","created_at":"2026-05-15","updated_at":"2026-05-15"}]}
{"type":"discussion","id":"DSC-0023","status":"done","ticket":"perf-full-migration-to-atomic-telemetry","title":"Agenda - [PERF] Full Migration to Atomic Telemetry","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["perf","runtime","telemetry"],"agendas":[{"id":"AGD-0021","file":"AGD-0021-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"decisions":[{"id":"DEC-0008","file":"DEC-0008-full-migration-to-atomic-telemetry.md","status":"accepted","created_at":"2026-04-10","updated_at":"2026-04-10"}],"plans":[{"id":"PLN-0007","file":"PLN-0007-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"lessons":[{"id":"LSN-0028","file":"discussion/lessons/DSC-0023-perf-full-migration-to-atomic-telemetry/LSN-0028-converging-to-single-atomic-telemetry-source.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0020","status":"done","ticket":"jenkins-gitea-integration","title":"Jenkins Gitea Integration and Relocation","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["ci","jenkins","gitea"],"agendas":[{"id":"AGD-0018","file":"AGD-0018-jenkins-gitea-integration-and-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0003","file":"DEC-0003-jenkins-gitea-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0003","file":"PLN-0003-jenkins-gitea-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0021","file":"discussion/lessons/DSC-0020-jenkins-gitea-integration/LSN-0021-jenkins-gitea-integration.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0023","status":"done","ticket":"perf-full-migration-to-atomic-telemetry","title":"Agenda - [PERF] Full Migration to Atomic Telemetry","created_at":"2026-04-10","updated_at":"2026-06-04","tags":["perf","runtime","telemetry"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0028","file":"discussion/lessons/DSC-0023-perf-full-migration-to-atomic-telemetry/LSN-0028-converging-to-single-atomic-telemetry-source.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0020","status":"done","ticket":"jenkins-gitea-integration","title":"Jenkins Gitea Integration and Relocation","created_at":"2026-04-07","updated_at":"2026-06-04","tags":["ci","jenkins","gitea"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0021","file":"discussion/lessons/DSC-0020-jenkins-gitea-integration/LSN-0021-jenkins-gitea-integration.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0021","status":"done","ticket":"asset-entry-codec-enum-with-metadata","title":"Asset Entry Codec Enum Contract","created_at":"2026-04-09","updated_at":"2026-04-09","tags":["asset","runtime","codec","metadata"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0024","file":"discussion/lessons/DSC-0021-asset-entry-codec-enum-contract/LSN-0024-string-on-the-wire-enum-in-runtime.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}
{"type":"discussion","id":"DSC-0022","status":"done","ticket":"tile-bank-vs-glyph-bank-domain-naming","title":"Glyph Bank Domain Naming Contract","created_at":"2026-04-09","updated_at":"2026-04-10","tags":["gfx","runtime","naming","domain-model"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0025","file":"discussion/lessons/DSC-0022-glyph-bank-domain-naming/LSN-0025-rename-artifact-by-meaning-not-by-token.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"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"}]}
@ -13,7 +17,7 @@
{"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-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-0008","status":"done","ticket":"perf-runtime-telemetry-hot-path","title":"Agenda - [PERF] Runtime Telemetry Hot Path","created_at":"2026-03-27","updated_at":"2026-04-10","tags":[],"agendas":[{"id":"AGD-0007","file":"AGD-0007-perf-runtime-telemetry-hot-path.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-10"}],"decisions":[{"id":"DEC-0005","file":"DEC-0005-perf-push-based-telemetry-model.md","status":"accepted","created_at":"2026-04-10","updated_at":"2026-04-10"}],"plans":[{"id":"PLN-0005","file":"PLN-0005-perf-push-based-telemetry-implementation.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"lessons":[{"id":"LSN-0026","file":"discussion/lessons/DSC-0008-perf-runtime-telemetry-hot-path/LSN-0026-push-based-telemetry-model.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0008","status":"done","ticket":"perf-runtime-telemetry-hot-path","title":"Agenda - [PERF] Runtime Telemetry Hot Path","created_at":"2026-03-27","updated_at":"2026-06-04","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0026","file":"discussion/lessons/DSC-0008-perf-runtime-telemetry-hot-path/LSN-0026-push-based-telemetry-model.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0009","status":"open","ticket":"perf-async-background-work-lanes-for-assets-and-fs","title":"Agenda - [PERF] Async Background Work Lanes for Assets and FS","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0008","file":"AGD-0008-perf-async-background-work-lanes-for-assets-and-fs.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0010","status":"done","ticket":"perf-host-desktop-frame-pacing-and-presentation","title":"Agenda - [PERF] Host Desktop Frame Pacing and Presentation","created_at":"2026-03-27","updated_at":"2026-04-20","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0036","file":"discussion/lessons/DSC-0010-perf-host-desktop-frame-pacing-and-presentation/LSN-0036-frame-publication-and-host-invalidation-must-be-separate.md","status":"done","created_at":"2026-04-20","updated_at":"2026-04-20"}]}
{"type":"discussion","id":"DSC-0011","status":"open","ticket":"perf-gfx-render-pipeline-and-dirty-regions","title":"Agenda - [PERF] GFX Render Pipeline and Dirty Regions","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0010","file":"AGD-0010-perf-gfx-render-pipeline-and-dirty-regions.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
@ -27,10 +31,10 @@
{"type":"discussion","id":"DSC-0029","status":"done","ticket":"scene-bank-glyph-runtime-binding-leak","title":"Scene Bank Glyph Runtime Binding Leak","created_at":"2026-04-24","updated_at":"2026-04-24","tags":["gfx","runtime","asset","scene","glyph","format","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0038","file":"discussion/lessons/DSC-0029-scene-bank-glyph-runtime-binding-leak/LSN-0038-cold-scene-dependencies-must-bind-by-asset-identity.md","status":"done","created_at":"2026-04-24","updated_at":"2026-04-24"}]}
{"type":"discussion","id":"DSC-0014","status":"done","ticket":"perf-vm-allocation-and-copy-pressure","title":"Agenda - [PERF] VM Allocation and Copy Pressure","created_at":"2026-03-27","updated_at":"2026-04-20","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0035","file":"discussion/lessons/DSC-0014-perf-vm-allocation-and-copy-pressure/LSN-0035-first-materialization-is-not-the-same-as-hot-path-copy-pressure.md","status":"done","created_at":"2026-04-20","updated_at":"2026-04-20"}]}
{"type":"discussion","id":"DSC-0015","status":"abandoned","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-04-20","tags":[],"agendas":[{"id":"AGD-0014","file":"AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"abandoned","created_at":"2026-03-27","updated_at":"2026-04-20","_override_reason":"User explicitly chose to close the discussion without decision because FS->memory copy for the program is already acceptable."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to abandon the discussion without creating a decision because FS->memory copy for the program is already acceptable."}
{"type":"discussion","id":"DSC-0016","status":"done","ticket":"tilemap-empty-cell-vs-tile-id-zero","title":"Tilemap Empty Cell vs Tile ID Zero","created_at":"2026-03-27","updated_at":"2026-04-09","tags":[],"agendas":[{"id":"AGD-0015","file":"AGD-0015-tilemap-empty-cell-vs-tile-id-zero.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-09"}],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0022","file":"discussion/lessons/DSC-0016-tilemap-empty-cell-semantics/LSN-0022-tilemap-empty-cell-convergence.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}
{"type":"discussion","id":"DSC-0017","status":"done","ticket":"asset-entry-metadata-normalization-contract","title":"Asset Entry Metadata Normalization Contract","created_at":"2026-03-27","updated_at":"2026-04-09","tags":[],"agendas":[{"id":"AGD-0016","file":"AGD-0016-asset-entry-metadata-normalization-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-09"}],"decisions":[{"id":"DEC-0004","file":"DEC-0004-asset-entry-metadata-normalization-contract.md","status":"accepted","created_at":"2026-04-09","updated_at":"2026-04-09"}],"plans":[],"lessons":[{"id":"LSN-0023","file":"discussion/lessons/DSC-0017-asset-metadata-normalization/LSN-0023-typed-asset-metadata-helpers.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}
{"type":"discussion","id":"DSC-0016","status":"done","ticket":"tilemap-empty-cell-vs-tile-id-zero","title":"Tilemap Empty Cell vs Tile ID Zero","created_at":"2026-03-27","updated_at":"2026-06-04","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0022","file":"discussion/lessons/DSC-0016-tilemap-empty-cell-semantics/LSN-0022-tilemap-empty-cell-convergence.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}
{"type":"discussion","id":"DSC-0017","status":"done","ticket":"asset-entry-metadata-normalization-contract","title":"Asset Entry Metadata Normalization Contract","created_at":"2026-03-27","updated_at":"2026-06-04","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0023","file":"discussion/lessons/DSC-0017-asset-metadata-normalization/LSN-0023-typed-asset-metadata-helpers.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}
{"type":"discussion","id":"DSC-0018","status":"done","ticket":"asset-load-asset-id-int-contract","title":"Asset Load Asset ID Int Contract","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["asset","runtime","abi"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0019","file":"discussion/lessons/DSC-0018-asset-load-asset-id-int-contract/LSN-0019-asset-load-id-abi-convergence.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
{"type":"discussion","id":"DSC-0019","status":"done","ticket":"jenkinsfile-correction","title":"Jenkinsfile Correction and Relocation","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["ci","jenkins"],"agendas":[{"id":"AGD-0017","file":"AGD-0017-jenkinsfile-correction.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0002","file":"DEC-0002-jenkinsfile-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0002","file":"PLN-0002-jenkinsfile-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0020","file":"discussion/lessons/DSC-0019-jenkins-ci-standardization/LSN-0020-jenkins-standard-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0019","status":"done","ticket":"jenkinsfile-correction","title":"Jenkinsfile Correction and Relocation","created_at":"2026-04-07","updated_at":"2026-06-04","tags":["ci","jenkins"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0020","file":"discussion/lessons/DSC-0019-jenkins-ci-standardization/LSN-0020-jenkins-standard-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0030","status":"done","ticket":"internal-viewport-270p","title":"Agenda - Internal Viewport 270p (480x270)","created_at":"2026-04-27","updated_at":"2026-04-28","tags":["gfx","runtime","viewport","resolution","frame-composer","host"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0039","file":"discussion/lessons/DSC-0030-internal-viewport-270p/LSN-0039-resolution-baseline-changes-are-runtime-wide-contracts.md","status":"done","created_at":"2026-04-28","updated_at":"2026-04-28"}]}
{"type":"discussion","id":"DSC-0031","status":"done","ticket":"runtime-mode-separation-game-system","title":"Agenda - Runtime Mode Separation: Game and System","created_at":"2026-05-11","updated_at":"2026-05-14","tags":["runtime","firmware","hub","system-apps","game-mode","scheduler"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0040","file":"discussion/lessons/DSC-0031-runtime-mode-separation-game-system/LSN-0040-system-pipeline-separation.md","status":"done","created_at":"2026-05-14","updated_at":"2026-05-14"}]}
{"type":"discussion","id":"DSC-0032","status":"done","ticket":"system-os-lifecycle-process-task-contract","title":"Agenda - SystemOS Lifecycle, Process and Task Contract","created_at":"2026-05-14","updated_at":"2026-05-15","tags":["runtime","os","lifecycle","process","task","shell","firmware"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0041","file":"discussion/lessons/DSC-0032-system-os-lifecycle-process-task-contract/LSN-0041-systemos-lifecycle-authority.md","status":"done","created_at":"2026-05-15","updated_at":"2026-05-15"}]}

View File

@ -0,0 +1,54 @@
---
id: LSN-0047
ticket: render-frame-packet-boundary
title: Typed Render Submissions Preserve Domain Boundaries
created: 2026-06-04
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
# LSN-0047: Typed Render Submissions Preserve Domain Boundaries
## Context
The RGBA8888 migration made the framebuffer format explicit, but the runtime still needed a cleaner boundary between logical frame production and surface publication. The important follow-up was not a GPU backend, a render thread, or a universal display list. It was the separation between domain-owned logical buffers, closed render submissions, and the surface implementation that turns those submissions into pixels.
`DSC-0038` closed that boundary across specs, runtime types, mode routing, PBS declarations, shell migration, host overlay removal, and validation. The resulting model treats rendering as a runtime contract above the final desktop surface rather than as direct mutable access to `Gfx` or a framebuffer.
## Key Decisions
### Logical Render Pipeline Command Boundary
**What:** Prometeu introduced a runtime-level `RenderManager` that closes mode-appropriate domain buffers into immutable `RenderSubmission` snapshots. A submission carries `frame_id`, `app_mode`, and a typed packet such as `Game2DFramePacket` or `UiFramePacket`.
**Why:** Game rendering and shell UI have different domain meanings. Game 2D owns scene, camera, sprites, HUD, and game primitives. Shell UI owns system-facing UI primitives. A weak universal command list would hide those differences and make future pipelines fit an accidental 2D/UI shape.
**Trade-offs:** The typed packet model creates more explicit types and routing work up front. In return, it keeps ABI domains honest, lets each pipeline evolve independently, and gives host/render-surface code a closed snapshot that can later be consumed off the VM hot path.
## Patterns and Algorithms
Keep three concepts separate:
1. Domain buffers are mutable while a logical frame is being built.
2. `RenderSubmission` is the immutable snapshot produced at the frame boundary.
3. Render-surface implementations consume submissions and publish RGBA8888 pixels.
Use `RenderManager` for coordination, not drawing. It owns active app mode, submission closure, transition policy, capabilities, and publication flow. It must not become the desktop renderer, the shell UI implementation, or the game composer.
Prefer typed top-level packets over a universal command list when pipelines carry different semantic contracts. Shared rasterization helpers are acceptable below the submission boundary, but shared implementation machinery must not collapse public ABI domains.
Treat publication as latest-complete-submission-wins. This keeps the v1 model simple while leaving room for VM/render parallelism without introducing an unbounded queue.
## Pitfalls
Do not preserve immediate rendering through trait-shaped abstractions that still require `&mut Gfx`, `&mut Hardware`, or live VM state during rasterization. That keeps the old coupling under a new name.
Do not move `composer` responsibilities into `gfx2d`. `composer` owns high-level Game 2D composition. `gfx2d` owns Game 2D primitives. `gfxui` owns Shell UI primitives. Similar local drawing operations below the surface adapter are implementation details.
Do not keep inherited framebuffer features, such as fade fields or host debug overlays, just because they existed before the boundary. Once the canonical render contract moves to typed submissions, legacy visual behavior either belongs in `RenderManager`, in a proper product UI, or nowhere.
## Takeaways
- Rendering architecture should name the producer boundary before introducing backend variety.
- A closed submission is the useful concurrency primitive, even when v1 consumes it synchronously.
- App mode is part of the render contract because it determines which ABI domains and packets are valid.
- Shared rendering code below the adapter is fine; shared public command shapes across unrelated domains are usually a design leak.

View File

@ -0,0 +1,104 @@
---
id: LSN-0048
ticket: vm-render-parallel-execution-boundary
title: Render Workers Need a Closed Packet Contract
created: 2026-06-06
tags: [runtime, renderer, vm, concurrency, handoff, telemetry]
---
## Context
`DSC-0040` took the runtime from a local "close and immediately render" model toward an architecture that can later support a real render worker, separate thread, separate core, or host-provided renderer.
The important result was not a real worker thread. The result was the contract a real worker must obey: the VM/logical side produces closed render submissions; the render side consumes those submissions without relying on live mutable VM, `Hardware`, `Gfx`, or `FrameComposer` state.
This work built on `DSC-0038`, where typed render submissions became the domain boundary. `DSC-0040` made that boundary operational by adding ownership, pacing, handoff, AppMode policy, shutdown semantics, and telemetry.
## Key Decisions
### Game Frames Are Closed Submissions
**What:** `RenderSubmissionPacket::Game2D` is the render consumer's complete frame description. The consumer must compose scene/layers/sprites first and then apply `gfx2d` primitives as the final overlay.
**Why:** A worker cannot safely reconstruct a frame from live runtime state. If an active-scene path bypasses the packet and calls a live `FrameComposer` path, the packet is no longer a true handoff boundary.
**Trade-offs:** Keeping packets small means heavy resources are referenced by stable IDs rather than copied per frame. That requires disciplined resource lifetime and read-only access APIs.
### Handoff Is Single-Slot Latest-Wins
**What:** The producer writes one pending submission. A newer submission replaces an older unconsumed one, and the replacement is telemetry.
**Why:** Render queues should not accumulate stale game frames. Latest-wins bounds memory, latency, and stale presentation risk.
**Trade-offs:** The system does not guarantee that every produced frame is consumed or presented. VM semantics must not depend on present acknowledgement.
### Frame Pacing Is Not Render ACK
**What:** Game logical frames are paced by a frame scheduler, not by render completion. `FRAME_SYNC` remains the logical frame boundary.
**Why:** Letting the worker decide when the VM may advance mixes display/raster completion with logical simulation authority. The VM should not run freely, but it also should not wait for a specific render ACK.
**Trade-offs:** A delayed render consumer repeats the last valid frame, while logical frames remain sequential. Overruns are diagnostics and telemetry, not hidden catch-up simulation.
### AppMode Owns Render Policy
**What:** Game workloads are frame-paced and worker-capable with local fallback. Shell/System UI remains lifecycle-driven and local/synchronous by default.
**Why:** A Shell app has a different lifecycle from a game loop. Treating Shell as just another game renderer would force unnecessary frame pacing and worker semantics into UI flows.
**Trade-offs:** The packet boundary is shared, but execution policy differs by AppMode. Future Shell worker support should be a separate decision, not an accidental inheritance from Game.
### Epoch Guards Presentation
**What:** Submissions carry render ownership. Foreground visual-owner changes update the active render epoch, and stale submissions are discarded before present.
**Why:** A worker may finish old work after the visible owner has changed. Without an epoch check, old Game, Shell, splash, or crash frames can be presented after they are no longer valid.
**Trade-offs:** Epoch protects correctness, but it does not solve foreground stack policy. Game pause/resume and VM-backed Shell coexistence belong to lifecycle/SystemOS discussions.
## Patterns and Algorithms
The durable pattern is:
```text
logical core / VM
-> close typed RenderSubmission
-> publish to single pending slot
-> render consumer takes owned submission
-> validate ownership/epoch
-> rasterize from packet + read-only resources
-> present or repeat last valid frame
-> report telemetry
```
The render manager is a coordinator, not a renderer. It owns policy, frame IDs, handoff, ownership, shutdown state, and telemetry. Concrete raster/present capability belongs to a render surface, backend, host, HAL, or future worker implementation.
The current `LocalRenderWorker` is intentionally a prototype. It proves the same policy path can select a worker-capable consumer, but it does not prove thread ownership, bounded join, stop token behavior for in-flight work, or a real 60Hz present loop.
## Pitfalls
### Treating overlay commands as background setup
Once `gfx2d` is a final overlay, `gfx2d.clear` in a scene-backed game frame clears over the composed scene and sprites. That is a cartridge/framework discipline issue: scene-backed games should not use final overlay clear as world background initialization.
### Consulting live composer state during packet consumption
The active-scene consumer must not decide from live `FrameComposer` state whether a packet has a scene. It must read `packet.composer.bound_scene`. Otherwise the worker boundary silently depends on mutable state that may already belong to a different logical frame.
### Counting telemetry without behavior
Counters such as repeated presents and shutdown discards are useful only if later worker work connects them to real display cadence and real in-flight cancellation. The local implementation establishes the surface; a real worker still needs behavior behind it.
### Assuming read-only banks solve viewport cache
Read-only glyph and scene bank access is only part of the worker boundary. The viewport cache and resolver path still need a concrete read-only or ownership model before a real threaded worker can rasterize without live `FrameComposer`.
## Takeaways
- A render worker is first a contract problem, then a threading problem.
- Closed packets must be complete enough to render without live VM/runtime state.
- Latest-wins handoff keeps render latency bounded and makes dropped frames observable.
- Game pacing belongs to the frame scheduler; render completion is telemetry, not VM semantics.
- Shell UI can share the packet concept without inheriting the Game frame loop.
- Epoch checks are the guardrail that prevents obsolete visual owners from presenting stale work.
- The real worker should be introduced as a separate discussion and plan, using this contract rather than reopening it.

View File

@ -0,0 +1,107 @@
---
id: LSN-0049
ticket: real-render-worker-establishment
title: Render Workers Publish Frames, Hosts Present Them
created: 2026-06-20
tags: [runtime, renderer, worker, host, publication, concurrency]
---
## Context
The real render worker work moved Prometeu from local, synchronous rendering toward an asynchronous worker path. The important architectural question was not only how to create a thread, but where ownership of render work, frame publication, and native presentation should live.
The final model keeps the VM producer, render worker, and desktop presenter separated:
- the VM closes logical render submissions;
- the worker consumes owned submissions through a latest-wins handoff;
- the worker publishes an owned `OwnedRgba8888Frame`;
- the desktop host uploads and presents the latest published frame through native window APIs.
This boundary is what makes the worker real without making it desktop-specific.
## Key Decisions
### Platform Boundary Before Worker Boundary
**What:** Prometeu removed the monolithic hardware bridge from runtime-facing contracts and introduced explicit platform services before establishing the real worker.
**Why:** A render worker cannot safely consume `&mut Hardware`, `&mut Gfx`, live `FrameComposer`, or mutable VM state. Those are single-threaded runtime concerns. The worker needs closed data plus read-only resource access.
**Trade-offs:** This required broad preparatory work before the worker could exist. The payoff is that the worker boundary is now testable without a native window and does not depend on host presentation details.
### Closed Submissions Are The Worker Input
**What:** The VM producer closes render work into owned `RenderSubmission` values and publishes them through a single-slot latest-wins handoff.
**Why:** The worker must not observe partially built render state or preserve an unbounded queue of historical frames. If the producer outruns the consumer, the newest complete submission wins.
**Trade-offs:** Some produced frames are intentionally dropped before consumption. That is correct for this runtime because the contract is visual freshness and VM progress, not rendering every intermediate state.
### Publication Store Is The Observable Frame Boundary
**What:** The sink that receives worker-published `OwnedRgba8888Frame` values is also the source the host reads for presentation.
**Why:** A no-op sink with a separate controller-owned latest-frame cache makes publication ambiguous. The controller should own lifecycle, shutdown, handoff, errors, and telemetry. The publication store should own the latest observable frame.
**Trade-offs:** Repeat telemetry remains a runtime concern and is recorded by frame id. Pixel retention stays in the publication store so the host can present without depending on controller internals.
### Host Presentation Remains Native And Single-Threaded
**What:** The worker never imports or calls winit, pixels, SDL, swapchain, native texture, or window APIs. The host event loop remains responsible for upload and present.
**Why:** Native presentation APIs often have thread affinity and backend-specific constraints. Keeping them in the host event loop avoids leaking host constraints into the runtime worker contract.
**Trade-offs:** The worker publishes memory frames rather than presenting directly. The host still performs the final copy/upload step, but the runtime remains portable and testable.
## Patterns and Algorithms
### Latest-Wins Handoff
Use a single pending slot plus a condition variable:
- producer publish replaces pending work if the worker has not consumed it;
- replacement increments telemetry;
- worker wait takes the latest pending submission;
- in-flight work is separate from pending work;
- producer publication never waits for render completion.
This is a bounded backpressure model. It prevents queue growth while keeping VM progress independent from render speed.
### Ownership/Epoch Stale Discard
Each submission and frame carries render ownership metadata. Before publication, the worker compares the completed frame ownership with the active ownership snapshot. If the frame is stale, it is discarded and counted.
This prevents obsolete pixels from being presented after an app, mode, or ownership transition.
### Store-Backed Publication
The publication sink stores the latest `OwnedRgba8888Frame` in owned memory. The desktop host clones or reads that frame before copying pixels into the native framebuffer.
The store is intentionally simpler than the controller. It does not own worker lifecycle. It only answers: "what is the latest worker-published frame?"
## Pitfalls
### A Worker Thread Alone Is Not A Worker Contract
Moving code to another thread is not sufficient. The contract must define input ownership, resource access, publication semantics, shutdown, stale-frame behavior, and telemetry.
### Native Presentation Must Not Leak Back Into Runtime
It is tempting to let the worker call the host presenter directly. That couples runtime execution to desktop window rules and makes deterministic tests harder. Publish owned frames first; present later in the host.
### No-Op Sinks Hide Boundary Ambiguity
A sink that accepts a frame and discards it while another component stores the actual latest frame is a design smell. The object that receives `publish(frame)` should be the observable publication boundary, or the controller should be explicitly documented as that store. Prometeu chose the store-backed sink model.
### Concurrency Tests Must Publish Before Waiting
A deterministic gate only works after work has actually reached the worker. Waiting for backend entry before publishing to the handoff causes a pipeline hang. Tests should explicitly publish pending work before waiting on worker-side synchronization.
## Takeaways
- Render workers should consume closed, owned submissions, not live runtime state.
- Latest-wins handoff is a deliberate bounded backpressure model, not a queue shortcut.
- In-flight render work must be validated against current ownership before publication.
- Worker publication and host presentation are different responsibilities.
- The sink/store that receives published frames should be the single observable source for presentation.
- Deterministic concurrency tests should synchronize on real state transitions, not sleeps or assumed scheduling.

View File

@ -1,446 +0,0 @@
---
id: AGD-0038
ticket: render-frame-packet-boundary
title: Logical Render Pipelines and Command Packets
status: accepted
created: 2026-05-25
resolved:
decision:
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
# Agenda - Logical Render Pipelines and Command Packets
## Contexto
Depois da migracao para RGBA8888, o proximo passo arquitetural do renderer nao deve ser GPU, 3D, multiplos backends ou troca dinamica complexa de pipeline. O objetivo imediato e separar melhor tres responsabilidades:
1. pipelines logicos produzem comandos de alto nivel;
2. uma implementacao de hardware/render surface consome esses comandos;
3. o framebuffer RGBA8888 atual continua sendo a superficie final no desktop.
A direcao desejada agora e explicitar pelo menos dois pipelines logicos:
```text
Game 2D Pipeline
scene + sprites + HUD
-> Game2DFramePacket / Game2DCommandSet
-> hardware/render implementation
-> framebuffer RGBA8888
Prometeu UI Pipeline
shell/home/system UI/windows
-> UiFramePacket / UiCommandSet
-> hardware/render implementation
-> framebuffer RGBA8888
```
Esses pipelines podem funcionar de formas totalmente diferentes. O ponto comum nao deve ser uma API unica pobre que force tudo a parecer igual. O ponto comum deve ser a fronteira operacional: cada pipeline publica seu proprio conjunto logico de comandos para uma implementacao de hardware/superficie.
Com isso, se depois surgir um pipeline 3D, outro pipeline 2D, ou um renderer especializado para hardware barato/DIY, o trabalho fica concentrado no novo pipeline e/ou no consumidor de comandos, sem reabrir a VM inteira.
## Problema
Hoje o runtime ainda mistura producao logica de frame com desenho imediato no `Gfx`/framebuffer.
O codigo ja tem separacoes parciais:
- `FrameComposer` coordena scene binding, camera, cache/resolver e sprites do jogo.
- `Gfx` rasteriza em `back`, faz `present()` para `front`, e expoe `front_buffer()` para o host.
- `VirtualMachineRuntime` chama `hw.render_frame()` quando o frame logico termina.
- Prometeu Hub, splash/crash e varias chamadas `gfx.*` ainda desenham diretamente via `gfx_mut()`.
- O host desktop copia o `front_buffer()` RGBA8888 para `pixels`.
O limite atual ainda e "codigo logico chama renderer", nao "pipeline publica comandos para uma implementacao".
Isso cria custo futuro:
- o pipeline 2D de jogo fica preso ao formato operacional atual de `Gfx`;
- a UI do Prometeu fica misturada ao mesmo caminho imperativo de primitives;
- um futuro pipeline 3D teria que disputar o mesmo contrato em vez de definir sua propria saida logica;
- hardware barato/DIY teria que emular detalhes de `Gfx` em vez de consumir um command set adequado;
- testes ficam presos a pixels finais mesmo quando o que interessa e o pacote logico produzido.
## Pontos Criticos
### 1. Pipeline nao e backend
Pipeline deve significar "modelo logico de producao de comandos". Backend/implementacao deve significar "como esses comandos viram pixels/superficie".
Nao queremos agora:
- registry sofisticado de backends;
- troca dinamica de pipeline;
- GPU backend;
- 3D;
- refatoracao agressiva de asset pipeline;
- mudanca visual.
Queremos uma fronteira para que esses itens possam existir depois sem contaminar o passo atual.
### 2. Game 2D e Prometeu UI nao precisam compartilhar o mesmo command set
O pipeline 2D de jogos naturalmente fala de scene/cache/camera/sprites/HUD.
O pipeline de UI do Prometeu naturalmente fala de janelas, paineis, texto, retangulos, estados de foco, home/shell/system UI, talvez layout e clipping.
Forcar ambos a um `RenderCommand` generico demais no v1 pode criar uma abstracao fraca. A agenda deve decidir se o contrato comum sera:
- um enum top-level de pacotes por pipeline; ou
- uma trait de submissao ao hardware; ou
- um `FrameSubmission` que contem command sets tipados por dominio.
### 3. `FrameComposer` continua sendo bom candidato para o Game 2D Pipeline
`FrameComposer` ja e o ponto natural para produzir o pacote de jogo:
- scene binding;
- camera;
- cache/resolver;
- sprites por frame;
- HUD/primitives de jogo quando aplicavel.
O menor corte para jogo ainda parece ser transformar `FrameComposer.render_frame(&mut dyn GfxBridge)` em uma producao de `Game2DFramePacket`, consumida em seguida pelo renderer Classic2D atual.
### 4. Prometeu UI precisa sair do desenho direto, mas talvez nao no mesmo PR
Prometeu Hub e system UI hoje usam `hw.gfx_mut()` diretamente. Isso e exatamente o tipo de acoplamento que a arquitetura quer remover, mas migrar UI junto com Game 2D pode deixar a primeira mudanca grande demais.
A decisao precisa escolher entre:
- definir os dois contratos agora e implementar em fases;
- implementar primeiro Game 2D e registrar UI como segundo plan;
- ou implementar um envelope comum e dois producers minimos no mesmo plano.
### 5. HUD pertence ao pipeline 2D de jogo, nao ao Prometeu UI por padrao
Para esta agenda, "HUD" significa HUD do jogo como parte do frame de jogo: overlays do cart/gameplay, texto/debug de jogo e primitives 2D associadas ao jogo.
Prometeu UI significa UI do sistema: hub, shell, janelas, home, crash/splash se forem tratados como telas do sistema.
Essa separacao evita confundir UI de jogo com UI do console.
### 6. A implementacao inicial ainda pode ser uma so
Mesmo com dois pipelines logicos, a implementacao inicial pode continuar sendo o `Gfx`/Classic2D atual gravando em RGBA8888. O ganho arquitetural nao exige multiplos backends. Exige que a entrada da implementacao deixe de ser acesso direto ao framebuffer e passe a ser submissao de comandos logicos.
### 7. A fronteira deve preparar VM e render pipeline paralelos
Render thread nao e objetivo do v1, mas a fronteira nao deve impedir que a VM e o renderer rodem em paralelo depois.
Isso implica que a submissao de frame deve ser tratada como um snapshot fechado do trabalho de render daquele frame. Depois de publicada, a VM nao deve continuar mutando estruturas que o renderer precisa ler. A implementacao inicial pode consumir o snapshot imediatamente e no mesmo thread, mas o contrato deve evitar dependencias como `&mut Hardware`, `&mut Gfx`, ou acesso mutavel ao estado vivo da VM durante a rasterizacao.
Para o v1, isso sugere:
- `RenderSubmission` deve carregar `frame_id`, `app_mode` e dados suficientes para identificar a ordem de publicacao;
- command sets pequenos devem preferir dados owned ou buffers owned pelo produtor ate a publicacao;
- referencias pesadas, como cache/assets, devem ser read-only e ter ownership compartilhado ou lifetime claramente limitado;
- a camada de hardware/render surface deve ser a unica dona de publicacao/present;
- a VM deve produzir/submeter comandos, nao desenhar nem esperar acesso ao framebuffer;
- testes devem conseguir validar o packet/submission sem depender apenas do framebuffer final.
### 8. Deve existir um `RenderManager` acima dos pipelines
Mesmo evitando registry sofisticado de backends no v1, a arquitetura precisa de uma camada acima dos pipelines individuais. Essa camada nao deve ser "mais um pipeline"; ela deve coordenar:
- qual `AppMode` esta ativo (`Game` ou `Shell`);
- quais command buffers/submissions estao validos para esse modo;
- transicoes entre modos;
- publicacao da superficie;
- politica de latest frame;
- capacidades disponiveis por modo.
Motivacao principal: transicoes entre Shell e Game nao pertencem completamente a nenhum dos dois pipelines. Exemplo: o Shell UI pode executar uma transicao visual de saida ao abrir um jogo, mas depois que o Game 2D assume, o Shell UI ja nao existe mais para executar a transicao de entrada do jogo. Isso sugere uma base comum de render/control plane acima dos pipelines, capaz de manter transicoes e estado de superficie durante a troca de modo.
Essa camada deve se chamar `RenderManager`. O nome indica coordenacao de render, nao implementacao de desenho. A decisao precisa separar com cuidado:
- `RenderManager` como abstracao/runtime contract de coordenacao;
- host/render-surface implementation como adaptador local que sabe publicar em uma superficie real.
O `RenderManager` coordena pipelines e superficie, mas nao produz comandos de jogo/UI e nao deve conter detalhes de host desktop.
### 9. Command buffers e submissions sao conceitos diferentes
Cada dominio/pipeline acumula estado e comandos em seu proprio buffer mutavel durante o frame logico. Exemplos:
- `ComposerBuffer` para scene, camera e sprites;
- `Gfx2dBuffer` para primitivas 2D de jogo;
- `GfxUiBuffer` para primitivas de Shell UI;
- `ComposerBuffer` tambem cobre HUD do Game 2D no v1.
No fechamento do frame, o `RenderManager` transforma esses buffers na `RenderSubmission` valida para o `AppMode` atual.
Depois do fechamento, a submission e um snapshot fechado. Produtores logicos nao podem continuar mutando a submission fechada. Essa regra deve ser cravada agora porque e a base para VM e render pipeline rodarem em paralelo no futuro.
Politica de backlog: nao existe fila infinita. A regra canonica e `latest complete submission wins`.
## Opcoes
### Opcao A - Dois command sets tipados, uma implementacao Classic2D inicial
**Abordagem:**
- Criar `Game2DFramePacket` para scene/sprites/HUD.
- Criar `UiFramePacket` para Prometeu UI/system UI.
- Criar um envelope simples, por exemplo `RenderSubmission`, que identifica qual pacote esta sendo submetido.
- Fazer a implementacao Classic2D atual consumir os pacotes e continuar escrevendo no framebuffer RGBA8888.
- Implementar em fases: primeiro Game 2D, depois Prometeu UI, ou ambos com producers minimos se o plano ficar pequeno o suficiente.
**Pro:**
- preserva modelos logicos diferentes para jogo e UI;
- evita uma command list generica demais;
- prepara 3D ou outros pipelines sem forcar eles a parecerem com 2D;
- deixa claro que pipeline e backend sao coisas diferentes.
**Con:**
- cria mais tipos no inicio;
- precisa definir como o hardware recebe pacotes heterogeneos;
- se so Game 2D for migrado no primeiro plan, UI continua acoplada por um tempo.
**Maintainability:**
Alta. Esta opcao combina com a direcao de longo prazo sem exigir varios backends agora.
### Opcao B - Um `RenderCommand` universal para tudo
**Abordagem:**
- Criar uma display list unica com comandos como `Clear`, `DrawSprite`, `DrawText`, `FillRect`, `SceneCache`, `Window`, `Transition`.
- Fazer Game 2D e Prometeu UI emitirem a mesma lista.
**Pro:**
- facil de passar por uma unica fila;
- simples para logging/replay inicial;
- reduz o numero de tipos.
**Con:**
- tende a virar um denominador comum fraco;
- mistura semantica de jogo, UI de sistema e futuros pipelines;
- pode forcar o pipeline 3D futuro a um contrato 2D inadequado;
- aumenta chance de reabrir a arquitetura quando outro pipeline surgir.
**Maintainability:**
Media a baixa como arquitetura principal. Pode ser util internamente dentro de um pipeline, mas nao como contrato unico do runtime.
### Opcao C - Trait comum de pipeline, sem pacote explicito
**Abordagem:**
- Definir algo como `RenderPipeline` com metodo `render(&mut HardwareSurface)`.
- Game 2D e UI implementam a trait, mas continuam desenhando por callbacks imediatos.
**Pro:**
- pequena mudanca inicial;
- organiza nomes e ownership;
- reduz um pouco o acoplamento por modulo.
**Con:**
- nao cria pacote logico observavel;
- continua misturando producao e execucao;
- nao ajuda tanto render thread, replay, testes de comandos ou hardware alternativo.
**Maintainability:**
Insuficiente se virar a decisao final. Pode ser uma API auxiliar, mas nao substitui command packets.
### Opcao D - Definir todos os pipelines e backend abstraction agora
**Abordagem:**
- Criar pipeline registry, backend interfaces, CPU/GPU abstractions, surfaces, scheduling, command queues e talvez render thread.
**Pro:**
- parece completo no papel;
- antecipa muitos cenarios futuros.
**Con:**
- grande demais para o objetivo atual;
- alto risco de abstrair sem uso real;
- pode atrasar a estabilizacao do 2D atual;
- tende a introduzir nomes e contratos que depois precisarao ser quebrados.
**Maintainability:**
Baixa para o momento atual. Melhor deixar essa camada emergir depois de pelo menos dois pipelines reais funcionando.
## Sugestao / Recomendacao
A recomendacao atual e a Opcao A: definir dois pipelines logicos com command sets proprios e uma implementacao Classic2D inicial.
Modelo recomendado:
```text
Game2DPipeline
produces Game2DFramePacket
owns/uses FrameComposer concepts:
scene/cache/camera
sprites
game HUD
HUD/primitives
PrometeuUiPipeline
produces UiFramePacket
owns/uses system UI concepts:
home/hub
shell windows
panels/rects/text
focus/input visual state
RenderHardware / RenderSurface implementation
consumes typed submissions:
Game2D(Game2DFramePacket)
PrometeuUi(UiFramePacket)
future: Game3D(...), Custom2D(...), etc.
writes to current RGBA8888 framebuffer for now
RenderManager
owns current AppMode render routing
closes per-domain command buffers into a submission
manages mode transitions
keeps only the latest complete submission
owns publish/present policy through the render surface
```
O primeiro plano de execucao pode ser incremental:
1. Definir nomes e contratos dos pacotes sem criar backend registry.
2. Migrar o Game 2D path para `Game2DFramePacket -> Classic2D`.
3. Manter comportamento pixel-level identico.
4. Em seguida migrar Prometeu UI para `UiFramePacket -> Classic2D`.
5. So depois discutir outros backends, render thread ou 3D.
Se quisermos reduzir ainda mais risco, a decisao pode determinar que o primeiro PR implementa apenas o Game 2D packet, mas ja reserva a arquitetura e nomes para `UiFramePacket`. O importante e nao fingir que Prometeu UI e apenas "mais primitives gfx" dentro do pipeline de jogo.
## Riscos
- **Abstracao comum errada:** um command set universal pode parecer simples agora, mas virar bloqueio para UI e 3D depois.
- **Escopo grande demais:** migrar Game 2D, UI, firmware screens e host presentation em um unico PR aumenta risco de regressao visual.
- **Dual path permanente:** se `gfx_mut()` continuar como escape hatch normal, a fronteira de comandos nao vira contrato real.
- **Ownership/lifetime dos pacotes:** pacotes borrowed reduzem copia no v1; pacotes owned ajudam render thread futura. A decisao precisa declarar o alvo do primeiro passo.
- **HUD ambiguo:** HUD de jogo e UI do Prometeu precisam ficar separados por contrato, mesmo que ambos sejam 2D.
- **Present/publication:** `present()` precisa ser tratado como publicacao de superficie, nao como parte aleatoria de cada produtor logico.
- **Asset/cache leakage:** command packets devem referenciar recursos logicos/resolvidos sem copiar asset pipeline nem expor detalhes demais do `Gfx`.
- **Paralelismo futuro:** se o packet v1 depender de estado mutavel vivo da VM/hardware, uma render thread futura exigira nova quebra arquitetural. A submissao precisa parecer um snapshot fechado mesmo quando consumida sincronicamente no v1.
- **Transicoes entre modos:** se Shell e Game forem totalmente independentes sem uma coordenacao mestre, efeitos de transicao e manutencao de superficie ficam sem dono claro.
- **Backpressure:** render futuro nao deve acumular fila infinita. A politica aceita e manter somente a submissao completa mais recente.
- **Abstracao vs host:** `RenderManager` deve ser contrato runtime/abstracao de coordenacao. Host desktop/render surface deve ser implementacao local separada, sem vazar detalhes para os pipelines.
- **HUD ownership:** HUD nao deve ficar escondido dentro de `Gfx2D`. `Gfx2D` deve conter apenas primitivas. No v1, HUD pertence explicitamente ao `composer`.
## Arquivos / Areas Provavelmente Afetados em uma Plan Futura
Nucleo Game 2D:
- `crates/console/prometeu-hal/src/` para tipos compartilhados de packet/submission, se virarem contrato de hardware.
- `crates/console/prometeu-hal/src/lib.rs` para exportar novos modulos.
- `crates/console/prometeu-drivers/src/frame_composer.rs` para produzir `Game2DFramePacket`.
- `crates/console/prometeu-drivers/src/gfx.rs` para consumir `Game2DFramePacket` no Classic2D atual.
- `crates/console/prometeu-drivers/src/hardware.rs` para conectar pipeline/submission/implementacao.
- `crates/console/prometeu-hal/src/hardware_bridge.rs` para reduzir acesso direto a `GfxBridge` no fluxo de frame.
Nucleo Prometeu UI:
- `crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs`
- `crates/console/prometeu-system/src/services/windows/*`
- `crates/console/prometeu-firmware/src/firmware/firmware_step_splash_screen.rs`
- `crates/console/prometeu-firmware/src/firmware/firmware_step_crash_screen.rs`
- possivelmente um novo modulo de UI commands em `prometeu-hal` ou `prometeu-system`.
Possivel, mas deve ser minimizado no primeiro passo:
- `crates/console/prometeu-system/src/services/vm_runtime/dispatch.rs` se `gfx.*` primitives forem convertidas em command submission.
- `crates/console/prometeu-hal/src/gfx_bridge.rs` se a ponte antiga for estreitada.
- `crates/host/prometeu-host-desktop-winit/src/runner.rs` se o acesso ao framebuffer for encapsulado, mas host presentation idealmente fica igual no v1.
## Perguntas em Aberto
- [x] O primeiro plano deve implementar Game 2D e Prometeu UI juntos, ou apenas Game 2D com contrato preparado para UI?
- Resposta: faseado. O primeiro plano deve priorizar Game 2D, mas o contrato deve nomear Prometeu UI como pipeline separado desde o inicio.
- [x] O contrato comum deve ser um enum `RenderSubmission`, uma trait de submissao, ou uma fila tipada por pipeline?
- Resposta: `AppMode` e `RenderSubmission` devem andar juntos. `AppMode::Game` e `AppMode::Shell` podem ter cadencias e pipelines diferentes; alguns renderers pertencem a um modo e nao ao outro. O envelope inicial pode ser um `RenderSubmission` tipado por modo/pipeline, sem registry dinamico.
- [x] `Game2DFramePacket` deve incluir HUD de jogo no v1 ou apenas reservar o campo?
- Resposta: deve incluir HUD de jogo no v1. O pipeline 2D de jogos abrange scene, sprites e HUD.
- [x] `UiFramePacket` deve representar UI em comandos baixos (`rect`, `text`, `clip`) ou em comandos mais semanticos (`window`, `panel`, `button`)?
- Resposta: comandos de UI podem ser diferentes dos comandos Game 2D, mas widget/layout deve ficar fora da implementacao do host. No host, `UiFramePacket` pode ser reduzido a diretivas semelhantes ou equivalentes as primitivas finais de desenho.
- [x] Pacotes v1 devem ser borrowed, owned, ou hibridos?
- Resposta: hibridos. O modelo precisa suportar transicoes de modo, como clicar no jogo, sair da UI, abrir o jogo e alternar entre `Game` e `Shell`, sem exigir copia pesada de todo recurso.
- [x] Onde fica `present()` no novo modelo: no hardware/render surface, no consumidor Classic2D, ou temporariamente no firmware?
- Resposta: `present()` pertence a implementacao de hardware/render surface quando ela precisa publicar/trocar buffers. Ele faz sentido em um blit/double-buffer path como Game 2D, mas nao deve ser parte obrigatoria do path canonico de todos os pipelines.
- [x] O acesso publico a `gfx.*` deve virar submissao de comandos, ser mantido como debug overlay temporario, ou ser separado em outra discussao?
- Resposta: separar apenas as primitivas de desenho por contrato de pipeline. O que ja e `composer.*` deve continuar no dominio `composer`, porque scene, camera, sprites, HUD e orquestracao de frame sao um contrato mais alto nivel do Game 2D, nao primitivas `gfx2d`. `Gfx2D` deve conter somente primitivas. O atual `gfx.*` deve ser dividido em primitivas apropriadas para Game e Shell, por exemplo `gfx2d.*` para primitivas do Game 2D e `gfxui.*` para Prometeu UI/Shell. As stdlibs podem continuar expondo nomes de alto nivel como `gfx`, desde que apontem para syscalls diferentes por `AppMode`.
- [x] A agenda aberta `DSC-0011` de dirty regions deve depender desta fronteira antes de continuar?
- Resposta: sim. `DSC-0011` vem depois desta fronteira, para nao otimizar o acoplamento errado.
- [x] O v1 precisa implementar render thread?
- Resposta: nao. Mas o v1 deve preparar a fronteira para isso: `RenderSubmission` deve ser um snapshot fechado por `frame_id`/`AppMode`, sem exigir `&mut Hardware` ou framebuffer mutavel no produtor logico. O consumo pode continuar sincrono na primeira implementacao.
- [x] Quem e dono do frame builder?
- Resposta: cada dominio/pipeline deve ter seus proprios buffers de comando/estado. O fechamento do frame deve ser centralizado no host/render coordinator, que transforma esses buffers na `RenderSubmission` valida para o `AppMode` atual.
- [x] Qual e a ordem de composicao Game 2D?
- Resposta: scene e sprites intercalam por `layer` inteiro usando os 4 layers disponiveis do Game 2D, normalmente como `sprite -> scene -> sprite -> scene -> ...` conforme ordenacao por layer. Depois vem um layer de HUD sobre tudo e entao publish. Transicoes visuais devem pertencer ao `RenderManager`, nao ao Game 2D frame path.
- [x] Como lidar com transicoes Game/Shell?
- Resposta: a arquitetura precisa de um `RenderManager` acima dos pipelines. Ele gerencia troca de modo, manutencao da superficie, capacidades ativas e transicoes que nao pertencem integralmente nem ao Shell UI nem ao Game 2D.
- [x] Qual politica de backpressure para render paralelo futuro?
- Resposta: sem fila infinita. Manter somente a submissao completa mais recente.
- [x] Capabilities devem ser separadas?
- Resposta: sim. O contrato deve prever capabilities separadas para `composer`, `gfx2d` e `gfxui`, reforcando o isolamento por `AppMode`.
- [x] O host debug overlay deve continuar?
- Resposta: nao como parte desta arquitetura. O overlay atual pode ser removido por completo em uma execucao futura; se um console/debug UI voltar a ser necessario, deve ser rediscutido como produto proprio, nao como overlay host-owned herdado.
- [x] Fade deve permanecer em algum contrato?
- Resposta: nao. Fade deve sair completamente. Ele era uma ideia de hardware inicial e nao deve permanecer como campo de packet, syscall ou habito de implementacao. Transicoes visuais futuras pertencem ao `RenderManager`, nao a `composer`, `gfx2d` ou `gfxui`.
- [x] HUD deve ser dominio proprio ou parte do `composer`?
- Resposta: HUD fica dentro de `composer` no v1, por ser parte alta do frame Game 2D junto de scene, camera e sprites. `Gfx2D` continua limitado a primitivas.
## Criterio para Encerrar
Esta agenda pode virar decisao quando houver consenso sobre:
- quais pipelines existem no v1;
- qual command set pertence ao Game 2D;
- qual command set pertence a Prometeu UI;
- qual e a fronteira comum entre pipeline e implementacao de hardware/superficie;
- o que fica explicitamente fora de escopo;
- estrategia de migracao incremental sem mudanca visual;
- testes de equivalencia esperados.
## Discussion
Atualizacao de 2026-05-25:
O foco nao deve ser apenas "introduzir `RenderFramePacket` para Classic2D". O foco deve ser separar pipelines logicos por dominio. Game 2D e Prometeu UI podem ter modelos de comando distintos, mas ambos devem submeter comandos a uma implementacao de hardware/render surface.
A menor refatoracao segura provavelmente continua partindo do Game 2D, porque `FrameComposer` ja concentra scene/cache/sprites. Mas a decisao deve nomear Prometeu UI como pipeline separado desde agora, para evitar que a UI do sistema vire uma extensao acidental do pipeline de jogo.
Respostas fechadas em discussao:
- `AppMode::Game` e `AppMode::Shell` continuam sendo os modos canonicos atuais; nao ha rename para `System`.
- `RenderSubmission` deve ser coerente com `AppMode`, porque a atualizacao de frame e os renderers disponiveis podem diferir entre Game e Shell.
- `composer.*` deve continuar representando scene, camera, sprites, HUD e orquestracao de frame do Game 2D.
- `Gfx2D` deve representar apenas primitivas/drawing commands do pipeline Game 2D.
- `GfxUI` deve representar primitivas/drawing commands do pipeline Prometeu UI/Shell.
- As stdlibs PBS de Game e Shell podem usar nomes parecidos em alto nivel, inclusive `gfx`, mas devem mapear para dominios ABI diferentes conforme `AppMode`.
- O host/render implementation pode reduzir command sets diferentes a diretivas locais parecidas, mas widget/layout nao deve morar no host.
- `present()` e publicacao de superficie, nao parte obrigatoria de todo pipeline logico.
- `DSC-0011` deve aguardar esta fronteira.
- Render thread futura nao deve ser implementada agora, mas a fronteira deve nascer compativel com VM e renderer rodando em paralelo: submissions fechadas, leitura imutavel de recursos compartilhados e publicacao isolada na render surface.
- Buffers de comando/estado devem pertencer aos dominios/pipelines; o fechamento em `RenderSubmission` deve ser centralizado no `RenderManager`.
- `RenderManager` e abstracao/runtime contract; host/render-surface implementation e adaptador local separado.
- Game 2D intercalara scene e sprites por layer. HUD fica acima de tudo e pertence ao `composer` no v1, ainda com contrato minimo e evolutivo.
- Fade sai completamente. Transicoes de entrada/saida entre Shell e Game pertencem ao `RenderManager`.
- Backpressure futuro: latest complete submission wins.
- Capabilities devem ser separadas para `composer`, `gfx2d` e `gfxui`.
- O debug overlay host-owned atual nao deve ser preservado como requisito; pode ser removido quando a execucao chegar nessa area.
## Resolution
As perguntas principais de escopo foram respondidas. A agenda ainda precisa ser aceita explicitamente antes de virar decisao normativa.
## Next Step
Fechar as perguntas sobre escopo do v1. Se a direcao de dois pipelines logicos for aceita, transformar esta agenda em uma decisao normativa antes de qualquer plan ou alteracao de codigo.

View File

@ -0,0 +1,126 @@
---
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.

View File

@ -1,236 +0,0 @@
---
id: DEC-0030
ticket: render-frame-packet-boundary
title: Logical Render Pipeline Command Boundary
status: accepted
created: 2026-05-25
ref_agenda: AGD-0038
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
# Decision - Logical Render Pipeline Command Boundary
## Status
Accepted.
## Contexto
Prometeu ja concluiu a migracao do framebuffer/runtime para RGBA8888. O proximo passo do renderer nao e introduzir GPU, 3D, multiplos backends, troca dinamica complexa de pipeline ou render thread. O objetivo imediato e criar uma fronteira arquitetural limpa entre:
- producao logica de comandos de render;
- fechamento/publicacao de frame;
- coordenacao entre `AppMode::Game` e `AppMode::Shell`;
- rasterizacao/publicacao em uma superficie real.
O estado atual ainda acopla codigo logico ao `Gfx`/framebuffer. `FrameComposer` ja centraliza parte da orquestracao do frame de jogo, mas ainda desenha por `GfxBridge`. Prometeu Hub, shell/system UI, splash/crash e primitivas `gfx.*` tambem acessam desenho imperativo direto. Essa decisao normatiza a nova fronteira de comandos e o papel de cada dominio.
## Decisao
Prometeu MUST separar render pipeline logico de render implementation.
O runtime SHALL introduce a `RenderManager` as the runtime-level render coordination abstraction. `RenderManager` MUST NOT be a host-specific renderer and MUST NOT contain desktop presentation details. Host/render-surface implementations SHALL adapt `RenderManager` submissions to the local hardware or host surface.
The canonical render flow SHALL be:
```text
domain buffers during logical frame
-> RenderManager closes buffers
-> RenderSubmission snapshot
-> render surface / implementation consumes submission
-> RGBA8888 surface publication
```
The v1 logical render domains are:
- `composer.*` for Game 2D high-level frame composition: scene, camera, sprites, HUD, and Game 2D frame orchestration.
- `gfx2d.*` for Game 2D primitives only.
- `gfxui.*` for Shell UI primitives.
The v1 app modes are:
- `AppMode::Game`, which SHALL route to Game 2D submissions.
- `AppMode::Shell`, which SHALL route to Shell UI submissions.
No `AppMode::System` rename is accepted by this decision.
`RenderSubmission` MUST be coherent with `AppMode`. The initial submission model SHOULD be a typed envelope rather than a generic universal command list:
```text
RenderSubmission
- frame_id
- app_mode
- packet:
- Game2D(Game2DFramePacket)
- ShellUi(UiFramePacket)
```
The exact Rust layout may evolve during planning, but the contract MUST preserve typed per-pipeline command sets.
## Rationale
Typed command sets preserve domain meaning. Game 2D and Shell UI have different responsibilities and should not be forced into a weak universal command list. A future 3D or custom 2D pipeline should add its own packet rather than reinterpret a 2D/UI command model.
`RenderManager` is required because mode transitions do not belong completely to either Shell UI or Game 2D. Shell can render an exit transition before launching a game, but once Game mode owns the frame, Shell UI is no longer available to render the entry transition. Therefore transition coordination, surface maintenance, active render capabilities, and frame publication need a manager above individual pipelines.
Command buffers and submissions must be distinct. Domain buffers are mutable while the logical frame is being built. A `RenderSubmission` is a closed snapshot. This makes future VM/render parallelism possible without requiring a render thread in v1.
The current fade model is rejected. Fade was a hardware-direction idea that no longer belongs in the Prometeu render contract after RGBA8888. Fade MUST NOT remain as a packet field, syscall, or implementation habit. Future visual transitions belong to `RenderManager`.
## Invariantes / Contrato
### Pipeline and Implementation Boundary
- Pipelines MUST produce logical commands or state.
- Render implementations MUST consume submissions and write/publish to a surface.
- Pipeline code MUST NOT require direct mutable access to the framebuffer.
- Pipeline code MUST NOT depend on host-specific presentation details.
### RenderManager
- `RenderManager` MUST coordinate active `AppMode`, submission closure, mode transitions, capabilities, and publication policy.
- `RenderManager` MUST be a runtime abstraction, not a host renderer.
- Host/render-surface implementations MUST be separate adapters.
- `RenderManager` MUST own transition coordination between Game and Shell.
- `RenderManager` MUST own the policy for surface publication/present through the render surface.
### Command Buffers and Submissions
- Each render domain/pipeline MUST own its own mutable command/state buffer during a logical frame.
- `RenderManager` MUST close the active buffers into a `RenderSubmission` at frame boundary.
- Once closed, `RenderSubmission` MUST be treated as immutable by producers.
- `RenderSubmission` MUST include `frame_id` and `app_mode`.
- Future parallel render MUST be supported by the contract: consuming a submission MUST NOT require `&mut Hardware`, `&mut Gfx`, or live mutable VM state.
- Backpressure policy MUST be latest-complete-submission-wins. The system MUST NOT accumulate an unbounded frame queue.
### AppMode Routing
- `AppMode::Game` SHALL expose and route Game render domains.
- `AppMode::Shell` SHALL expose and route Shell UI render domains.
- Renderers/capabilities MAY differ by app mode.
- PBS stdlibs MAY expose similar high-level names, including `gfx`, but they MUST map to mode-appropriate ABI domains.
### ABI Domains
- `composer.*` MUST remain the high-level Game 2D composition domain.
- `composer.*` MUST own scene, camera, sprites, HUD, and Game 2D frame orchestration.
- `gfx2d.*` MUST contain Game 2D primitives only.
- `gfx2d.*` MUST NOT own scene, camera, sprites, HUD, or frame orchestration.
- `gfxui.*` MUST contain Shell UI primitives.
- `gfxui.*` MUST NOT contain widget/layout policy. Widget/layout belongs in Shell/UI code or stdlib, not the host implementation.
- Capabilities MUST be separated for `composer`, `gfx2d`, and `gfxui`.
### Game 2D Composition
- Game 2D scene and sprites MUST compose by integer `layer`.
- The v1 Game 2D model has four available layers.
- Scene and sprite composition MAY interleave by layer, normally as sprite/scene ordering across the four layers.
- HUD MUST render above scene/sprite composition.
- HUD belongs to `composer` in v1 and remains a minimal, evolvable contract.
- HUD in this decision means Game HUD only. Shell/system UI is always part of the Shell UI pipeline.
- Game 2D publish happens after scene/sprite/HUD composition.
### Shell UI Composition
- Shell UI MUST use its own `gfxui.*` primitive command set.
- Shell UI commands MAY reduce to the same local drawing directives as Game 2D primitives inside a host/render-surface implementation.
- That reduction MUST remain an implementation detail and MUST NOT collapse the ABI domains.
### Fade and Transitions
- Fade MUST be removed completely from canonical pipeline contracts.
- Fade MUST NOT appear as a `Game2DFramePacket`, `UiFramePacket`, `RenderSubmission`, `composer`, `gfx2d`, or `gfxui` field or syscall.
- Future visual transitions MUST be coordinated by `RenderManager`.
### Debug Overlay
- The current host-owned debug overlay is not part of the new render architecture.
- It MAY be removed during execution.
- A future console/debug UI MUST be discussed as a separate product/domain, not preserved as inherited host overlay behavior.
## Impactos
### Spec
Specs must define the logical render pipeline model, `RenderManager`, `RenderSubmission`, app-mode routing, domain buffers, and publication boundary in English.
The public syscall/ABI specs must replace the old primitive `gfx.*` surface with mode-appropriate `gfx2d.*` and `gfxui.*` primitive domains while preserving `composer.*` as the high-level Game 2D composition domain.
### Runtime
Runtime frame execution must stop treating `Gfx`/framebuffer as the direct target of logical render calls. It must accumulate domain state/commands and let `RenderManager` close submissions.
`VirtualMachineRuntime` and firmware flow must route frame closure through `RenderManager`.
### HAL
HAL must expose distinct contracts for:
- `composer`;
- `gfx2d`;
- `gfxui`;
- render submissions;
- render manager/surface boundary.
Capability flags must be split accordingly.
### Host
The desktop host remains a concrete render-surface implementation. It may continue publishing RGBA8888 to `pixels`, but that behavior must sit below the render-surface adapter, not inside logical pipeline APIs.
Host debug overlay may be removed.
### Firmware / Shell
Shell UI must migrate away from direct `gfx_mut()` drawing toward `gfxui.*`/Shell UI command buffers.
Game mode must migrate toward `composer.*` plus `gfx2d.*` buffers closed by `RenderManager`.
### PBS / Stdlibs
PBS Game and Shell stdlibs may present similar high-level APIs to authors. They must map to different ABI domains according to `AppMode`.
Game stdlib should map scene/camera/sprites/HUD to `composer.*` and primitive Game drawing to `gfx2d.*`.
Shell stdlib should map UI primitives to `gfxui.*`.
## Alternativas Descartadas
### Universal RenderCommand
A single `RenderCommand` display list for Game, Shell, and future pipelines is rejected. It collapses domain meaning and would likely force future 3D or custom pipelines into a 2D/UI-shaped contract.
### Trait-Only RenderPipeline
A trait that calls `render(&mut Surface)` without a closed packet is rejected as the primary boundary. It preserves immediate rendering and does not prepare VM/render parallelism.
### Backend Registry Now
A full backend registry, GPU/CPU backend abstraction, dynamic pipeline switching, render thread, or 3D implementation is out of scope. Those may come later after the command boundary is stable.
### Moving Composer into Gfx2D
Moving scene, camera, sprites, HUD, or frame orchestration into `gfx2d.*` is rejected. `gfx2d.*` is primitives only.
## Referencias
- Agenda: `AGD-0038`
- Related dependency: `DSC-0011` dirty regions must wait until this boundary exists.
- Related prior lessons: frame composition belongs above the render backend; public ABI must follow canonical service boundaries; RGBA8888 is the canonical framebuffer contract.
## Propagacao Necessaria
- Create a plan before any spec or code changes.
- Update canonical specs in English.
- Update HAL syscall registry, capability definitions, and resolver tests.
- Update PBS/stdlib syscall declarations for Game and Shell.
- Update runtime dispatch so render syscalls mutate domain buffers instead of drawing directly.
- Introduce `RenderManager`, domain buffers, `RenderSubmission`, and render-surface implementation boundary.
- Migrate Game 2D first, then Shell UI, unless the plan proves both can be safely migrated together.
- Remove fade surfaces from render contracts.
- Remove or explicitly retire the host debug overlay.
- Add packet/submission tests, app-mode capability tests, and pixel-equivalence tests for unchanged visible behavior.
## Revision Log
- 2026-05-25: Initial decision draft from `AGD-0038`.

View File

@ -1,67 +0,0 @@
---
id: PLN-0073
ticket: render-frame-packet-boundary
title: Render Contract Specs
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Publish canonical English spec language for the logical render pipeline boundary before code migration. The specs must make `RenderManager`, domain buffers, typed `RenderSubmission`, app-mode routing, render-surface publication, latest-complete-submission policy, and fade removal explicit.
## Target
Canonical specs describe the DEC-0030 render contract well enough that HAL, runtime, host, firmware, and PBS work can proceed without reopening architecture.
## Scope
- Define the logical flow from mutable domain buffers to closed `RenderSubmission` to render-surface publication.
- Define `RenderManager` as runtime coordination, not a host renderer.
- Define `RenderSubmission` with `frame_id`, `app_mode`, and typed Game 2D or Shell UI packet.
- Define `composer.*`, `gfx2d.*`, and `gfxui.*` as separate ABI domains.
- Specify latest-complete-submission-wins backpressure.
- Remove fade from canonical render contracts.
## Out of Scope
- Rust implementation.
- PBS stdlib implementation.
- Pixel output changes.
- Backend registry, GPU backend, render thread, 3D pipeline, or debug console design.
## Execution Sequence
1. Inventory existing render, graphics, framebuffer, composer, and syscall specs.
2. Replace old primitive `gfx.*` ABI language with the DEC-0030 domain split.
3. Add sections for `RenderManager`, domain buffers, submission closure, immutability, and publication.
4. Add app-mode and capability routing language for Game and Shell.
5. Remove normative fade fields, syscalls, and packet references from render specs.
6. Cross-link impacted specs for HAL, runtime, host, firmware, and PBS propagation.
## Acceptance Criteria
- Specs are in English and reference `DEC-0030`.
- No canonical render spec presents direct framebuffer mutation as the logical render API.
- No canonical render spec keeps fade as a packet field, syscall, or ABI habit.
- Specs distinguish logical pipeline production from render-surface implementation.
## Tests / Validation
- Run repository documentation/spec validation if available.
- Inspect `rg -n "fade|gfx\\.\\*|RenderSubmission|RenderManager|present\\(" specs discussion/workflow/decisions/DEC-0030-logical-render-pipeline-command-boundary.md`.
- Run `discussion validate` if discussion references change.
## Risks
- Existing specs may use `gfx.*` as both author API and ABI name; preserve author clarity while changing ABI names.
- Removing fade text can accidentally delete transition requirements; move those requirements to `RenderManager` language.
## Affected Artifacts
- `specs/`
- `discussion/workflow/decisions/DEC-0030-logical-render-pipeline-command-boundary.md`

View File

@ -1,66 +0,0 @@
---
id: PLN-0074
ticket: render-frame-packet-boundary
title: HAL Render Submission Types
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Introduce HAL-level submission types that encode the logical render boundary without binding consumers to live `Hardware`, `Gfx`, framebuffer, or VM state.
## Target
HAL exposes typed contracts for `RenderSubmission`, `Game2DFramePacket`, `ShellUiFramePacket`, frame ids, app-mode routing, and immutable closed submissions.
## Scope
- Add typed packet structures for Game 2D and Shell UI.
- Add a `RenderSubmission` envelope with `frame_id`, `app_mode`, and typed packet variant.
- Add or reuse a monotonic frame id type.
- Encode closed-submission immutability through ownership and API shape.
- Keep packet types free of host presentation details and live mutable runtime references.
## Out of Scope
- `RenderManager` behavior beyond type integration.
- Renderer/rasterizer implementation.
- Backend registry, render thread, GPU path, or 3D packet.
- PBS stdlib updates.
## Execution Sequence
1. Locate current HAL graphics, composer, capability, and app-mode modules.
2. Add packet and submission types where ABI-facing render data belongs.
3. Represent packets as typed variants: `Game2D(Game2DFramePacket)` and `ShellUi(ShellUiFramePacket)`.
4. Ensure constructors or closure APIs consume or clone domain buffer contents.
5. Add tests for app-mode and packet coherence.
6. Update internal references to use the new types without changing runtime rendering yet.
## Acceptance Criteria
- HAL contains the DEC-0030 submission and packet vocabulary.
- A closed `RenderSubmission` can be handed to a consumer without borrowing live runtime rendering state.
- Game and Shell packets are distinguishable at the type level.
- The HAL API does not include fade fields.
## Tests / Validation
- Run HAL crate unit tests.
- Add tests for packet construction, app-mode coherence, and immutable ownership semantics.
- Inspect `rg -n "fade|UiFramePacket|ShellUiFramePacket|Game2DFramePacket|RenderSubmission" crates specs`.
## Risks
- New types must live at the ABI/contract level, not inside a host renderer.
- Borrowing shortcuts could leak mutability and violate the future parallel render contract.
## Affected Artifacts
- HAL crate/module under `crates/`
- HAL tests

View File

@ -1,68 +0,0 @@
---
id: PLN-0075
ticket: render-frame-packet-boundary
title: RenderManager Core
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Add `RenderManager` as the runtime abstraction that closes domain buffers into typed submissions, tracks active app mode, owns transition coordination placeholders, and hands the latest complete submission to the render surface.
## Target
Runtime frame execution routes render closure through `RenderManager` without making it a host renderer or direct framebuffer writer.
## Scope
- Add the `RenderManager` module and public runtime API.
- Track active `AppMode` and frame id generation.
- Close the active domain buffer into a `RenderSubmission`.
- Enforce latest-complete-submission-wins rather than an unbounded queue.
- Add transition coordination placeholders owned by `RenderManager`.
- Provide a publication handoff to a render-surface abstraction.
## Out of Scope
- Full visual transition implementation.
- Host-specific presentation details.
- Domain buffer implementation beyond integration points.
- Render thread, GPU backend, or backend registry.
## Execution Sequence
1. Add a runtime render module that owns `RenderManager` and related state.
2. Wire `VirtualMachineRuntime` frame closure through `RenderManager`.
3. Add active app-mode routing and rejection for incoherent packet closure.
4. Add latest-complete-submission storage and replacement semantics.
5. Add a render-surface handoff interface without calling host-specific `present()` from producers.
6. Add explicit no-op transition hooks for future work.
## Acceptance Criteria
- Runtime has one frame closure authority for render submissions.
- Producers can mutate domain buffers before closure but cannot mutate the closed submission.
- `RenderManager` does not depend on desktop host types.
- Backpressure is latest-complete-submission-wins.
- App-mode routing is explicit and tested.
## Tests / Validation
- Add unit tests for frame id increment, latest submission replacement, app-mode routing, and no-op transition state.
- Add runtime integration coverage proving frame closure passes through `RenderManager`.
- Run affected crate tests.
## Risks
- Existing frame lifecycle code may assume immediate framebuffer mutation; isolate compatibility shims and remove them in later plans.
- Transition placeholders must not become hidden fade behavior.
## Affected Artifacts
- Runtime crate/modules under `crates/`
- Runtime tests

View File

@ -1,67 +0,0 @@
---
id: PLN-0076
ticket: render-frame-packet-boundary
title: Capabilities and ABI Domain Split
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Split render capabilities and syscall metadata so `composer`, `gfx2d`, and `gfxui` are independently addressable and enforceable according to `AppMode`.
## Target
Capability gates reject wrong-domain render syscalls, and metadata no longer treats the old primitive `gfx.*` ABI as the single render surface.
## Scope
- Add or rename capability flags for `COMPOSER`, `GFX2D`, and `GFXUI`.
- Update syscall metadata for all render-domain calls.
- Enforce domain availability by `AppMode::Game` and `AppMode::Shell`.
- Update resolver and capability tests.
- Preserve `composer.*` as Game 2D high-level composition.
## Out of Scope
- Implementing command buffers.
- PBS stdlib author-facing wrappers.
- Host rendering.
- Backward compatibility for the old primitive `gfx.*` ABI.
## Execution Sequence
1. Locate capability definitions, syscall registry metadata, and resolver tests.
2. Introduce separate render capability identifiers for `composer`, `gfx2d`, and `gfxui`.
3. Update metadata for existing composer calls and new or renamed primitive domains.
4. Add app-mode checks so Game exposes `composer` and `gfx2d`, while Shell exposes `gfxui`.
5. Replace stale tests that assume one graphics capability.
6. Run resolver and runtime syscall tests.
## Acceptance Criteria
- Wrong-domain calls fail before mutating render state.
- Game mode cannot use `gfxui.*`.
- Shell mode cannot use `composer.*` or `gfx2d.*`.
- Capability metadata uses the split domain names.
- No compatibility promise remains for the old primitive `gfx.*` ABI.
## Tests / Validation
- Add resolver tests for all three capabilities.
- Add app-mode enforcement tests for allowed and rejected calls.
- Run affected unit and integration tests.
## Risks
- A large rename can hide semantic mistakes; verify every syscall maps to the correct domain responsibility.
- Author-facing stdlib names may still use `gfx`; keep this plan focused on ABI metadata.
## Affected Artifacts
- Syscall metadata and resolver modules under `crates/`
- Capability tests

View File

@ -1,66 +0,0 @@
---
id: PLN-0077
ticket: render-frame-packet-boundary
title: Composer Buffer and Game2D Packet
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Convert current `composer.*` frame state into a mutable `ComposerBuffer` that closes into `Game2DFramePacket` at frame boundary.
## Target
Game 2D scene, camera, sprites, HUD, and frame orchestration are represented as domain-owned state until `RenderManager` closes them into a typed packet.
## Scope
- Add `ComposerBuffer`.
- Move scene, camera, sprite, HUD, layer ordering, and Game 2D orchestration state into the buffer.
- Close the buffer into `Game2DFramePacket`.
- Preserve the v1 four-layer scene/sprite composition model and HUD-above-game rule.
- Ensure fade is absent from Game 2D packet state.
## Out of Scope
- Primitive `gfx2d.*` command buffering.
- Shell UI packet work.
- Renderer consumption refactor beyond packet shape.
- New Game 2D features.
## Execution Sequence
1. Locate current `FrameComposer`, composer syscalls, scene/camera/sprite/HUD state, and tests.
2. Introduce `ComposerBuffer` and migrate composer mutation paths into it.
3. Implement closure into `Game2DFramePacket` with immutable packet contents.
4. Preserve integer layer ordering across the four Game 2D layers and HUD final composition.
5. Route frame closure through `RenderManager`.
6. Update tests that currently inspect `FrameComposer` internals.
## Acceptance Criteria
- `composer.*` owns Game 2D high-level frame state.
- Closed `Game2DFramePacket` contains all data required by the Game 2D renderer without live VM or `Gfx` access.
- Scene/sprite layer behavior and HUD ordering are preserved.
- Fade is not represented.
## Tests / Validation
- Add unit tests for buffer mutation and packet closure.
- Add composition ordering tests for scene, sprites, and HUD.
- Run current frame composer/render tests and compare existing expected output.
## Risks
- Existing composer code may mix orchestration with drawing; split state capture from rendering before deeper migration.
- Layer behavior is user-visible and must not drift.
## Affected Artifacts
- Composer/frame-composer modules under `crates/`
- Game 2D tests

View File

@ -1,64 +0,0 @@
---
id: PLN-0078
ticket: render-frame-packet-boundary
title: Classic2D Game Renderer Consumer
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Refactor the Classic2D renderer path so it consumes `Game2DFramePacket` instead of being called directly by `FrameComposer` or logical producers.
## Target
Classic2D remains the concrete Game 2D rasterizer, but its input is a closed packet and its visible pixel output remains equivalent.
## Scope
- Add a packet consumer entry point for the current Classic2D/Gfx renderer.
- Replace direct `FrameComposer` to `Gfx` calls with packet consumption.
- Keep RGBA8888 output unchanged for existing supported scenes.
- Keep renderer implementation below the logical pipeline boundary.
## Out of Scope
- Redesigning scene, sprite, HUD, or primitive semantics.
- Shell UI rendering.
- Backend registry or GPU work.
- Dirty region optimization.
## Execution Sequence
1. Locate the current Classic2D/Gfx rendering entry points and `FrameComposer` call graph.
2. Add a `Game2DFramePacket` consumer API.
3. Move rendering reads from live composer state to packet data.
4. Update frame execution to pass the closed packet into the renderer through the render surface path.
5. Preserve existing framebuffer dimensions, RGBA8888 format, and pixel behavior.
6. Remove direct producer-to-renderer calls that bypass the packet boundary.
## Acceptance Criteria
- Classic2D renders from `Game2DFramePacket`.
- Logical composer code no longer directly mutates `Gfx` during frame production.
- Existing Game 2D visual fixtures or tests remain pixel-equivalent except where explicitly updated for the new boundary.
## Tests / Validation
- Add packet-to-pixels tests for representative scene, sprite, primitive, and HUD cases.
- Run existing render tests.
- Add regression coverage comparing old expected pixel buffers where available.
## Risks
- Packet conversion can accidentally alter draw order; keep tests focused on ordering and final pixels.
- Some current code may depend on side effects from direct `Gfx` mutation; identify and replace those effects explicitly.
## Affected Artifacts
- Classic2D/Gfx renderer modules under `crates/`
- Render tests and fixtures

View File

@ -1,66 +0,0 @@
---
id: PLN-0079
ticket: render-frame-packet-boundary
title: Gfx2D Primitive Domain
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Replace primitive Game drawing paths with `gfx2d.*` command buffering while keeping `gfx2d` limited to primitives only.
## Target
Game primitive drawing becomes a Game-mode ABI domain that records commands into a buffer consumed as part of Game 2D rendering.
## Scope
- Add `gfx2d.*` syscall domain and command buffer.
- Migrate primitive Game drawing calls from old `gfx.*` behavior to `gfx2d.*`.
- Ensure `gfx2d` contains primitives only.
- Integrate `gfx2d` commands into the Game 2D packet/render path without taking ownership of scene, camera, sprites, HUD, or frame orchestration.
## Out of Scope
- Shell UI primitive domain.
- `composer.*` high-level Game state.
- Widget/layout policy.
- Compatibility with old primitive `gfx.*` ABI names.
## Execution Sequence
1. Inventory old primitive `gfx.*` syscalls and direct drawing callsites used by Game mode.
2. Define the `gfx2d.*` command enum and mutable command buffer.
3. Route Game primitive syscalls into the buffer.
4. Add app-mode and capability enforcement for Game-only access.
5. Integrate buffered primitives into Game 2D packet closure or renderer consumption at the DEC-0030-approved layer.
6. Remove or retire old primitive `gfx.*` ABI registrations.
## Acceptance Criteria
- Game primitive commands are buffered as `gfx2d.*`.
- `gfx2d` does not own scene, camera, sprites, HUD, or frame orchestration.
- Shell mode cannot call `gfx2d.*`.
- No logical primitive path directly mutates framebuffer during production.
## Tests / Validation
- Add syscall tests for each migrated primitive.
- Add command buffer ordering tests.
- Add capability rejection tests for Shell mode.
- Run pixel-equivalence tests for unchanged primitive output.
## Risks
- The old `gfx` naming may appear in PBS wrappers; distinguish author API names from ABI domain names.
- Primitive ordering relative to composer output must be explicit.
## Affected Artifacts
- Syscall registry and runtime render domain modules under `crates/`
- Game primitive tests

View File

@ -1,66 +0,0 @@
---
id: PLN-0080
ticket: render-frame-packet-boundary
title: Shell UI Packet and GfxUI Domain
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Add `gfxui.*` command buffering and `ShellUiFramePacket` for Shell UI primitives while keeping widget/layout policy outside the host/render-surface implementation.
## Target
Shell UI emits typed UI primitive commands into a Shell-only packet that render-surface implementations can consume without collapsing UI and Game ABI domains.
## Scope
- Define `gfxui.*` primitive commands.
- Add a mutable Shell UI command buffer.
- Close Shell UI commands into `ShellUiFramePacket`.
- Enforce Shell-only capability and app-mode access.
- Keep widget/layout policy in Shell/UI code or stdlib layers.
## Out of Scope
- Migrating all Shell callsites; that is handled by the Shell/Hub migration plan.
- Game primitive rendering.
- New widget framework.
- Host-owned debug overlay.
## Execution Sequence
1. Define the `gfxui.*` syscall and command vocabulary needed for current Shell UI primitives.
2. Add a Shell UI command buffer and closure into `ShellUiFramePacket`.
3. Add capability metadata and resolver enforcement for `GFXUI`.
4. Add a render-surface consumer path that can rasterize Shell UI commands as an implementation detail.
5. Keep layout/widget decisions above the packet boundary.
6. Add tests for closure, app-mode gating, and packet consumption.
## Acceptance Criteria
- Shell UI commands close into `ShellUiFramePacket`.
- `gfxui.*` does not contain widget/layout policy.
- Game mode cannot call `gfxui.*`.
- Host/render-surface code consumes commands without redefining Shell UI product policy.
## Tests / Validation
- Add Shell UI command buffer unit tests.
- Add app-mode rejection tests for Game mode.
- Add render-surface consumer tests for representative primitives.
## Risks
- It is easy to migrate widget policy into host rendering while adding UI primitives; keep commands low-level and policy-free.
- Shell UI and Game primitives may rasterize similarly, but ABI domains must remain distinct.
## Affected Artifacts
- Shell UI render domain modules under `crates/`
- Syscall metadata and tests

View File

@ -1,67 +0,0 @@
---
id: PLN-0081
ticket: render-frame-packet-boundary
title: Shell Hub Migration
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Move Prometeu Hub, shell windows, splash/crash UI, and Shell drawing away from `gfx_mut()` into `gfxui.*` and Shell UI command buffers.
## Target
Shell-facing UI no longer draws directly into `Gfx`; it produces Shell UI commands that close into `ShellUiFramePacket`.
## Scope
- Migrate Prometeu Hub UI drawing.
- Migrate shell windows and window-manager drawing.
- Migrate splash and crash UI drawing.
- Remove direct Shell access to `gfx_mut()` for render production.
- Preserve visible Shell output where behavior is not intentionally changed.
## Out of Scope
- Designing new Hub visuals.
- New widget/layout framework.
- Game mode migration.
- Debug console replacement.
## Execution Sequence
1. Inventory Shell, Hub, splash, crash, and firmware drawing callsites that use `gfx_mut()` or direct framebuffer access.
2. Replace direct primitive drawing with `gfxui.*` buffer writes.
3. Route Shell frame closure through `RenderManager`.
4. Ensure Shell UI packets are consumed by the render-surface implementation.
5. Remove now-unused direct Shell rendering hooks.
6. Update tests and snapshots for unchanged visible behavior.
## Acceptance Criteria
- Shell UI production no longer requires mutable `Gfx` access.
- Hub, shell windows, splash, and crash UI can render through `ShellUiFramePacket`.
- Shell mode does not use `composer.*` or `gfx2d.*`.
- Visible UI output is preserved except for approved fixture updates caused by boundary-only refactoring.
## Tests / Validation
- Add integration tests for Hub/shell frame production.
- Add tests for splash and crash UI packet output.
- Run host smoke tests where available.
- Use pixel or snapshot comparisons for representative Shell screens.
## Risks
- Firmware and Shell code may mix lifecycle side effects with drawing; split command production without changing lifecycle semantics.
- Some host tests may assume immediate framebuffer writes; update them to observe submissions or final pixels.
## Affected Artifacts
- Shell, Hub, firmware, and host integration modules under `crates/`
- Shell UI tests and fixtures

View File

@ -1,69 +0,0 @@
---
id: PLN-0082
ticket: render-frame-packet-boundary
title: Frame Publication and Present Boundary
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Move `present()` and surface publication behind the render-surface implementation boundary so runtime producers no longer publish buffers directly.
## Target
Only the render-surface implementation publishes RGBA8888 output; logical producers and `RenderManager` hand off closed submissions through a boundary.
## Scope
- Identify all direct `present()` and surface publication callsites.
- Define the render-surface adapter boundary.
- Route publication through the adapter from `RenderManager`.
- Keep desktop `pixels` publication as a host implementation detail.
- Preserve frame pacing and invalidation behavior.
## Out of Scope
- New backend registry.
- GPU implementation.
- Render thread.
- Dirty regions.
- Visual transition design.
## Execution Sequence
1. Inventory `present()`, framebuffer publication, and host invalidation callsites.
2. Add or refine a render-surface trait/adapter that consumes `RenderSubmission`.
3. Move host desktop publication below that adapter.
4. Update runtime frame loop to publish only through `RenderManager` and the adapter.
5. Remove direct publication from composer, `gfx2d`, `gfxui`, Shell, and Game producers.
6. Verify frame pacing and host invalidation still occur at the correct boundary.
## Acceptance Criteria
- Logical render domains cannot call `present()` or publish buffers directly.
- Host desktop publication remains functional below the render-surface boundary.
- `RenderManager` owns the policy for selecting the latest complete submission.
- Existing frame pacing behavior is not regressed.
## Tests / Validation
- Add unit tests for render-surface handoff.
- Add integration tests proving producers cannot publish directly.
- Run host desktop render/pacing tests.
- Inspect `rg -n "present\\(|publish|pixels" crates` for boundary violations.
## Risks
- Publication and invalidation may be coupled in current host code; preserve timing semantics while moving ownership.
- A too-broad adapter can become a backend registry prematurely.
## Affected Artifacts
- Runtime render manager modules
- Host desktop render surface modules
- Frame pacing tests

View File

@ -1,63 +0,0 @@
---
id: PLN-0083
ticket: render-frame-packet-boundary
title: Fade Removal
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Remove fade fields, syscalls, packet members, renderer state, tests, and docs from canonical render contracts.
## Target
Fade is no longer a render pipeline feature. Future visual transitions remain the responsibility of `RenderManager` and require separate product/domain work.
## Scope
- Remove fade from specs, HAL packet types, syscall metadata, runtime state, renderer state, tests, and PBS declarations.
- Remove or update tests that assert fade behavior.
- Add guard tests or searches that prevent reintroducing fade in canonical render contracts.
## Out of Scope
- Implementing replacement visual transitions.
- Replacing fade with another transition API.
- Preserving backward compatibility for fade syscalls.
## Execution Sequence
1. Inventory all fade references in specs, crates, tests, examples, and PBS stdlibs.
2. Delete fade fields and syscalls from canonical ABI and packet definitions.
3. Remove renderer/runtime fade state and behavior.
4. Update callers and tests to stop setting or expecting fade.
5. Add documentation language that transitions belong to `RenderManager`.
6. Run targeted and full affected tests.
## Acceptance Criteria
- No canonical packet, syscall, or render contract contains fade.
- Runtime and renderer code do not maintain fade state.
- Tests no longer depend on fade behavior.
- Specs clearly state future transitions are `RenderManager` responsibility.
## Tests / Validation
- Run `rg -n "\\bfade\\b|Fade" specs crates discussion/workflow` and verify remaining references are only historical decision/agenda text or explicit removal notes.
- Run affected unit and integration tests.
- Add regression tests preventing fade fields in current packet types.
## Risks
- Removing tests can reduce coverage; replace fade-specific assertions with packet/transition boundary assertions where appropriate.
- Historical discussion files may keep fade references; do not rewrite history unless the framework requires it.
## Affected Artifacts
- Specs
- HAL, runtime, renderer, syscall, PBS, and tests under `crates/`

View File

@ -1,65 +0,0 @@
---
id: PLN-0084
ticket: render-frame-packet-boundary
title: Host Debug Overlay Removal
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Remove the current host-owned debug overlay from the render path. Any future console/debug UI is a separate product/domain discussion.
## Target
Host rendering no longer injects an inherited debug overlay into Game or Shell output.
## Scope
- Identify host-owned debug overlay code and render hooks.
- Remove overlay rendering from the host render path.
- Remove overlay toggles/tests that assume inherited host drawing.
- Preserve non-render telemetry and logging where they are not tied to overlay drawing.
## Out of Scope
- Designing a replacement debug console.
- Moving debug UI into `gfxui.*`.
- Changing telemetry collection.
- Removing unrelated developer diagnostics.
## Execution Sequence
1. Inventory debug overlay rendering callsites and tests.
2. Remove host overlay injection from Game and Shell render paths.
3. Delete overlay-specific drawing state, toggles, and fixture expectations.
4. Keep telemetry/logging APIs that do not render overlay pixels.
5. Update docs/specs to state that future debug UI requires a separate decision.
6. Run host and render tests.
## Acceptance Criteria
- Host render path does not draw debug overlay pixels.
- Game and Shell output do not depend on host overlay state.
- Remaining diagnostics do not violate the render pipeline boundary.
- Docs do not preserve host overlay as an architectural requirement.
## Tests / Validation
- Run host render tests.
- Add or update tests proving overlay state does not affect final rendered output.
- Run `rg -n "debug overlay|overlay|DebugOverlay" crates specs discussion/workflow` and inspect remaining references.
## Risks
- Removing overlay code can accidentally remove useful telemetry; keep non-render diagnostics intact.
- The word overlay may also refer to Game HUD/primitives; distinguish those from host debug overlay.
## Affected Artifacts
- Host desktop modules under `crates/`
- Host/render tests

View File

@ -1,67 +0,0 @@
---
id: PLN-0085
ticket: render-frame-packet-boundary
title: PBS Stdlib and Syscall Declarations
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Update PBS Game and Shell stdlibs so high-level APIs map to app-mode-specific ABI domains: Game uses `composer.*` and `gfx2d.*`; Shell uses `gfxui.*`.
## Target
PBS authors can keep ergonomic high-level APIs where appropriate, but generated or declared syscalls use the correct DEC-0030 ABI domain for the active app mode.
## Scope
- Update Game stdlib declarations for `composer.*` and `gfx2d.*`.
- Update Shell stdlib declarations for `gfxui.*`.
- Remove old primitive `gfx.*` ABI declarations.
- Keep author-facing wrappers clear about mode-specific behavior.
- Update examples/tests that compile PBS code.
## Out of Scope
- Runtime implementation of the syscall handlers.
- New visual APIs beyond the domain split.
- Backward compatibility for old primitive `gfx.*` ABI declarations.
- Widget/layout redesign.
## Execution Sequence
1. Inventory PBS stdlib files and syscall declaration generation.
2. Map Game scene/camera/sprites/HUD APIs to `composer.*`.
3. Map Game primitive APIs to `gfx2d.*`.
4. Map Shell primitive APIs to `gfxui.*`.
5. Remove or fail old primitive `gfx.*` ABI declarations.
6. Update PBS compile tests, examples, and syscall metadata expectations.
## Acceptance Criteria
- PBS Game code emits only Game-allowed render ABI domains.
- PBS Shell code emits only Shell-allowed render ABI domains.
- Old primitive `gfx.*` ABI declarations are gone or explicitly rejected.
- Existing examples are updated to the new declarations.
## Tests / Validation
- Run PBS stdlib and compiler tests.
- Add Game and Shell declaration tests for emitted syscall names.
- Add negative tests for wrong-domain declarations.
## Risks
- User-facing API names may remain similar while ABI names change; tests must assert emitted syscall domains, not just wrapper names.
- Examples can mask stale declarations if they do not compile in both Game and Shell contexts.
## Affected Artifacts
- PBS stdlib files
- Syscall declaration generation
- PBS tests and examples

View File

@ -1,71 +0,0 @@
---
id: PLN-0086
ticket: render-frame-packet-boundary
title: End-to-End Render Boundary Validation
status: done
created: 2026-05-25
ref_decisions: [DEC-0030]
tags: [gfx, renderer, runtime, frame-composer, architecture, ui, pipeline]
---
## Briefing
Source decision: `DEC-0030`.
Add end-to-end validation proving the new boundary works across Game and Shell modes after the granular implementation plans land.
## Target
Integration coverage demonstrates typed submissions, app-mode capability gates, immutable closed submissions, render-surface publication, fade removal, and visible output preservation.
## Scope
- Add Game end-to-end tests from PBS/syscall production to `Game2DFramePacket` and final pixels.
- Add Shell end-to-end tests from UI production to `ShellUiFramePacket` and final pixels.
- Test wrong-domain capability failures.
- Test closed-submission immutability.
- Test latest-complete-submission-wins behavior.
- Test absence of fade and host debug overlay from canonical paths.
## Out of Scope
- Implementing missing production code.
- New rendering features.
- Performance tuning.
- Dirty regions.
## Execution Sequence
1. Define representative Game and Shell scenarios that cover composer, `gfx2d`, and `gfxui`.
2. Add integration harness access to inspect closed submissions where appropriate.
3. Assert Game produces `RenderSubmission::Game2D` and Shell produces `RenderSubmission::ShellUi`.
4. Assert wrong-domain syscalls fail before mutating buffers.
5. Assert closed submissions remain unchanged after producers continue mutating the next frame.
6. Assert final pixels match existing expected output for unchanged scenarios.
7. Add searches or tests proving fade and host debug overlay are absent from the active render path.
## Acceptance Criteria
- Game and Shell each have end-to-end typed submission tests.
- Capability gates reject `gfxui` in Game and `composer`/`gfx2d` in Shell.
- Closed submissions are immutable snapshots.
- Latest complete submission replaces older unpublished submissions without queue growth.
- Unchanged Game and Shell scenarios remain visually equivalent.
## Tests / Validation
- Run full affected Rust test suites.
- Run PBS compile/runtime integration tests.
- Run pixel-equivalence tests for representative Game and Shell output.
- Run `discussion validate` once execution discussion artifacts are updated.
## Risks
- End-to-end tests may become brittle if they assert host details; assert the logical contract and final pixels, not internal host implementation names.
- Pixel-equivalence fixtures must account for intentional removals such as host debug overlay.
## Affected Artifacts
- Integration tests under `crates/`
- PBS tests
- Render fixtures

View File

@ -31,6 +31,13 @@ MUST contain a frame id, the active app mode, and one typed packet:
immutable. The runtime backpressure policy is latest-complete-submission-wins;
it MUST NOT accumulate an unbounded frame queue.
The platform layer is the runtime-facing implementation boundary for render
handoff. Runtime and firmware code publish completed frames through typed
platform services such as `RenderSubmissionSink`, and they access Game 2D
composition through `Game2DFrameComposer`. They MUST NOT depend on a monolithic
hardware bridge, mutable `Hardware`, mutable `Gfx`, or live `FrameComposer`
reference as the render handoff contract.
The current 2D graphics model is based on:
- framebuffer
@ -66,6 +73,24 @@ Color values in the runtime, HAL, host-facing framebuffer, and GFX ABI are
logical RGBA8888 values. RGB565 is not a supported runtime framebuffer,
palette, host presentation, or compatibility contract.
### Published owned frame
The canonical worker-published frame value is `OwnedRgba8888Frame`.
`OwnedRgba8888Frame` contains:
- the logical `FrameId`;
- render ownership metadata;
- width and height in pixels;
- stride in pixels;
- an owned `Vec<u32>` containing packed RGBA8888 pixels in canonical RGBA
channel order.
The pixel vector is owned by the published frame. Consumers may copy it into a
host upload buffer, retain it as the latest published frame, or repeat it for a
host redraw. Consumers MUST NOT interpret the frame as a native texture,
swapchain image, window handle, or host GPU resource.
---
## 3. Double Buffering
@ -92,7 +117,167 @@ This guarantees:
---
## 4. PROMETEU Graphical Structure
## 4. Asynchronous Render Boundary
`DEC-0031` defines the asynchronous render boundary. The contract applies even
when the current implementation consumes submissions synchronously.
### 4.1 Complete packet contract
`RenderSubmissionPacket::Game2D` MUST be a complete frame description for the
Game 2D render consumer. The consumer MUST NOT bypass the packet by consulting
live VM state, live `FrameComposer` state, or mutable runtime state.
For Game 2D, the required composition order is:
```text
composer scene/layers/sprites/HUD
-> buffered gfx2d primitives
-> publication/present
```
`gfx2d.*` primitives are therefore a final Game 2D overlay. This is true for
both active-scene frames and no-scene frames.
### 4.2 Resource boundary
Submissions MUST remain small and owned. Heavy resident resources such as glyph
banks, scene banks, asset payloads, and viewport cache materializations MUST
NOT be copied into each submission.
Submissions SHALL carry stable resource IDs or handles. The render consumer may
resolve those IDs through read-only resource APIs. Resource installation,
resolver updates, viewport cache refreshes, and bank residency changes belong
to the logical/runtime side before handoff, or to an owning service. They MUST
NOT require the render consumer to hold mutable VM, `Hardware`, `Gfx`, or
`FrameComposer` references.
Read-only resource APIs expose compact lookup by ID. They do not expose mutable
bank state, `Arc` ownership as part of the contract, copies of whole banks, or
snapshot payloads. The implementation may choose its internal sharing mechanism,
but the render boundary contract is ID-based read-only access.
Local host implementations may keep a concrete hardware object internally as a
platform implementation detail. That object is not part of the runtime-facing
render boundary; the boundary is the typed platform service set.
The runtime does not guarantee visual integrity if a developer or framework
replaces resources behind an in-flight submission in a way that violates asset
discipline.
### 4.3 Handoff
The first asynchronous Game render model uses a single pending slot with
latest-wins semantics:
- publishing a new submission replaces the previous pending submission;
- replacement before consumption is counted as a render drop;
- the render consumer takes ownership of the pending submission;
- the VM/producer MUST NOT block on render consumption, raster completion, or
present completion.
The consumer status is telemetry. It is not a semantic ACK to the VM.
The real render worker consumes owned `RenderSubmission` values from this
single-slot handoff and publishes owned `OwnedRgba8888Frame` values. The
producer path MUST remain non-blocking with respect to worker rasterization and
host present. A slow worker can cause replacement/drop telemetry, but it MUST
NOT create an unbounded queue or stall VM logical frame production.
### 4.4 Frame pacing
Game logical frames are paced by the runtime frame scheduler, not by render
worker ACK. The intended cadence is one Game logical frame per frame tick and
at most one pending submission ahead of the consumer.
`FRAME_SYNC` remains the canonical end of a VM logical frame. Cycle/time budget
is evidence for certification, watchdog, diagnostics, and overrun reporting; it
MUST NOT be used as the normal mechanism for cutting a logical frame short.
If a Game logical frame overruns the display cadence, logical frames remain
sequential. The render consumer may repeat the last valid frame, and telemetry
records the overrun/repeat. The VM MUST NOT produce catch-up frames to skip
from frame `N` to frame `N+k`.
Repeating a frame means reusing the latest published `OwnedRgba8888Frame`
without recomposing, rerunning VM code, or consuming a new submission. Repeat
behavior is presentation cadence behavior, not guest-visible execution.
### 4.5 AppMode policy
Render execution policy is explicit by pipeline/AppMode:
- `AppMode::Game` is frame-paced and may use a render worker when the
host/runtime supports it, with local synchronous fallback.
- `AppMode::Shell` is lifecycle-driven and local/synchronous by default.
- Shell VM-backed apps follow Shell lifecycle; they do not declare an
independent frame-paced game workload under this contract.
- Splash, crash, and hub/system screens follow Shell/local policy unless a
later decision defines a more specific policy.
### 4.6 Ownership and epoch
Every submission that may cross an asynchronous boundary MUST carry render
ownership metadata: at minimum frame identity, app mode, app identity where
available, and render epoch/generation.
Foreground visual-owner transitions MUST advance the active render epoch or
equivalent generation through a central runtime render manager. The render
consumer MUST check ownership before present and MUST discard stale submissions
whose ownership no longer matches the active owner.
Transitions that invalidate stale render work include:
- Game to Shell/Hub;
- Shell/Hub to Game;
- crash screen takeover;
- splash or system screen takeover;
- cartridge or app swap, even if `AppMode` remains unchanged;
- shutdown/stop.
The same physical surface may be reused by multiple visual owners, but logical
ownership MUST remain explicit.
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
foreground/lifecycle discussion.
### 4.7 Render telemetry
Asynchronous render is best-effort observable, not a VM-visible handshake.
Render drops, stale epoch discards, repeated presents, render errors, and
present errors MUST be recorded for host diagnostics, debugging, profiling, and
certification evidence. VM program semantics MUST NOT depend on whether a
submission was consumed or presented.
Minimum render telemetry includes:
- produced submissions;
- replaced-before-consume submissions;
- consumed submissions;
- presented frames;
- repeated presents;
- render errors;
- present errors;
- stale epoch discards;
- shutdown discards;
- last produced, consumed, presented, dropped, and error frame IDs;
- active render epoch.
### 4.8 Shutdown and typed failures
Render worker shutdown is bounded and observable. A shutdown request MUST
wake a waiting worker, discard pending submissions that will not be consumed,
and either join within the configured timeout or report a typed shutdown
failure.
Worker backend failures, sink publication failures, stale ownership discards,
panic capture, and shutdown timeout are typed render worker outcomes. They are
recorded through telemetry and diagnostics; they do not become VM-visible ACKs.
---
## 5. PROMETEU Graphical Structure
The graphical world is composed of:
@ -101,7 +286,7 @@ The graphical world is composed of:
- **1 HUD Layer** (fixed, always on top)
- Sprites with priority between layers
### 4.1 Tile Banks
### 5.1 Tile Banks
- There are up to **16 banks**
- Each bank has a fixed tile size:
@ -115,7 +300,7 @@ The graphical world is composed of:
- serialized pixels are `4bpp` packed in payload order
- runtime memory may expand pixels to one `u8` palette index per pixel after decode
### 4.2 Layers
### 5.2 Layers
- There are:
- 4 Tile Layers
@ -129,7 +314,7 @@ The graphical world is composed of:
---
## 5. Internal Model of a Tile Layer
## 6. Internal Model of a Tile Layer
A Tile Layer **is not a bitmap of pixels**.
It is composed of:
@ -149,7 +334,7 @@ It is composed of:
---
## 6. Logical Tilemap
## 7. Logical Tilemap
The tilemap represents the world:
@ -165,7 +350,7 @@ The tilemap can be much larger than the screen.
---
## 7. Border Cache (Tile Cache)
## 8. Border Cache (Tile Cache)
The cache is a window of tiles around the camera.
@ -179,7 +364,7 @@ It stores tiles already resolved from the tilemap.
---
## 8. Cache Update
## 9. Cache Update
Every frame:
@ -201,7 +386,7 @@ Only **one row and/or column** is updated per frame.
---
## 9. Cache as Ring Buffer
## 10. Cache as Ring Buffer
The cache is circular:
@ -214,7 +399,7 @@ Access:
---
## 10. Canonical Game Projection
## 11. Canonical Game Projection
Game mode uses a typed Game 2D submission. `composer.*` owns high-level Game 2D
frame composition: scene binding, camera, sprites, HUD, and frame orchestration.
@ -243,7 +428,7 @@ uses `gfxui.*` and `ShellUiFramePacket`; it is never part of Game HUD or
---
## 11. Drawing Order and Priority
## 12. Drawing Order and Priority
- There is no Z-buffer
- There is no automatic sorting
@ -269,7 +454,7 @@ Normative boundary:
---
## 12. Transparency
## 13. Transparency
Transparency is represented by the alpha channel of the resolved RGBA8888
color.
@ -289,7 +474,7 @@ else:
---
## 13. Color Math (Discrete Blending)
## 14. Color Math (Discrete Blending)
Inspired by the SNES.
@ -313,7 +498,7 @@ Everything is:
---
## 14. Where Blend is Applied
## 15. Where Blend is Applied
- Blending occurs during drawing
- For canonical game composition, the result goes to the back buffer during composition
@ -323,7 +508,7 @@ Everything is:
---
## 15. What the GFX DOES NOT support
## 16. What the GFX DOES NOT support
By design:
@ -338,7 +523,7 @@ By design:
---
## 16. Performance Rule
## 17. Performance Rule
- Layers:
- only update the border when crossing a tile
@ -350,7 +535,7 @@ By design:
---
## 17. Transitions and Removed Fade Contract
## 18. Transitions and Removed Fade Contract
The previous special fade model is not part of the canonical render contract.
Current render packets, syscalls, and ABI domains MUST NOT expose scene fade,
@ -371,9 +556,9 @@ Rules:
---
## 18. Palette System
## 19. Palette System
### 18.1. Overview
### 19.1. Overview
PROMETEU uses **exclusively** palette-indexed graphics.
@ -382,7 +567,7 @@ Every graphical pixel is an **index** pointing to a real color in a palette.
---
### 18.2. Pixel Format
### 19.2. Pixel Format
Each pixel of a tile or sprite is:
@ -396,7 +581,7 @@ Fixed rule:
---
### 18.3. Palette Structure
### 19.3. Palette Structure
Each **Tile Bank** contains:
@ -413,7 +598,7 @@ Size:
---
### 18.4. Palette Association
### 19.4. Palette Association
#### Fundamental Rule
@ -425,7 +610,7 @@ There is no palette swap within the same tile or sprite.
---
### 18.5. Where the Palette is Defined
### 19.5. Where the Palette is Defined
#### Tilemap
@ -457,7 +642,7 @@ Runtime-facing validity rule for v1:
---
### 18.6. Color Resolution
### 19.6. Color Resolution
The pipeline works like this:
@ -482,7 +667,7 @@ else:
---
### 18.7. Organization of Tile Banks
### 19.7. Organization of Tile Banks
Tile Banks are "strong assets":
@ -507,7 +692,7 @@ Runtime-facing v1 baseline:
---
### 18.8. Metrics for Certification (CAP)
### 19.8. Metrics for Certification (CAP)
The system can measure:
@ -518,7 +703,7 @@ The system can measure:
---
## 19. Syscall Return and Fault Policy
## 20. Syscall Return and Fault Policy
Graphics-related public ABI in v1 is split between:
@ -538,7 +723,7 @@ Fault boundary:
- `status`: operational failure;
- `Panic`: internal runtime invariant break only.
### 19.1 Return-shape matrix in v1
### 20.1 Return-shape matrix in v1
| Syscall | Return | Policy basis |
| ----------------------- | ------------- | --------------------------------------------------- |
@ -555,7 +740,7 @@ Fault boundary:
| `composer.set_camera` | `void` | no real operational failure path in v1 |
| `composer.emit_sprite` | `status:int` | explicit orchestration-domain operational rejection |
### 19.1.a Primitive domain semantics
### 20.1.a Primitive domain semantics
The primitive domains have stable operational meaning:
@ -569,7 +754,7 @@ Callers MUST NOT rely on stable immediate writes to the working back buffer as
the public contract for primitive drawing. Primitive calls mutate domain command
buffers that close into the active typed submission.
### 19.1.b Scene dependency fatal boundary
### 20.1.b Scene dependency fatal boundary
`composer.bind_scene` remains a status-returning syscall in the public ABI, but scene glyph dependency absence is outside the accepted passive operational-error model.
@ -581,7 +766,7 @@ Rules:
- if scene composition later discovers that a layer dependency can no longer be resolved, the machine MUST fail fatally and emit a clear log;
- runtime MUST NOT continue canonical scene composition after such a dependency failure.
### 19.2 `composer.emit_sprite`
### 20.2 `composer.emit_sprite`
`composer.emit_sprite` returns `status:int`.

View File

@ -100,7 +100,34 @@ Important properties:
- no execution occurs outside the frame loop;
- frame structure remains observable for host tooling and host-owned certification.
## 7 Determinism and Best Practices
## 7 Render Worker Concurrency
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
submissions.
The render worker handoff uses single-slot latest-wins semantics:
- a producer publishes at most one pending owned `RenderSubmission`;
- a newer submission replaces an older unconsumed pending submission;
- replacement is counted as telemetry;
- the producer does not wait for worker rasterization or host present;
- the worker takes ownership of the submission it consumes.
The worker publishes `OwnedRgba8888Frame` values. Each published frame owns its
RGBA8888 pixel vector and carries frame and ownership metadata. Repeating a
frame reuses the latest published owned frame and does not execute guest code
or recompose the frame.
Render resources reachable from a submission are resolved through read-only
ID-based access. The worker MUST NOT hold mutable VM state, mutable `Hardware`,
mutable `Gfx`, or a live mutable `FrameComposer` as its cross-thread contract.
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
typed failure if the worker cannot join within the configured timeout.
## 8 Determinism and Best Practices
PROMETEU encourages:
@ -115,7 +142,7 @@ PROMETEU discourages:
- hidden timing channels;
- ambiguous out-of-band execution.
## 8 Relationship to Other Specs
## 9 Relationship to Other Specs
- [`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.

View File

@ -118,19 +118,32 @@ Hardware differences:
The graphics system:
- produces typed logical render submissions that render to RGBA8888 output
- may consume Game submissions through a render worker that publishes owned
`OwnedRgba8888Frame` values
- uses an indexed palette
- does not depend on a specific GPU
The platform layer:
- exposes typed service facades for render submission, render backend
execution, Game 2D composition, input, audio, assets, and telemetry
- consumes closed render submissions through a render-surface implementation
- transports published RGBA8888 output into a host presentation surface without
- transports published RGBA8888 output or worker-published
`OwnedRgba8888Frame` pixels into a host presentation surface without
injecting host-owned debug overlay pixels
- is the runtime-facing portability boundary; runtime and firmware code must not
depend on a monolithic hardware bridge or on a concrete hardware aggregate
The host presentation layer MUST treat RGBA8888 as the canonical logical
framebuffer format. RGB565 conversion is not part of the normal host
presentation contract.
Native upload and present belong to the host event loop. The render worker MUST
NOT own or require a native window, swapchain, `pixels` surface, SDL texture, or
host GPU texture as part of its contract. The worker output is an owned
RGBA8888 frame; the host decides how to upload that frame to the native
presentation API for the current platform.
Host presentation SHOULD be driven by published render submissions and explicit
host-owned invalidation, not by perpetual redraw polling.

View File

@ -50,6 +50,19 @@ Does not belong in `docs/runtime/specs/` as the primary canonical source:
Here, hardware is not a generic bucket for everything. In the current package, "hardware" means the machine's virtual peripherals: GFX, AUDIO, INPUT, TOUCH, and MEMCARD/save memory. VM, firmware, cartridge, and ABI sit in sibling categories rather than inside hardware.
The implementation boundary for host portability is the platform layer. Runtime,
firmware, and host integration code use typed platform services for rendering,
input, audio, assets, and telemetry. A concrete local hardware aggregate may
exist inside a host or test platform, but it is not the normative runtime-facing
contract.
The real render worker contract is documented across the GFX, events, and
portability chapters. `04-gfx-peripheral.md` defines the render submission and
`OwnedRgba8888Frame` publication contract, `09-events-and-concurrency.md`
defines latest-wins handoff and bounded shutdown behavior, and
`11-portability-and-cross-platform-execution.md` defines the split between
worker-owned RGBA8888 frame production and host-owned native upload/present.
## Document Functions
- `normative`: defines the technical contract, expected behavior, or implementation-facing surface.