diff --git a/Cargo.lock b/Cargo.lock index ada203b3..e3fbe577 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1475,6 +1475,8 @@ version = "0.1.0" dependencies = [ "anyhow", "prometeu-bytecode", + "prometeu-hal", + "serde_json", ] [[package]] diff --git a/crates/console/prometeu-drivers/src/asset.rs b/crates/console/prometeu-drivers/src/asset.rs index d6541337..99d33cf1 100644 --- a/crates/console/prometeu-drivers/src/asset.rs +++ b/crates/console/prometeu-drivers/src/asset.rs @@ -14,7 +14,7 @@ use prometeu_hal::glyph::Glyph; use prometeu_hal::glyph_bank::{GlyphBank, TileSize}; use prometeu_hal::sample::Sample; use prometeu_hal::scene_bank::SceneBank; -use prometeu_hal::scene_layer::{MotionFactor, SceneLayer}; +use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer}; use prometeu_hal::sound_bank::SoundBank; use prometeu_hal::tile::Tile; use prometeu_hal::tilemap::TileMap; @@ -748,7 +748,7 @@ impl AssetManager { active: false, glyph_bank_id: 0, tile_size: TileSize::Size8, - motion_factor: MotionFactor { x: 1.0, y: 1.0 }, + parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, tilemap: TileMap { width: 0, height: 0, tiles: Vec::new() }, }); let mut layers = layers; @@ -770,20 +770,20 @@ impl AssetManager { 32 => TileSize::Size32, other => return Err(format!("Invalid SCENE tile size: {}", other)), }; - let motion_factor_x = f32::from_le_bytes([ + let parallax_factor_x = f32::from_le_bytes([ buffer[offset + 4], buffer[offset + 5], buffer[offset + 6], buffer[offset + 7], ]); - let motion_factor_y = f32::from_le_bytes([ + let parallax_factor_y = f32::from_le_bytes([ buffer[offset + 8], buffer[offset + 9], buffer[offset + 10], buffer[offset + 11], ]); - if !motion_factor_x.is_finite() || !motion_factor_y.is_finite() { - return Err("Invalid SCENE motion_factor".to_string()); + if !parallax_factor_x.is_finite() || !parallax_factor_y.is_finite() { + return Err("Invalid SCENE parallax_factor".to_string()); } let width = u32::from_le_bytes([ @@ -847,7 +847,7 @@ impl AssetManager { active: (flags & 0b0000_0001) != 0, glyph_bank_id, tile_size, - motion_factor: MotionFactor { x: motion_factor_x, y: motion_factor_y }, + parallax_factor: ParallaxFactor { x: parallax_factor_x, y: parallax_factor_y }, tilemap: TileMap { width, height, tiles }, }; } @@ -1105,7 +1105,7 @@ mod tests { SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1, }; use prometeu_hal::glyph::Glyph; - use prometeu_hal::scene_layer::{MotionFactor, SceneLayer}; + use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer}; use prometeu_hal::tile::Tile; use prometeu_hal::tilemap::TileMap; @@ -1144,11 +1144,11 @@ mod tests { fn test_scene() -> SceneBank { let make_layer = - |glyph_bank_id: u8, motion_x: f32, motion_y: f32, tile_size: TileSize| SceneLayer { + |glyph_bank_id: u8, parallax_x: f32, parallax_y: f32, tile_size: TileSize| SceneLayer { active: glyph_bank_id != 3, glyph_bank_id, tile_size, - motion_factor: MotionFactor { x: motion_x, y: motion_y }, + parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y }, tilemap: TileMap { width: 2, height: 2, @@ -1227,8 +1227,8 @@ mod tests { data.push(layer.glyph_bank_id); data.push(layer.tile_size as u8); data.push(0); - data.extend_from_slice(&layer.motion_factor.x.to_le_bytes()); - data.extend_from_slice(&layer.motion_factor.y.to_le_bytes()); + data.extend_from_slice(&layer.parallax_factor.x.to_le_bytes()); + data.extend_from_slice(&layer.parallax_factor.y.to_le_bytes()); data.extend_from_slice(&(layer.tilemap.width as u32).to_le_bytes()); data.extend_from_slice(&(layer.tilemap.height as u32).to_le_bytes()); data.extend_from_slice(&(layer.tilemap.tiles.len() as u32).to_le_bytes()); @@ -1359,7 +1359,7 @@ mod tests { let decoded = AssetManager::decode_scene_bank_from_buffer(&entry, &data).expect("scene"); assert_eq!(decoded.layers[1].glyph_bank_id, 1); - assert_eq!(decoded.layers[1].motion_factor.x, 0.5); + assert_eq!(decoded.layers[1].parallax_factor.x, 0.5); assert_eq!(decoded.layers[2].tile_size, TileSize::Size32); assert_eq!(decoded.layers[0].tilemap.tiles[1].flip_x, true); assert_eq!(decoded.layers[2].tilemap.tiles[2].flip_y, true); diff --git a/crates/console/prometeu-drivers/src/frame_composer.rs b/crates/console/prometeu-drivers/src/frame_composer.rs new file mode 100644 index 00000000..02a2f471 --- /dev/null +++ b/crates/console/prometeu-drivers/src/frame_composer.rs @@ -0,0 +1,719 @@ +use crate::memory_banks::SceneBankPoolAccess; +use prometeu_hal::GfxBridge; +use prometeu_hal::glyph::Glyph; +use prometeu_hal::scene_bank::SceneBank; +use prometeu_hal::scene_viewport_cache::SceneViewportCache; +use prometeu_hal::scene_viewport_resolver::{CacheRefreshRequest, SceneViewportResolver}; +use prometeu_hal::sprite::Sprite; +use std::sync::Arc; + +const EMPTY_SPRITE: Sprite = Sprite { + glyph: Glyph { glyph_id: 0, palette_id: 0 }, + x: 0, + y: 0, + layer: 0, + bank_id: 0, + active: false, + flip_x: false, + flip_y: false, + priority: 0, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SceneStatus { + #[default] + Unbound, + Available { + scene_bank_id: usize, + }, +} + +#[derive(Clone, Debug)] +pub struct SpriteController { + sprites: [Sprite; 512], + sprite_count: usize, + frame_counter: u64, + dropped_sprites: usize, + layer_buckets: [Vec; 4], +} + +impl Default for SpriteController { + fn default() -> Self { + Self::new() + } +} + +impl SpriteController { + pub fn new() -> Self { + Self { + sprites: [EMPTY_SPRITE; 512], + sprite_count: 0, + frame_counter: 0, + dropped_sprites: 0, + layer_buckets: std::array::from_fn(|_| Vec::with_capacity(128)), + } + } + + pub fn sprites(&self) -> &[Sprite; 512] { + &self.sprites + } + + pub fn sprites_mut(&mut self) -> &mut [Sprite; 512] { + &mut self.sprites + } + + pub fn begin_frame(&mut self) { + self.frame_counter = self.frame_counter.wrapping_add(1); + self.sprite_count = 0; + self.dropped_sprites = 0; + for bucket in &mut self.layer_buckets { + bucket.clear(); + } + } + + pub fn emit_sprite(&mut self, mut sprite: Sprite) -> bool { + let Some(bucket) = self.layer_buckets.get_mut(sprite.layer as usize) else { + self.dropped_sprites += 1; + return false; + }; + if self.sprite_count >= self.sprites.len() { + self.dropped_sprites += 1; + return false; + } + + sprite.active = true; + let index = self.sprite_count; + self.sprites[index] = sprite; + self.sprite_count += 1; + bucket.push(index); + true + } + + pub fn sprite_count(&self) -> usize { + self.sprite_count + } + + pub fn frame_counter(&self) -> u64 { + self.frame_counter + } + + pub fn dropped_sprites(&self) -> usize { + self.dropped_sprites + } + + pub fn ordered_sprites(&self) -> Vec { + let mut ordered = Vec::with_capacity(self.sprite_count); + for bucket in &self.layer_buckets { + let mut indices = bucket.clone(); + indices.sort_by_key(|&index| self.sprites[index].priority); + for index in indices { + ordered.push(self.sprites[index]); + } + } + ordered + } +} + +pub struct FrameComposer { + scene_bank_pool: Arc, + viewport_width_px: usize, + viewport_height_px: usize, + active_scene_id: Option, + active_scene: Option>, + scene_status: SceneStatus, + camera_x_px: i32, + camera_y_px: i32, + cache: Option, + resolver: Option, + sprite_controller: SpriteController, +} + +impl FrameComposer { + pub fn new( + viewport_width_px: usize, + viewport_height_px: usize, + scene_bank_pool: Arc, + ) -> Self { + Self { + scene_bank_pool, + viewport_width_px, + viewport_height_px, + active_scene_id: None, + active_scene: None, + scene_status: SceneStatus::Unbound, + camera_x_px: 0, + camera_y_px: 0, + cache: None, + resolver: None, + sprite_controller: SpriteController::new(), + } + } + + pub fn viewport_size(&self) -> (usize, usize) { + (self.viewport_width_px, self.viewport_height_px) + } + + pub fn scene_bank_pool(&self) -> &Arc { + &self.scene_bank_pool + } + + pub fn scene_bank_slot(&self, slot: usize) -> Option> { + self.scene_bank_pool.scene_bank_slot(slot) + } + + pub fn scene_bank_slot_count(&self) -> usize { + self.scene_bank_pool.scene_bank_slot_count() + } + + pub fn active_scene_id(&self) -> Option { + self.active_scene_id + } + + pub fn active_scene(&self) -> Option<&Arc> { + self.active_scene.as_ref() + } + + pub fn scene_status(&self) -> SceneStatus { + self.scene_status + } + + pub fn camera(&self) -> (i32, i32) { + (self.camera_x_px, self.camera_y_px) + } + + pub fn bind_scene(&mut self, scene_bank_id: usize) -> bool { + let Some(scene) = self.scene_bank_pool.scene_bank_slot(scene_bank_id) else { + self.unbind_scene(); + return false; + }; + + let (cache, resolver) = + Self::build_scene_runtime(self.viewport_width_px, self.viewport_height_px, &scene); + self.active_scene_id = Some(scene_bank_id); + self.active_scene = Some(scene); + self.scene_status = SceneStatus::Available { scene_bank_id }; + self.cache = Some(cache); + self.resolver = Some(resolver); + true + } + + pub fn unbind_scene(&mut self) { + self.active_scene_id = None; + self.active_scene = None; + self.scene_status = SceneStatus::Unbound; + self.cache = None; + self.resolver = None; + } + + pub fn set_camera(&mut self, x: i32, y: i32) { + self.camera_x_px = x; + self.camera_y_px = y; + } + + pub fn cache(&self) -> Option<&SceneViewportCache> { + self.cache.as_ref() + } + + pub fn resolver(&self) -> Option<&SceneViewportResolver> { + self.resolver.as_ref() + } + + pub fn sprite_controller(&self) -> &SpriteController { + &self.sprite_controller + } + + pub fn sprite_controller_mut(&mut self) -> &mut SpriteController { + &mut self.sprite_controller + } + + pub fn begin_frame(&mut self) { + self.sprite_controller.begin_frame(); + } + + pub fn emit_sprite(&mut self, sprite: Sprite) -> bool { + self.sprite_controller.emit_sprite(sprite) + } + + pub fn ordered_sprites(&self) -> Vec { + self.sprite_controller.ordered_sprites() + } + + pub fn render_frame(&mut self, gfx: &mut dyn GfxBridge) { + let ordered_sprites = self.ordered_sprites(); + gfx.load_frame_sprites(&ordered_sprites); + + if let (Some(scene), Some(cache), Some(resolver)) = + (self.active_scene.as_deref(), self.cache.as_mut(), self.resolver.as_mut()) + { + let update = resolver.update(scene, self.camera_x_px, self.camera_y_px); + Self::apply_refresh_requests(cache, scene, &update.refresh_requests); + // `FrameComposer` owns only canonical game-frame composition. + // Deferred `gfx.*` primitives are drained later by a separate + // overlay/debug stage outside this service boundary. + gfx.render_scene_from_cache(cache, &update); + return; + } + + // No-scene frames still stop at canonical game composition. Final + // overlay/debug work remains outside `FrameComposer`. + gfx.render_no_scene_frame(); + } + + fn build_scene_runtime( + viewport_width_px: usize, + viewport_height_px: usize, + scene: &SceneBank, + ) -> (SceneViewportCache, SceneViewportResolver) { + let min_tile_px = + scene.layers.iter().map(|layer| layer.tile_size as usize).min().unwrap_or(8); + let cache_width_tiles = viewport_width_px.div_ceil(min_tile_px) + 5; + let cache_height_tiles = viewport_height_px.div_ceil(min_tile_px) + 4; + let hysteresis_safe_px = min_tile_px.saturating_sub(4) as i32; + let hysteresis_trigger_px = (min_tile_px + 4) as i32; + + ( + SceneViewportCache::new(scene, cache_width_tiles, cache_height_tiles), + SceneViewportResolver::new( + viewport_width_px as i32, + viewport_height_px as i32, + cache_width_tiles, + cache_height_tiles, + hysteresis_safe_px, + hysteresis_trigger_px, + ), + ) + } + + fn apply_refresh_requests( + cache: &mut SceneViewportCache, + scene: &SceneBank, + refresh_requests: &[CacheRefreshRequest], + ) { + for request in refresh_requests { + match *request { + CacheRefreshRequest::InvalidateLayer { layer_index } => { + cache.layers[layer_index].invalidate_all(); + } + CacheRefreshRequest::RefreshLine { layer_index, cache_y } => { + cache.refresh_layer_line(scene, layer_index, cache_y); + } + CacheRefreshRequest::RefreshColumn { layer_index, cache_x } => { + cache.refresh_layer_column(scene, layer_index, cache_x); + } + CacheRefreshRequest::RefreshRegion { layer_index, region } => { + cache.refresh_layer_region(scene, layer_index, region); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gfx::Gfx; + use crate::memory_banks::{ + GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller, + }; + use prometeu_hal::color::Color; + use prometeu_hal::glyph_bank::{GlyphBank, TileSize}; + use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer}; + use prometeu_hal::tile::Tile; + use prometeu_hal::tilemap::TileMap; + + fn make_scene() -> SceneBank { + make_scene_with_palette(1, 1, TileSize::Size8) + } + + fn make_scene_with_palette( + glyph_bank_id: u8, + palette_id: u8, + tile_size: TileSize, + ) -> SceneBank { + let layer = SceneLayer { + active: true, + glyph_bank_id, + tile_size, + parallax_factor: ParallaxFactor { x: 1.0, y: 0.5 }, + tilemap: TileMap { + width: 2, + height: 2, + tiles: vec![ + Tile { + active: true, + glyph: Glyph { glyph_id: 0, palette_id }, + flip_x: false, + flip_y: false, + }; + 4 + ], + }, + }; + + SceneBank { layers: std::array::from_fn(|_| layer.clone()) } + } + + fn make_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank { + let size = tile_size as usize; + let mut bank = GlyphBank::new(tile_size, size, size); + bank.palettes[palette_id as usize][1] = color; + for pixel in &mut bank.pixel_indices { + *pixel = 1; + } + bank + } + + #[test] + fn frame_composer_starts_unbound_with_empty_owned_state() { + let frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); + + assert_eq!(frame_composer.viewport_size(), (320, 180)); + assert_eq!(frame_composer.active_scene_id(), None); + assert!(frame_composer.active_scene().is_none()); + assert_eq!(frame_composer.scene_status(), SceneStatus::Unbound); + assert_eq!(frame_composer.camera(), (0, 0)); + assert!(frame_composer.cache().is_none()); + assert!(frame_composer.resolver().is_none()); + assert_eq!(frame_composer.sprite_controller().sprites().len(), 512); + assert_eq!(frame_composer.sprite_controller().sprite_count(), 0); + assert_eq!(frame_composer.sprite_controller().dropped_sprites(), 0); + } + + #[test] + fn frame_composer_exposes_shared_scene_bank_access() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_scene_bank(3, Arc::new(make_scene())); + + let frame_composer = FrameComposer::new(320, 180, banks); + let scene = + frame_composer.scene_bank_slot(3).expect("scene bank slot 3 should be resident"); + + assert_eq!(frame_composer.scene_bank_slot_count(), 16); + assert_eq!(scene.layers[0].tile_size, TileSize::Size8); + assert_eq!(scene.layers[0].parallax_factor.y, 0.5); + } + + #[test] + fn bind_scene_stores_scene_identity_and_shared_reference() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_scene_bank(3, Arc::new(make_scene())); + + let expected_scene = + banks.scene_bank_slot(3).expect("scene bank slot 3 should be resident"); + let mut frame_composer = FrameComposer::new(320, 180, banks); + + assert!(frame_composer.bind_scene(3)); + + assert_eq!(frame_composer.active_scene_id(), Some(3)); + assert!(Arc::ptr_eq( + frame_composer.active_scene().expect("active scene should exist"), + &expected_scene, + )); + assert_eq!(frame_composer.scene_status(), SceneStatus::Available { scene_bank_id: 3 }); + assert!(frame_composer.cache().is_some()); + assert!(frame_composer.resolver().is_some()); + } + + #[test] + fn unbind_scene_clears_scene_and_cache_state() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_scene_bank(1, Arc::new(make_scene())); + + let mut frame_composer = FrameComposer::new(320, 180, banks); + assert!(frame_composer.bind_scene(1)); + + frame_composer.unbind_scene(); + + assert_eq!(frame_composer.active_scene_id(), None); + assert!(frame_composer.active_scene().is_none()); + assert_eq!(frame_composer.scene_status(), SceneStatus::Unbound); + assert!(frame_composer.cache().is_none()); + assert!(frame_composer.resolver().is_none()); + } + + #[test] + fn set_camera_stores_top_left_pixel_coordinates() { + let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); + + frame_composer.set_camera(-12, 48); + + assert_eq!(frame_composer.camera(), (-12, 48)); + } + + #[test] + fn bind_scene_derives_cache_and_resolver_from_eight_pixel_layers() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_scene_bank(0, Arc::new(make_scene())); + + let mut frame_composer = FrameComposer::new(320, 180, banks); + assert!(frame_composer.bind_scene(0)); + + let cache = frame_composer.cache().expect("cache should exist for bound scene"); + assert_eq!((cache.width(), cache.height()), (45, 27)); + } + + #[test] + fn missing_scene_binding_falls_back_to_no_scene_state() { + let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); + + assert!(!frame_composer.bind_scene(7)); + + assert_eq!(frame_composer.scene_status(), SceneStatus::Unbound); + assert!(frame_composer.cache().is_none()); + assert!(frame_composer.resolver().is_none()); + } + + #[test] + fn sprite_controller_begin_frame_resets_sprite_count_and_buckets() { + let mut controller = SpriteController::new(); + let emitted = controller.emit_sprite(Sprite { + glyph: Glyph { glyph_id: 1, palette_id: 2 }, + x: 4, + y: 5, + layer: 2, + bank_id: 3, + active: false, + flip_x: false, + flip_y: false, + priority: 1, + }); + assert!(emitted); + + controller.begin_frame(); + + assert_eq!(controller.frame_counter(), 1); + assert_eq!(controller.sprite_count(), 0); + assert_eq!(controller.dropped_sprites(), 0); + assert!(controller.ordered_sprites().is_empty()); + } + + #[test] + fn sprite_controller_orders_by_layer_then_priority_then_fifo() { + let mut controller = SpriteController::new(); + controller.begin_frame(); + + assert!(controller.emit_sprite(Sprite { + glyph: Glyph { glyph_id: 10, palette_id: 0 }, + x: 0, + y: 0, + layer: 1, + bank_id: 0, + active: false, + flip_x: false, + flip_y: false, + priority: 2, + })); + assert!(controller.emit_sprite(Sprite { + glyph: Glyph { glyph_id: 11, palette_id: 0 }, + x: 0, + y: 0, + layer: 0, + bank_id: 0, + active: false, + flip_x: false, + flip_y: false, + priority: 3, + })); + assert!(controller.emit_sprite(Sprite { + glyph: Glyph { glyph_id: 12, palette_id: 0 }, + x: 0, + y: 0, + layer: 1, + bank_id: 0, + active: false, + flip_x: false, + flip_y: false, + priority: 1, + })); + assert!(controller.emit_sprite(Sprite { + glyph: Glyph { glyph_id: 13, palette_id: 0 }, + x: 0, + y: 0, + layer: 1, + bank_id: 0, + active: false, + flip_x: false, + flip_y: false, + priority: 2, + })); + + let ordered = controller.ordered_sprites(); + let ordered_ids: Vec = ordered.iter().map(|sprite| sprite.glyph.glyph_id).collect(); + + assert_eq!(ordered_ids, vec![11, 12, 10, 13]); + assert!(ordered.iter().all(|sprite| sprite.active)); + } + + #[test] + fn sprite_controller_drops_overflow_without_panicking() { + let mut controller = SpriteController::new(); + controller.begin_frame(); + + for glyph_id in 0..512 { + assert!(controller.emit_sprite(Sprite { + glyph: Glyph { glyph_id, palette_id: 0 }, + x: 0, + y: 0, + layer: 0, + bank_id: 0, + active: false, + flip_x: false, + flip_y: false, + priority: 0, + })); + } + + let overflowed = controller.emit_sprite(Sprite { + glyph: Glyph { glyph_id: 999, palette_id: 0 }, + x: 0, + y: 0, + layer: 0, + bank_id: 0, + active: false, + flip_x: false, + flip_y: false, + priority: 0, + }); + + assert!(!overflowed); + assert_eq!(controller.sprite_count(), 512); + assert_eq!(controller.dropped_sprites(), 1); + } + + #[test] + fn frame_composer_emits_ordered_sprites_for_rendering() { + let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); + frame_composer.begin_frame(); + + assert!(frame_composer.emit_sprite(Sprite { + glyph: Glyph { glyph_id: 21, palette_id: 0 }, + x: 0, + y: 0, + layer: 2, + bank_id: 1, + active: false, + flip_x: false, + flip_y: false, + priority: 1, + })); + assert!(frame_composer.emit_sprite(Sprite { + glyph: Glyph { glyph_id: 20, palette_id: 0 }, + x: 0, + y: 0, + layer: 1, + bank_id: 1, + active: false, + flip_x: false, + flip_y: false, + priority: 0, + })); + + let ordered = frame_composer.ordered_sprites(); + + assert_eq!(ordered.len(), 2); + assert_eq!(ordered[0].glyph.glyph_id, 20); + assert_eq!(ordered[1].glyph.glyph_id, 21); + } + + #[test] + fn render_frame_without_scene_uses_sprite_only_path() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_glyph_bank(1, Arc::new(make_glyph_bank(TileSize::Size8, 3, Color::WHITE))); + + let mut frame_composer = + FrameComposer::new(16, 16, Arc::clone(&banks) as Arc); + frame_composer.begin_frame(); + assert!(frame_composer.emit_sprite(Sprite { + glyph: Glyph { glyph_id: 0, palette_id: 3 }, + x: 0, + y: 0, + layer: 0, + bank_id: 1, + active: false, + flip_x: false, + flip_y: false, + priority: 0, + })); + + let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc); + gfx.scene_fade_level = 31; + gfx.hud_fade_level = 31; + + frame_composer.render_frame(&mut gfx); + gfx.present(); + + assert_eq!(gfx.front_buffer()[0], Color::WHITE.raw()); + } + + #[test] + fn render_frame_with_scene_applies_refreshes_before_composition() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_glyph_bank(0, Arc::new(make_glyph_bank(TileSize::Size8, 2, Color::BLUE))); + banks.install_scene_bank(0, Arc::new(make_scene_with_palette(0, 2, TileSize::Size8))); + + let mut frame_composer = + FrameComposer::new(16, 16, Arc::clone(&banks) as Arc); + assert!(frame_composer.bind_scene(0)); + + let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc); + gfx.scene_fade_level = 31; + gfx.hud_fade_level = 31; + + frame_composer.render_frame(&mut gfx); + gfx.present(); + + assert!( + frame_composer + .cache() + .expect("cache should exist") + .layers + .iter() + .all(|layer| layer.valid) + ); + assert_eq!(gfx.front_buffer()[0], Color::BLUE.raw()); + } + + #[test] + fn render_frame_survives_scene_transition_through_unbind_and_rebind() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_glyph_bank(0, Arc::new(make_glyph_bank(TileSize::Size8, 1, Color::RED))); + banks.install_glyph_bank(1, Arc::new(make_glyph_bank(TileSize::Size8, 2, Color::BLUE))); + banks.install_glyph_bank(2, Arc::new(make_glyph_bank(TileSize::Size8, 3, Color::WHITE))); + banks.install_scene_bank(0, Arc::new(make_scene_with_palette(0, 1, TileSize::Size8))); + banks.install_scene_bank(1, Arc::new(make_scene_with_palette(1, 2, TileSize::Size8))); + + let mut frame_composer = + FrameComposer::new(16, 16, Arc::clone(&banks) as Arc); + let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc); + gfx.scene_fade_level = 31; + gfx.hud_fade_level = 31; + + assert!(frame_composer.bind_scene(0)); + frame_composer.render_frame(&mut gfx); + gfx.present(); + assert_eq!(gfx.front_buffer()[0], Color::RED.raw()); + + frame_composer.unbind_scene(); + frame_composer.begin_frame(); + assert!(frame_composer.emit_sprite(Sprite { + glyph: Glyph { glyph_id: 0, palette_id: 3 }, + x: 0, + y: 0, + layer: 0, + bank_id: 2, + active: false, + flip_x: false, + flip_y: false, + priority: 0, + })); + frame_composer.render_frame(&mut gfx); + gfx.present(); + assert_eq!(gfx.front_buffer()[0], Color::WHITE.raw()); + + frame_composer.begin_frame(); + assert!(frame_composer.bind_scene(1)); + frame_composer.render_frame(&mut gfx); + gfx.present(); + assert_eq!(gfx.front_buffer()[0], Color::BLUE.raw()); + } +} diff --git a/crates/console/prometeu-drivers/src/gfx.rs b/crates/console/prometeu-drivers/src/gfx.rs index 26d09ebf..a39e0658 100644 --- a/crates/console/prometeu-drivers/src/gfx.rs +++ b/crates/console/prometeu-drivers/src/gfx.rs @@ -1,3 +1,4 @@ +use crate::gfx_overlay::{DeferredGfxOverlay, OverlayCommand}; use crate::memory_banks::GlyphBankPoolAccess; use prometeu_hal::GfxBridge; use prometeu_hal::color::Color; @@ -31,22 +32,11 @@ pub enum BlendMode { /// PROMETEU Graphics Subsystem (GFX). /// -/// Models a specialized graphics chip with a fixed resolution, double buffering, -/// and a multi-layered tile/sprite architecture. -/// -/// The GFX system works by composing several "layers" into a single 16-bit -/// RGB565 framebuffer. It supports hardware-accelerated primitives (lines, rects) -/// and specialized console features like background scrolling and sprite sorting. -/// -/// ### Layer Composition Order (back to front): -/// 1. **Priority 0 Sprites**: Objects behind everything else. -/// 2. **Tile Layer 0 + Priority 1 Sprites**: Background 0. -/// 3. **Tile Layer 1 + Priority 2 Sprites**: Background 1. -/// 4. **Tile Layer 2 + Priority 3 Sprites**: Background 2. -/// 5. **Tile Layer 3 + Priority 4 Sprites**: Foreground. -/// 6. **Scene Fade**: Global brightness/color filter. -/// 7. **HUD Layer**: Fixed UI elements (always on top). -/// 8. **HUD Fade**: Independent fade for the UI. +/// `Gfx` owns the framebuffer backend and the canonical game-frame raster path +/// consumed by `FrameComposer`. That canonical path covers scene composition, +/// sprite composition, and fades. Public `gfx.*` primitives remain valid, but +/// they do not define the canonical game composition contract; they belong to a +/// separate final overlay/debug stage. pub struct Gfx { /// Width of the internal framebuffer in pixels. w: usize, @@ -54,11 +44,16 @@ pub struct Gfx { h: usize, /// Front buffer: the "VRAM" currently being displayed by the Host window. front: Vec, - /// Back buffer: the "Work RAM" where new frames are composed. + /// Back buffer: the working buffer where canonical game frames are composed + /// before any final overlay/debug drain. back: Vec, /// Shared access to graphical memory banks (tiles and palettes). pub glyph_banks: Arc, + /// Deferred overlay/debug capture kept separate from canonical game composition. + overlay: DeferredGfxOverlay, + /// Internal guard to replay deferred overlay commands without re-enqueueing them. + is_draining_overlay: bool, /// Hardware sprite list (512 slots). Equivalent to OAM (Object Attribute Memory). pub sprites: [Sprite; 512], @@ -71,12 +66,29 @@ pub struct Gfx { /// Target color for the HUD fade effect. pub hud_fade_color: Color, - /// Internal cache used to sort sprites into priority groups to optimize rendering. - priority_buckets: [Vec; 5], + /// Internal sprite count for the current frame state. + sprite_count: usize, + /// Internal cache used to sort sprites by layer while keeping stable priority order. + layer_buckets: [Vec; 4], } const GLYPH_UNKNOWN: [u8; 5] = [0x7, 0x7, 0x7, 0x7, 0x7]; +struct RenderTarget<'a> { + back: &'a mut [u16], + screen_w: usize, + screen_h: usize, +} + +#[derive(Clone, Copy)] +struct CachedTileDraw<'a> { + x: i32, + y: i32, + entry: CachedTileEntry, + bank: &'a GlyphBank, + tile_size: prometeu_hal::glyph_bank::TileSize, +} + #[inline] fn glyph_for_char(c: char) -> &'static [u8; 5] { match c.to_ascii_uppercase() { @@ -207,12 +219,15 @@ impl GfxBridge for Gfx { fn present(&mut self) { self.present() } - fn render_all(&mut self) { - self.render_all() + fn render_no_scene_frame(&mut self) { + self.render_no_scene_frame() } fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) { self.render_scene_from_cache(cache, update) } + fn load_frame_sprites(&mut self, sprites: &[Sprite]) { + self.load_frame_sprites(sprites) + } fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color) { self.draw_text(x, y, text, color) } @@ -224,6 +239,7 @@ impl GfxBridge for Gfx { &self.sprites[index] } fn sprite_mut(&mut self, index: usize) -> &mut Sprite { + self.sprite_count = self.sprite_count.max(index.saturating_add(1)).min(self.sprites.len()); &mut self.sprites[index] } @@ -262,6 +278,7 @@ impl Gfx { glyph: EMPTY_GLYPH, x: 0, y: 0, + layer: 0, bank_id: 0, active: false, flip_x: false, @@ -276,13 +293,15 @@ impl Gfx { front: vec![0; len], back: vec![0; len], glyph_banks, + overlay: DeferredGfxOverlay::default(), + is_draining_overlay: false, sprites: [EMPTY_SPRITE; 512], + sprite_count: 0, scene_fade_level: 31, scene_fade_color: Color::BLACK, hud_fade_level: 31, hud_fade_color: Color::BLACK, - priority_buckets: [ - Vec::with_capacity(128), + layer_buckets: [ Vec::with_capacity(128), Vec::with_capacity(128), Vec::with_capacity(128), @@ -295,6 +314,42 @@ impl Gfx { (self.w, self.h) } + pub fn begin_overlay_frame(&mut self) { + self.overlay.begin_frame(); + } + + pub fn overlay(&self) -> &DeferredGfxOverlay { + &self.overlay + } + + pub fn drain_overlay_debug(&mut self) { + let commands = self.overlay.take_commands(); + self.is_draining_overlay = true; + + for command in commands { + match command { + OverlayCommand::FillRectBlend { x, y, w, h, color, mode } => { + self.fill_rect_blend(x, y, w, h, color, mode) + } + OverlayCommand::DrawLine { x0, y0, x1, y1, color } => { + self.draw_line(x0, y0, x1, y1, color) + } + OverlayCommand::DrawCircle { x, y, r, color } => self.draw_circle(x, y, r, color), + OverlayCommand::DrawDisc { x, y, r, border_color, fill_color } => { + self.draw_disc(x, y, r, border_color, fill_color) + } + OverlayCommand::DrawSquare { x, y, w, h, border_color, fill_color } => { + self.draw_square(x, y, w, h, border_color, fill_color) + } + OverlayCommand::DrawText { x, y, text, color } => { + self.draw_text(x, y, &text, color) + } + } + } + + self.is_draining_overlay = false; + } + /// The buffer that the host should display (RGB565). pub fn front_buffer(&self) -> &[u16] { &self.front @@ -314,6 +369,10 @@ impl Gfx { color: Color, mode: BlendMode, ) { + if !self.is_draining_overlay { + self.overlay.push(OverlayCommand::FillRectBlend { x, y, w, h, color, mode }); + return; + } if color == Color::COLOR_KEY { return; } @@ -355,6 +414,10 @@ impl Gfx { /// Draws a line between two points using Bresenham's algorithm. pub fn draw_line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: Color) { + if !self.is_draining_overlay { + self.overlay.push(OverlayCommand::DrawLine { x0, y0, x1, y1, color }); + return; + } if color == Color::COLOR_KEY { return; } @@ -387,6 +450,10 @@ impl Gfx { /// Draws a circle outline using Midpoint Circle Algorithm. pub fn draw_circle(&mut self, xc: i32, yc: i32, r: i32, color: Color) { + if !self.is_draining_overlay { + self.overlay.push(OverlayCommand::DrawCircle { x: xc, y: yc, r, color }); + return; + } if color == Color::COLOR_KEY { return; } @@ -455,6 +522,10 @@ impl Gfx { /// Draws a disc (filled circle with border). pub fn draw_disc(&mut self, x: i32, y: i32, r: i32, border_color: Color, fill_color: Color) { + if !self.is_draining_overlay { + self.overlay.push(OverlayCommand::DrawDisc { x, y, r, border_color, fill_color }); + return; + } self.fill_circle(x, y, r, fill_color); self.draw_circle(x, y, r, border_color); } @@ -484,6 +555,10 @@ impl Gfx { border_color: Color, fill_color: Color, ) { + if !self.is_draining_overlay { + self.overlay.push(OverlayCommand::DrawSquare { x, y, w, h, border_color, fill_color }); + return; + } self.fill_rect(x, y, w, h, fill_color); self.draw_rect(x, y, w, h, border_color); } @@ -530,23 +605,33 @@ impl Gfx { std::mem::swap(&mut self.front, &mut self.back); } + pub fn load_frame_sprites(&mut self, sprites: &[Sprite]) { + self.sprite_count = sprites.len().min(self.sprites.len()); + for (index, sprite) in sprites.iter().copied().take(self.sprites.len()).enumerate() { + self.sprites[index] = Sprite { active: true, ..sprite }; + } + for sprite in self.sprites.iter_mut().skip(self.sprite_count) { + sprite.active = false; + } + } + /// The main rendering pipeline. /// /// This method composes the final frame by rasterizing layers and sprites in the /// correct priority order into the back buffer. /// Follows the hardware model where layers and sprites are composed every frame. - pub fn render_all(&mut self) { - self.populate_priority_buckets(); - - // 1. Priority 0 sprites: drawn at the very back, behind everything else. - Self::draw_bucket_on_buffer( - &mut self.back, - self.w, - self.h, - &self.priority_buckets[0], - &self.sprites, - &*self.glyph_banks, - ); + pub fn render_no_scene_frame(&mut self) { + self.populate_layer_buckets(); + for bucket in &self.layer_buckets { + Self::draw_bucket_on_buffer( + &mut self.back, + self.w, + self.h, + bucket, + &self.sprites, + &*self.glyph_banks, + ); + } // 2. Scene-only fallback path: sprites and fades still work even before a // cache-backed world composition request is issued for the frame. @@ -563,18 +648,17 @@ impl Gfx { /// plus sprite state and fade controls. pub fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) { self.back.fill(Color::BLACK.raw()); - self.populate_priority_buckets(); - - Self::draw_bucket_on_buffer( - &mut self.back, - self.w, - self.h, - &self.priority_buckets[0], - &self.sprites, - &*self.glyph_banks, - ); + self.populate_layer_buckets(); for layer_index in 0..cache.layers.len() { + Self::draw_bucket_on_buffer( + &mut self.back, + self.w, + self.h, + &self.layer_buckets[layer_index], + &self.sprites, + &*self.glyph_banks, + ); Self::draw_cache_layer_to_buffer( &mut self.back, self.w, @@ -583,31 +667,26 @@ impl Gfx { &update.copy_requests[layer_index], &*self.glyph_banks, ); - - Self::draw_bucket_on_buffer( - &mut self.back, - self.w, - self.h, - &self.priority_buckets[layer_index + 1], - &self.sprites, - &*self.glyph_banks, - ); } Self::apply_fade_to_buffer(&mut self.back, self.scene_fade_level, self.scene_fade_color); Self::apply_fade_to_buffer(&mut self.back, self.hud_fade_level, self.hud_fade_color); } - fn populate_priority_buckets(&mut self) { - for bucket in self.priority_buckets.iter_mut() { + fn populate_layer_buckets(&mut self) { + for bucket in self.layer_buckets.iter_mut() { bucket.clear(); } - for (idx, sprite) in self.sprites.iter().enumerate() { - if sprite.active && sprite.priority < 5 { - self.priority_buckets[sprite.priority as usize].push(idx); + for (idx, sprite) in self.sprites.iter().take(self.sprite_count).enumerate() { + if sprite.active && (sprite.layer as usize) < self.layer_buckets.len() { + self.layer_buckets[sprite.layer as usize].push(idx); } } + + for bucket in self.layer_buckets.iter_mut() { + bucket.sort_by_key(|&idx| self.sprites[idx].priority); + } } fn draw_cache_layer_to_buffer( @@ -618,6 +697,7 @@ impl Gfx { request: &LayerCopyRequest, glyph_banks: &dyn GlyphBankPoolAccess, ) { + let mut target = RenderTarget { back, screen_w, screen_h }; let layer_cache = &cache.layers[request.layer_index]; if !layer_cache.valid { return; @@ -646,52 +726,43 @@ impl Gfx { } Self::draw_cached_tile_pixels( - back, - screen_w, - screen_h, - screen_tile_x, - screen_tile_y, - entry, - &bank, - request.tile_size, + &mut target, + CachedTileDraw { + x: screen_tile_x, + y: screen_tile_y, + entry, + bank: &bank, + tile_size: request.tile_size, + }, ); } } } - fn draw_cached_tile_pixels( - back: &mut [u16], - screen_w: usize, - screen_h: usize, - x: i32, - y: i32, - entry: CachedTileEntry, - bank: &GlyphBank, - tile_size: prometeu_hal::glyph_bank::TileSize, - ) { - let size = tile_size as usize; + fn draw_cached_tile_pixels(target: &mut RenderTarget<'_>, tile: CachedTileDraw<'_>) { + let size = tile.tile_size as usize; for local_y in 0..size { - let world_y = y + local_y as i32; - if world_y < 0 || world_y >= screen_h as i32 { + let world_y = tile.y + local_y as i32; + if world_y < 0 || world_y >= target.screen_h as i32 { continue; } for local_x in 0..size { - let world_x = x + local_x as i32; - if world_x < 0 || world_x >= screen_w as i32 { + let world_x = tile.x + local_x as i32; + if world_x < 0 || world_x >= target.screen_w as i32 { continue; } - let fetch_x = if entry.flip_x() { size - 1 - local_x } else { local_x }; - let fetch_y = if entry.flip_y() { size - 1 - local_y } else { local_y }; - let px_index = bank.get_pixel_index(entry.glyph_id, fetch_x, fetch_y); + let fetch_x = if tile.entry.flip_x() { size - 1 - local_x } else { local_x }; + let fetch_y = if tile.entry.flip_y() { size - 1 - local_y } else { local_y }; + let px_index = tile.bank.get_pixel_index(tile.entry.glyph_id, fetch_x, fetch_y); if px_index == 0 { continue; } - let color = bank.resolve_color(entry.palette_id, px_index); - back[world_y as usize * screen_w + world_x as usize] = color.raw(); + let color = tile.bank.resolve_color(tile.entry.palette_id, px_index); + target.back[world_y as usize * target.screen_w + world_x as usize] = color.raw(); } } } @@ -775,6 +846,10 @@ impl Gfx { } pub fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color) { + if !self.is_draining_overlay { + self.overlay.push(OverlayCommand::DrawText { x, y, text: text.to_string(), color }); + return; + } let mut cx = x; for c in text.chars() { self.draw_char(cx, y, c, color); @@ -819,10 +894,11 @@ impl Gfx { #[cfg(test)] mod tests { use super::*; - use crate::memory_banks::{GlyphBankPoolInstaller, MemoryBanks}; + use crate::FrameComposer; + use crate::memory_banks::{GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess}; use prometeu_hal::glyph_bank::TileSize; use prometeu_hal::scene_bank::SceneBank; - use prometeu_hal::scene_layer::{MotionFactor, SceneLayer}; + 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; @@ -853,7 +929,7 @@ mod tests { active: true, glyph_bank_id, tile_size: TileSize::Size8, - motion_factor: MotionFactor { x: 1.0, y: 1.0 }, + parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, tilemap: TileMap { width, height, @@ -875,7 +951,7 @@ mod tests { active: false, glyph_bank_id, tile_size: TileSize::Size8, - motion_factor: MotionFactor { x: 1.0, y: 1.0 }, + parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, tilemap: TileMap { width, height, tiles: vec![Tile::default(); width * height] }, } } @@ -918,9 +994,12 @@ mod tests { fn test_draw_line() { let banks = Arc::new(MemoryBanks::new()); let mut gfx = Gfx::new(10, 10, banks); + gfx.begin_overlay_frame(); gfx.draw_line(0, 0, 9, 9, Color::WHITE); + gfx.drain_overlay_debug(); assert_eq!(gfx.back[0], Color::WHITE.0); assert_eq!(gfx.back[9 * 10 + 9], Color::WHITE.0); + assert_eq!(gfx.overlay().command_count(), 0); } #[test] @@ -946,11 +1025,54 @@ mod tests { fn test_draw_square() { let banks = Arc::new(MemoryBanks::new()); let mut gfx = Gfx::new(10, 10, banks); + gfx.begin_overlay_frame(); gfx.draw_square(2, 2, 6, 6, Color::WHITE, Color::BLACK); + gfx.drain_overlay_debug(); // Border assert_eq!(gfx.back[2 * 10 + 2], Color::WHITE.0); // Fill assert_eq!(gfx.back[3 * 10 + 3], Color::BLACK.0); + assert_eq!(gfx.overlay().command_count(), 0); + } + + #[test] + fn draw_text_captures_overlay_command() { + let banks = Arc::new(MemoryBanks::new()); + let mut gfx = Gfx::new(32, 18, banks); + gfx.begin_overlay_frame(); + + gfx.draw_text(4, 5, "HUD", Color::WHITE); + + assert_eq!( + gfx.overlay().commands(), + &[OverlayCommand::DrawText { x: 4, y: 5, text: "HUD".into(), color: Color::WHITE }] + ); + } + + #[test] + fn overlay_state_is_separate_from_frame_composer_sprite_state() { + let banks = Arc::new(MemoryBanks::new()); + let mut gfx = Gfx::new(32, 18, Arc::clone(&banks) as Arc); + let mut frame_composer = + FrameComposer::new(32, 18, Arc::clone(&banks) as Arc); + + gfx.begin_overlay_frame(); + frame_composer.begin_frame(); + frame_composer.emit_sprite(Sprite { + glyph: Glyph { glyph_id: 0, palette_id: 0 }, + x: 1, + y: 2, + layer: 0, + bank_id: 0, + active: true, + flip_x: false, + flip_y: false, + priority: 0, + }); + gfx.draw_text(1, 1, "X", Color::WHITE); + + assert_eq!(frame_composer.sprite_controller().sprite_count(), 1); + assert_eq!(gfx.overlay().command_count(), 1); } #[test] @@ -1004,6 +1126,7 @@ mod tests { glyph: Glyph { glyph_id: 0, palette_id: 4 }, x: 0, y: 0, + layer: 0, bank_id: 0, active: true, flip_x: false, @@ -1014,17 +1137,57 @@ mod tests { glyph: Glyph { glyph_id: 0, palette_id: 4 }, x: 0, y: 0, + layer: 2, bank_id: 0, active: true, flip_x: false, flip_y: false, priority: 2, }; + gfx.sprite_count = 2; gfx.render_scene_from_cache(&cache, &update); assert_eq!(gfx.back[0], Color::BLUE.raw()); } + + #[test] + fn load_frame_sprites_replaces_slot_first_submission_for_render_state() { + let banks = Arc::new(MemoryBanks::new()); + let mut gfx = Gfx::new(16, 16, banks as Arc); + + gfx.load_frame_sprites(&[ + Sprite { + glyph: Glyph { glyph_id: 1, palette_id: 2 }, + x: 2, + y: 3, + layer: 1, + bank_id: 4, + active: false, + flip_x: true, + flip_y: false, + priority: 7, + }, + Sprite { + glyph: Glyph { glyph_id: 5, palette_id: 6 }, + x: 7, + y: 8, + layer: 3, + bank_id: 9, + active: false, + flip_x: false, + flip_y: true, + priority: 1, + }, + ]); + + assert_eq!(gfx.sprite_count, 2); + assert!(gfx.sprites[0].active); + assert!(gfx.sprites[1].active); + assert!(!gfx.sprites[2].active); + assert_eq!(gfx.sprites[0].layer, 1); + assert_eq!(gfx.sprites[1].glyph.glyph_id, 5); + } } /// Blends in RGB565 per channel with saturation. diff --git a/crates/console/prometeu-drivers/src/gfx_overlay.rs b/crates/console/prometeu-drivers/src/gfx_overlay.rs new file mode 100644 index 00000000..be77e9c4 --- /dev/null +++ b/crates/console/prometeu-drivers/src/gfx_overlay.rs @@ -0,0 +1,39 @@ +use crate::gfx::BlendMode; +use prometeu_hal::color::Color; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OverlayCommand { + FillRectBlend { x: i32, y: i32, w: i32, h: i32, color: Color, mode: BlendMode }, + DrawLine { x0: i32, y0: i32, x1: i32, y1: i32, color: Color }, + DrawCircle { x: i32, y: i32, r: i32, color: Color }, + DrawDisc { x: i32, y: i32, r: i32, border_color: Color, fill_color: Color }, + DrawSquare { x: i32, y: i32, w: i32, h: i32, border_color: Color, fill_color: Color }, + DrawText { x: i32, y: i32, text: String, color: Color }, +} + +#[derive(Debug, Clone, Default)] +pub struct DeferredGfxOverlay { + commands: Vec, +} + +impl DeferredGfxOverlay { + pub fn begin_frame(&mut self) { + self.commands.clear(); + } + + pub fn push(&mut self, command: OverlayCommand) { + self.commands.push(command); + } + + pub fn commands(&self) -> &[OverlayCommand] { + &self.commands + } + + pub fn command_count(&self) -> usize { + self.commands.len() + } + + pub fn take_commands(&mut self) -> Vec { + std::mem::take(&mut self.commands) + } +} diff --git a/crates/console/prometeu-drivers/src/hardware.rs b/crates/console/prometeu-drivers/src/hardware.rs index 3d7a2194..a7f09aec 100644 --- a/crates/console/prometeu-drivers/src/hardware.rs +++ b/crates/console/prometeu-drivers/src/hardware.rs @@ -1,13 +1,15 @@ use crate::asset::AssetManager; use crate::audio::Audio; +use crate::frame_composer::FrameComposer; use crate::gfx::Gfx; use crate::memory_banks::{ - GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller, - SoundBankPoolAccess, SoundBankPoolInstaller, + GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess, + SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller, }; 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 std::sync::Arc; @@ -26,6 +28,8 @@ use std::sync::Arc; pub struct Hardware { /// The Graphics Processing Unit (GPU). Handles drawing primitives, sprites, and tilemaps. pub gfx: Gfx, + /// Canonical frame orchestration owner for scene/camera/cache/resolver/sprites. + pub frame_composer: FrameComposer, /// The Sound Processing Unit (SPU). Manages sample playback and volume control. pub audio: Audio, /// The standard digital gamepad. Provides state for D-Pad, face buttons, and triggers. @@ -43,6 +47,36 @@ impl Default for Hardware { } impl HardwareBridge for Hardware { + fn begin_frame(&mut self) { + self.gfx.begin_overlay_frame(); + 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 render_frame(&mut self) { + self.frame_composer.render_frame(&mut self.gfx); + self.gfx.drain_overlay_debug(); + } + + fn has_glyph_bank(&self, bank_id: usize) -> bool { + self.gfx.glyph_banks.glyph_bank_slot(bank_id).is_some() + } + fn gfx(&self) -> &dyn GfxBridge { &self.gfx } @@ -98,6 +132,11 @@ impl Hardware { Self::H, Arc::clone(&memory_banks) as Arc, ), + frame_composer: FrameComposer::new( + Self::W, + Self::H, + Arc::clone(&memory_banks) as Arc, + ), audio: Audio::new(Arc::clone(&memory_banks) as Arc), pad: Pad::default(), touch: Touch::default(), @@ -122,7 +161,7 @@ mod tests { use prometeu_hal::glyph::Glyph; use prometeu_hal::glyph_bank::{GlyphBank, TileSize}; use prometeu_hal::scene_bank::SceneBank; - use prometeu_hal::scene_layer::{MotionFactor, SceneLayer}; + 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; @@ -142,7 +181,7 @@ mod tests { active: true, glyph_bank_id: 0, tile_size: TileSize::Size8, - motion_factor: MotionFactor { x: 1.0, y: 1.0 }, + parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, tilemap: TileMap { width: 4, height: 4, @@ -182,4 +221,20 @@ mod tests { assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw()); } + + #[test] + fn hardware_constructs_frame_composer_with_shared_scene_bank_access() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_scene_bank(2, Arc::new(make_scene())); + + let hardware = Hardware::new_with_memory_banks(banks); + let scene = hardware + .frame_composer + .scene_bank_slot(2) + .expect("scene bank slot 2 should be resident"); + + assert_eq!(hardware.frame_composer.viewport_size(), (Hardware::W, Hardware::H)); + assert_eq!(hardware.frame_composer.scene_bank_slot_count(), 16); + assert_eq!(scene.layers[0].tile_size, TileSize::Size8); + } } diff --git a/crates/console/prometeu-drivers/src/lib.rs b/crates/console/prometeu-drivers/src/lib.rs index f9d82bc5..397e9a07 100644 --- a/crates/console/prometeu-drivers/src/lib.rs +++ b/crates/console/prometeu-drivers/src/lib.rs @@ -1,6 +1,8 @@ mod asset; mod audio; +mod frame_composer; mod gfx; +mod gfx_overlay; pub mod hardware; mod memory_banks; mod pad; @@ -8,7 +10,9 @@ mod touch; pub use crate::asset::AssetManager; pub use crate::audio::{Audio, AudioCommand, Channel, MAX_CHANNELS, OUTPUT_SAMPLE_RATE}; +pub use crate::frame_composer::{FrameComposer, SceneStatus, SpriteController}; pub use crate::gfx::Gfx; +pub use crate::gfx_overlay::{DeferredGfxOverlay, OverlayCommand}; pub use crate::memory_banks::{ GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess, SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller, diff --git a/crates/console/prometeu-hal/src/composer_status.rs b/crates/console/prometeu-hal/src/composer_status.rs new file mode 100644 index 00000000..2e02b7ed --- /dev/null +++ b/crates/console/prometeu-hal/src/composer_status.rs @@ -0,0 +1,10 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum ComposerOpStatus { + Ok = 0, + SceneUnavailable = 1, + ArgRangeInvalid = 2, + BankInvalid = 3, + LayerInvalid = 4, + SpriteOverflow = 5, +} diff --git a/crates/console/prometeu-hal/src/gfx_bridge.rs b/crates/console/prometeu-hal/src/gfx_bridge.rs index 1c677ab4..dc989f82 100644 --- a/crates/console/prometeu-hal/src/gfx_bridge.rs +++ b/crates/console/prometeu-hal/src/gfx_bridge.rs @@ -48,8 +48,21 @@ pub trait GfxBridge { fn draw_horizontal_line(&mut self, x0: i32, x1: i32, y: i32, color: Color); fn draw_vertical_line(&mut self, x: i32, y0: i32, y1: i32, color: Color); fn present(&mut self); - fn render_all(&mut self); + /// Render the canonical game frame with no bound scene. + /// + /// Deferred `gfx.*` overlay/debug primitives are intentionally outside this + /// contract and are drained by a separate final overlay stage. + fn render_no_scene_frame(&mut self); + /// Render the canonical scene-backed game frame from cache/resolver state. + /// + /// Deferred `gfx.*` overlay/debug primitives are intentionally outside this + /// contract and are drained by a separate final overlay stage. fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate); + fn load_frame_sprites(&mut self, sprites: &[Sprite]); + /// Submit text into the `gfx.*` primitive path. + /// + /// Under the accepted runtime contract this is not the canonical game + /// composition path; it belongs to the deferred final overlay/debug family. fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color); fn draw_char(&mut self, x: i32, y: i32, c: char, color: Color); diff --git a/crates/console/prometeu-hal/src/hardware_bridge.rs b/crates/console/prometeu-hal/src/hardware_bridge.rs index 1e8d9728..28ccb77f 100644 --- a/crates/console/prometeu-hal/src/hardware_bridge.rs +++ b/crates/console/prometeu-hal/src/hardware_bridge.rs @@ -2,9 +2,18 @@ use crate::asset_bridge::AssetBridge; use crate::audio_bridge::AudioBridge; use crate::gfx_bridge::GfxBridge; use crate::pad_bridge::PadBridge; +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 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; diff --git a/crates/console/prometeu-hal/src/lib.rs b/crates/console/prometeu-hal/src/lib.rs index abfc7d7c..cc8de0a1 100644 --- a/crates/console/prometeu-hal/src/lib.rs +++ b/crates/console/prometeu-hal/src/lib.rs @@ -5,6 +5,7 @@ pub mod button; pub mod cartridge; pub mod cartridge_loader; pub mod color; +pub mod composer_status; pub mod debugger_protocol; pub mod gfx_bridge; pub mod glyph; @@ -34,6 +35,7 @@ pub mod window; 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}; diff --git a/crates/console/prometeu-hal/src/scene_bank.rs b/crates/console/prometeu-hal/src/scene_bank.rs index c5f4b296..6abb47d5 100644 --- a/crates/console/prometeu-hal/src/scene_bank.rs +++ b/crates/console/prometeu-hal/src/scene_bank.rs @@ -10,16 +10,16 @@ mod tests { use super::*; use crate::glyph::Glyph; use crate::glyph_bank::TileSize; - use crate::scene_layer::MotionFactor; + use crate::scene_layer::ParallaxFactor; use crate::tile::Tile; use crate::tilemap::TileMap; - fn layer(glyph_bank_id: u8, motion_x: f32, motion_y: f32, glyph_id: u16) -> SceneLayer { + fn layer(glyph_bank_id: u8, parallax_x: f32, parallax_y: f32, glyph_id: u16) -> SceneLayer { SceneLayer { active: true, glyph_bank_id, tile_size: TileSize::Size16, - motion_factor: MotionFactor { x: motion_x, y: motion_y }, + parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y }, tilemap: TileMap { width: 1, height: 1, diff --git a/crates/console/prometeu-hal/src/scene_layer.rs b/crates/console/prometeu-hal/src/scene_layer.rs index a7c448f3..90feec93 100644 --- a/crates/console/prometeu-hal/src/scene_layer.rs +++ b/crates/console/prometeu-hal/src/scene_layer.rs @@ -2,7 +2,7 @@ use crate::glyph_bank::TileSize; use crate::tilemap::TileMap; #[derive(Clone, Copy, Debug)] -pub struct MotionFactor { +pub struct ParallaxFactor { pub x: f32, pub y: f32, } @@ -12,7 +12,7 @@ pub struct SceneLayer { pub active: bool, pub glyph_bank_id: u8, pub tile_size: TileSize, - pub motion_factor: MotionFactor, + pub parallax_factor: ParallaxFactor, pub tilemap: TileMap, } @@ -23,12 +23,12 @@ mod tests { use crate::tile::Tile; #[test] - fn scene_layer_preserves_motion_factor_and_tilemap_ownership() { + fn scene_layer_preserves_parallax_factor_and_tilemap_ownership() { let layer = SceneLayer { active: true, glyph_bank_id: 7, tile_size: TileSize::Size16, - motion_factor: MotionFactor { x: 0.5, y: 0.75 }, + parallax_factor: ParallaxFactor { x: 0.5, y: 0.75 }, tilemap: TileMap { width: 2, height: 1, @@ -50,8 +50,8 @@ mod tests { }; assert_eq!(layer.glyph_bank_id, 7); - assert_eq!(layer.motion_factor.x, 0.5); - assert_eq!(layer.motion_factor.y, 0.75); + assert_eq!(layer.parallax_factor.x, 0.5); + assert_eq!(layer.parallax_factor.y, 0.75); assert_eq!(layer.tilemap.width, 2); assert_eq!(layer.tilemap.tiles[1].glyph.glyph_id, 22); assert!(layer.tilemap.tiles[1].flip_x); diff --git a/crates/console/prometeu-hal/src/scene_viewport_cache.rs b/crates/console/prometeu-hal/src/scene_viewport_cache.rs index c0c3bcdb..028cc7ff 100644 --- a/crates/console/prometeu-hal/src/scene_viewport_cache.rs +++ b/crates/console/prometeu-hal/src/scene_viewport_cache.rs @@ -270,7 +270,7 @@ mod tests { use super::*; use crate::glyph::Glyph; use crate::glyph_bank::TileSize; - use crate::scene_layer::MotionFactor; + use crate::scene_layer::ParallaxFactor; use crate::tile::Tile; use crate::tilemap::TileMap; @@ -295,7 +295,7 @@ mod tests { active: true, glyph_bank_id, tile_size: TileSize::Size16, - motion_factor: MotionFactor { x: 1.0, y: 1.0 }, + parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, tilemap: TileMap { width: 4, height: 4, tiles }, } } @@ -325,6 +325,34 @@ mod tests { assert_eq!(cache.layers[0].ring_origin(), (1, 1)); } + #[test] + fn layer_cache_wraps_ring_origin_for_negative_and_large_movements() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 3, 3); + + cache.move_layer_window_by(0, -1, -4); + assert_eq!(cache.layers[0].logical_origin(), (-1, -4)); + assert_eq!(cache.layers[0].ring_origin(), (2, 2)); + + cache.move_layer_window_by(0, 7, 8); + assert_eq!(cache.layers[0].logical_origin(), (6, 4)); + assert_eq!(cache.layers[0].ring_origin(), (0, 1)); + } + + #[test] + fn move_window_to_matches_incremental_ring_movement() { + let scene = make_scene(); + let mut direct = SceneViewportCache::new(&scene, 4, 4); + let mut incremental = SceneViewportCache::new(&scene, 4, 4); + + direct.move_layer_window_to(0, 9, -6); + incremental.move_layer_window_by(0, 5, -2); + incremental.move_layer_window_by(0, 4, -4); + + assert_eq!(direct.layers[0].logical_origin(), incremental.layers[0].logical_origin()); + assert_eq!(direct.layers[0].ring_origin(), incremental.layers[0].ring_origin()); + } + #[test] fn cache_entry_fields_are_derived_from_scene_tiles() { let scene = make_scene(); @@ -415,6 +443,113 @@ mod tests { assert_eq!(cache.layers[3].entry(3, 3).glyph_id, 415); } + #[test] + fn refresh_after_wrapped_window_move_materializes_new_logical_tiles() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 3, 3); + + cache.refresh_layer_all(&scene, 0); + cache.move_layer_window_to(0, 1, 2); + cache.refresh_layer_all(&scene, 0); + + assert_eq!(cache.layers[0].logical_origin(), (1, 2)); + assert_eq!(cache.layers[0].ring_origin(), (1, 2)); + assert_eq!(cache.layers[0].entry(0, 0).glyph_id, 109); + assert_eq!(cache.layers[0].entry(1, 0).glyph_id, 110); + assert_eq!(cache.layers[0].entry(2, 0).glyph_id, 111); + assert_eq!(cache.layers[0].entry(0, 1).glyph_id, 113); + assert_eq!(cache.layers[0].entry(2, 1).glyph_id, 115); + assert_eq!(cache.layers[0].entry(0, 2), CachedTileEntry::default()); + } + + #[test] + fn partial_refresh_uses_wrapped_physical_slots_after_window_move() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 3, 3); + + cache.move_layer_window_to(0, 1, 0); + cache.refresh_layer_column(&scene, 0, 0); + + assert_eq!(cache.layers[0].ring_origin(), (1, 0)); + assert_eq!(cache.layers[0].entry(0, 0).glyph_id, 101); + assert_eq!(cache.layers[0].entry(0, 1).glyph_id, 105); + assert_eq!(cache.layers[0].entry(0, 2).glyph_id, 109); + assert_eq!(cache.layers[0].entry(1, 0), CachedTileEntry::default()); + assert_eq!(cache.layers[0].entry(2, 0), CachedTileEntry::default()); + } + + #[test] + fn out_of_bounds_logical_origins_materialize_default_entries_after_wrap() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 2, 2); + + cache.move_layer_window_to(0, -2, 3); + cache.refresh_layer_all(&scene, 0); + + for y in 0..2 { + for x in 0..2 { + assert_eq!(cache.layers[0].entry(x, y), CachedTileEntry::default()); + } + } + } + + #[test] + fn ringbuffer_preserves_logical_tile_mapping_across_long_mixed_movements() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 3, 3); + let motions = [ + (1, 0), + (0, 1), + (2, 2), + (-1, 0), + (0, -2), + (4, 1), + (-3, 3), + (5, -4), + (-6, 2), + (3, -3), + (7, 7), + (-8, -5), + ]; + + for &(dx, dy) in &motions { + cache.move_layer_window_by(0, dx, dy); + cache.refresh_layer_all(&scene, 0); + + let (origin_x, origin_y) = cache.layers[0].logical_origin(); + for cache_y in 0..cache.height() { + for cache_x in 0..cache.width() { + let expected_scene_x = origin_x + cache_x as i32; + let expected_scene_y = origin_y + cache_y as i32; + + let expected = if expected_scene_x < 0 + || expected_scene_y < 0 + || expected_scene_x as usize >= scene.layers[0].tilemap.width + || expected_scene_y as usize >= scene.layers[0].tilemap.height + { + CachedTileEntry::default() + } else { + let tile_x = expected_scene_x as usize; + let tile_y = expected_scene_y as usize; + let tile = scene.layers[0].tilemap.tiles + [tile_y * scene.layers[0].tilemap.width + tile_x]; + CachedTileEntry::from_tile(&scene.layers[0], tile) + }; + + assert_eq!( + cache.layers[0].entry(cache_x, cache_y), + expected, + "mismatch at logical origin ({}, {}), cache ({}, {})", + origin_x, + origin_y, + cache_x, + cache_y + ); + } + } + } + } + #[test] fn materialization_populates_all_four_layers() { let scene = make_scene(); diff --git a/crates/console/prometeu-hal/src/scene_viewport_resolver.rs b/crates/console/prometeu-hal/src/scene_viewport_resolver.rs index 41fad0f2..a9e99902 100644 --- a/crates/console/prometeu-hal/src/scene_viewport_resolver.rs +++ b/crates/console/prometeu-hal/src/scene_viewport_resolver.rs @@ -96,8 +96,8 @@ impl SceneViewportResolver { let layer_inputs: [(i32, i32, i32, i32, i32); 4] = std::array::from_fn(|i| { let layer = &scene.layers[i]; let tile_size_px = layer.tile_size as i32; - let layer_camera_x_px = ((camera_x_px as f32) * layer.motion_factor.x).floor() as i32; - let layer_camera_y_px = ((camera_y_px as f32) * layer.motion_factor.y).floor() as i32; + let layer_camera_x_px = ((camera_x_px as f32) * layer.parallax_factor.x).floor() as i32; + let layer_camera_y_px = ((camera_y_px as f32) * layer.parallax_factor.y).floor() as i32; let layer_center_x_px = layer_camera_x_px + self.viewport_width_px / 2; let layer_center_y_px = layer_camera_y_px + self.viewport_height_px / 2; ( @@ -388,14 +388,14 @@ mod tests { use super::*; use crate::glyph::Glyph; use crate::glyph_bank::TileSize; - use crate::scene_layer::{MotionFactor, SceneLayer}; + use crate::scene_layer::{ParallaxFactor, SceneLayer}; use crate::tile::Tile; use crate::tilemap::TileMap; fn make_layer( tile_size: TileSize, - motion_x: f32, - motion_y: f32, + parallax_x: f32, + parallax_y: f32, width: usize, height: usize, ) -> SceneLayer { @@ -413,7 +413,7 @@ mod tests { active: true, glyph_bank_id: 1, tile_size, - motion_factor: MotionFactor { x: motion_x, y: motion_y }, + parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y }, tilemap: TileMap { width, height, tiles }, } } @@ -443,7 +443,7 @@ mod tests { } #[test] - fn per_layer_copy_requests_follow_motion_factor() { + fn per_layer_copy_requests_follow_parallax_factor() { let scene = make_scene(); let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20); diff --git a/crates/console/prometeu-hal/src/sprite.rs b/crates/console/prometeu-hal/src/sprite.rs index 1d5f141f..e38ae920 100644 --- a/crates/console/prometeu-hal/src/sprite.rs +++ b/crates/console/prometeu-hal/src/sprite.rs @@ -5,6 +5,7 @@ pub struct Sprite { pub glyph: Glyph, pub x: i32, pub y: i32, + pub layer: u8, pub bank_id: u8, pub active: bool, pub flip_x: bool, diff --git a/crates/console/prometeu-hal/src/syscalls.rs b/crates/console/prometeu-hal/src/syscalls.rs index c4a90381..3e9b269a 100644 --- a/crates/console/prometeu-hal/src/syscalls.rs +++ b/crates/console/prometeu-hal/src/syscalls.rs @@ -19,6 +19,7 @@ pub use resolver::{ /// Each Syscall has a unique 32-bit ID. The IDs are grouped by category: /// - **0x0xxx**: System & OS Control /// - **0x1xxx**: Graphics (GFX) +/// - **0x11xx**: Frame Composer orchestration /// - **0x2xxx**: Reserved for legacy input syscalls (disabled for v1 VM-owned input) /// - **0x3xxx**: Audio (PCM & Mixing) /// - **0x4xxx**: Filesystem (Sandboxed I/O) @@ -35,9 +36,12 @@ pub enum Syscall { GfxDrawCircle = 0x1004, GfxDrawDisc = 0x1005, GfxDrawSquare = 0x1006, - GfxSetSprite = 0x1007, GfxDrawText = 0x1008, GfxClear565 = 0x1010, + ComposerBindScene = 0x1101, + ComposerUnbindScene = 0x1102, + ComposerSetCamera = 0x1103, + ComposerEmitSprite = 0x1104, AudioPlaySample = 0x3001, AudioPlay = 0x3002, FsOpen = 0x4001, diff --git a/crates/console/prometeu-hal/src/syscalls/domains/composer.rs b/crates/console/prometeu-hal/src/syscalls/domains/composer.rs new file mode 100644 index 00000000..32a53a95 --- /dev/null +++ b/crates/console/prometeu-hal/src/syscalls/domains/composer.rs @@ -0,0 +1,22 @@ +use crate::syscalls::{Syscall, SyscallRegistryEntry, caps}; + +pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[ + SyscallRegistryEntry::builder(Syscall::ComposerBindScene, "composer", "bind_scene") + .args(1) + .rets(1) + .caps(caps::GFX) + .cost(5), + SyscallRegistryEntry::builder(Syscall::ComposerUnbindScene, "composer", "unbind_scene") + .rets(1) + .caps(caps::GFX) + .cost(2), + SyscallRegistryEntry::builder(Syscall::ComposerSetCamera, "composer", "set_camera") + .args(2) + .caps(caps::GFX) + .cost(2), + SyscallRegistryEntry::builder(Syscall::ComposerEmitSprite, "composer", "emit_sprite") + .args(9) + .rets(1) + .caps(caps::GFX) + .cost(5), +]; diff --git a/crates/console/prometeu-hal/src/syscalls/domains/gfx.rs b/crates/console/prometeu-hal/src/syscalls/domains/gfx.rs index 95998186..f6023957 100644 --- a/crates/console/prometeu-hal/src/syscalls/domains/gfx.rs +++ b/crates/console/prometeu-hal/src/syscalls/domains/gfx.rs @@ -25,11 +25,6 @@ pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[ .args(6) .caps(caps::GFX) .cost(5), - SyscallRegistryEntry::builder(Syscall::GfxSetSprite, "gfx", "set_sprite") - .args(10) - .rets(1) - .caps(caps::GFX) - .cost(5), SyscallRegistryEntry::builder(Syscall::GfxDrawText, "gfx", "draw_text") .args(4) .caps(caps::GFX) diff --git a/crates/console/prometeu-hal/src/syscalls/domains/mod.rs b/crates/console/prometeu-hal/src/syscalls/domains/mod.rs index 3b4d3b34..00e896f1 100644 --- a/crates/console/prometeu-hal/src/syscalls/domains/mod.rs +++ b/crates/console/prometeu-hal/src/syscalls/domains/mod.rs @@ -1,6 +1,7 @@ mod asset; mod audio; mod bank; +mod composer; mod fs; mod gfx; mod log; @@ -12,6 +13,7 @@ pub(crate) fn all_entries() -> impl Iterator Some(Self::GfxDrawCircle), 0x1005 => Some(Self::GfxDrawDisc), 0x1006 => Some(Self::GfxDrawSquare), - 0x1007 => Some(Self::GfxSetSprite), 0x1008 => Some(Self::GfxDrawText), 0x1010 => Some(Self::GfxClear565), + 0x1101 => Some(Self::ComposerBindScene), + 0x1102 => Some(Self::ComposerUnbindScene), + 0x1103 => Some(Self::ComposerSetCamera), + 0x1104 => Some(Self::ComposerEmitSprite), 0x3001 => Some(Self::AudioPlaySample), 0x3002 => Some(Self::AudioPlay), 0x4001 => Some(Self::FsOpen), @@ -68,9 +71,12 @@ impl Syscall { Self::GfxDrawCircle => "GfxDrawCircle", Self::GfxDrawDisc => "GfxDrawDisc", Self::GfxDrawSquare => "GfxDrawSquare", - Self::GfxSetSprite => "GfxSetSprite", Self::GfxDrawText => "GfxDrawText", Self::GfxClear565 => "GfxClear565", + Self::ComposerBindScene => "ComposerBindScene", + Self::ComposerUnbindScene => "ComposerUnbindScene", + Self::ComposerSetCamera => "ComposerSetCamera", + Self::ComposerEmitSprite => "ComposerEmitSprite", Self::AudioPlaySample => "AudioPlaySample", Self::AudioPlay => "AudioPlay", Self::FsOpen => "FsOpen", diff --git a/crates/console/prometeu-hal/src/syscalls/tests.rs b/crates/console/prometeu-hal/src/syscalls/tests.rs index 8f3f55af..3129d813 100644 --- a/crates/console/prometeu-hal/src/syscalls/tests.rs +++ b/crates/console/prometeu-hal/src/syscalls/tests.rs @@ -63,6 +63,18 @@ fn resolver_rejects_unknown_identity() { } } +#[test] +fn resolver_rejects_removed_legacy_gfx_set_sprite_identity() { + assert!(resolve_syscall("gfx", "set_sprite", 1).is_none()); + + let requested = [SyscallIdentity { module: "gfx", name: "set_sprite", version: 1 }]; + let err = resolve_program_syscalls(&requested, caps::ALL).unwrap_err(); + assert_eq!( + err, + LoadError::UnknownSyscall { module: "gfx".into(), name: "set_sprite".into(), version: 1 } + ); +} + #[test] fn resolver_enforces_capabilities() { let requested = [SyscallIdentity { module: "gfx", name: "clear", version: 1 }]; @@ -194,10 +206,6 @@ fn status_first_syscall_signatures_are_pinned() { assert_eq!(draw_square.arg_slots, 6); assert_eq!(draw_square.ret_slots, 0); - let set_sprite = meta_for(Syscall::GfxSetSprite); - assert_eq!(set_sprite.arg_slots, 10); - assert_eq!(set_sprite.ret_slots, 1); - let draw_text = meta_for(Syscall::GfxDrawText); assert_eq!(draw_text.arg_slots, 4); assert_eq!(draw_text.ret_slots, 0); @@ -206,6 +214,22 @@ fn status_first_syscall_signatures_are_pinned() { assert_eq!(clear_565.arg_slots, 1); assert_eq!(clear_565.ret_slots, 0); + let bind_scene = meta_for(Syscall::ComposerBindScene); + assert_eq!(bind_scene.arg_slots, 1); + assert_eq!(bind_scene.ret_slots, 1); + + let unbind_scene = meta_for(Syscall::ComposerUnbindScene); + assert_eq!(unbind_scene.arg_slots, 0); + assert_eq!(unbind_scene.ret_slots, 1); + + let set_camera = meta_for(Syscall::ComposerSetCamera); + assert_eq!(set_camera.arg_slots, 2); + assert_eq!(set_camera.ret_slots, 0); + + let emit_sprite = meta_for(Syscall::ComposerEmitSprite); + assert_eq!(emit_sprite.arg_slots, 9); + assert_eq!(emit_sprite.ret_slots, 1); + let audio_play_sample = meta_for(Syscall::AudioPlaySample); assert_eq!(audio_play_sample.arg_slots, 5); assert_eq!(audio_play_sample.ret_slots, 1); @@ -231,10 +255,10 @@ fn status_first_syscall_signatures_are_pinned() { fn declared_resolver_rejects_legacy_status_first_signatures() { let declared = vec![ prometeu_bytecode::SyscallDecl { - module: "gfx".into(), - name: "set_sprite".into(), + module: "composer".into(), + name: "bind_scene".into(), version: 1, - arg_slots: 10, + arg_slots: 1, ret_slots: 0, }, prometeu_bytecode::SyscallDecl { @@ -306,10 +330,24 @@ fn declared_resolver_rejects_legacy_status_first_signatures() { fn declared_resolver_accepts_mixed_status_first_surface_as_a_single_module() { let declared = vec![ prometeu_bytecode::SyscallDecl { - module: "gfx".into(), - name: "set_sprite".into(), + module: "composer".into(), + name: "bind_scene".into(), version: 1, - arg_slots: 10, + arg_slots: 1, + ret_slots: 1, + }, + prometeu_bytecode::SyscallDecl { + module: "composer".into(), + name: "unbind_scene".into(), + version: 1, + arg_slots: 0, + ret_slots: 1, + }, + prometeu_bytecode::SyscallDecl { + module: "composer".into(), + name: "emit_sprite".into(), + version: 1, + arg_slots: 9, ret_slots: 1, }, prometeu_bytecode::SyscallDecl { @@ -342,8 +380,10 @@ fn declared_resolver_accepts_mixed_status_first_surface_as_a_single_module() { assert_eq!(resolved.len(), declared.len()); assert_eq!(resolved[0].meta.ret_slots, 1); assert_eq!(resolved[1].meta.ret_slots, 1); - assert_eq!(resolved[2].meta.ret_slots, 2); + assert_eq!(resolved[2].meta.ret_slots, 1); assert_eq!(resolved[3].meta.ret_slots, 1); + assert_eq!(resolved[4].meta.ret_slots, 2); + assert_eq!(resolved[5].meta.ret_slots, 1); } #[test] diff --git a/crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs b/crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs index e6037679..2a0fb39b 100644 --- a/crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs +++ b/crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs @@ -10,8 +10,8 @@ use prometeu_hal::sprite::Sprite; use prometeu_hal::syscalls::Syscall; use prometeu_hal::vm_fault::VmFault; use prometeu_hal::{ - AudioOpStatus, GfxOpStatus, HostContext, HostReturn, NativeInterface, SyscallId, expect_bool, - expect_int, + AudioOpStatus, ComposerOpStatus, HostContext, HostReturn, NativeInterface, SyscallId, + expect_bool, expect_int, }; use std::sync::atomic::Ordering; @@ -56,6 +56,23 @@ impl VirtualMachineRuntime { pub(crate) fn get_color(&self, value: i64) -> Color { Color::from_raw(value as u16) } + + fn int_arg_to_usize_status(value: i64) -> Result { + usize::try_from(value).map_err(|_| ComposerOpStatus::ArgRangeInvalid) + } + + fn int_arg_to_i32_trap(value: i64, name: &str) -> Result { + i32::try_from(value) + .map_err(|_| VmFault::Trap(TRAP_OOB, format!("{name} value out of bounds"))) + } + + fn int_arg_to_u8_status(value: i64) -> Result { + u8::try_from(value).map_err(|_| ComposerOpStatus::ArgRangeInvalid) + } + + fn int_arg_to_u16_status(value: i64) -> Result { + u16::try_from(value).map_err(|_| ComposerOpStatus::ArgRangeInvalid) + } } impl NativeInterface for VirtualMachineRuntime { @@ -135,46 +152,6 @@ impl NativeInterface for VirtualMachineRuntime { hw.gfx_mut().draw_square(x, y, w, h, border_color, fill_color); Ok(()) } - Syscall::GfxSetSprite => { - let bank_id = expect_int(args, 0)? as u8; - let index = expect_int(args, 1)? as usize; - let x = expect_int(args, 2)? as i32; - let y = expect_int(args, 3)? as i32; - let glyph_id = expect_int(args, 4)? as u16; - let palette_id = expect_int(args, 5)? as u8; - let active = expect_bool(args, 6)?; - let flip_x = expect_bool(args, 7)?; - let flip_y = expect_bool(args, 8)?; - let priority = expect_int(args, 9)? as u8; - - if index >= 512 { - ret.push_int(GfxOpStatus::InvalidSpriteIndex as i64); - return Ok(()); - } - - if hw.assets().slot_info(SlotRef::gfx(bank_id as usize)).asset_id.is_none() { - ret.push_int(GfxOpStatus::BankInvalid as i64); - return Ok(()); - } - - if palette_id >= 64 || priority >= 5 { - ret.push_int(GfxOpStatus::ArgRangeInvalid as i64); - return Ok(()); - } - - *hw.gfx_mut().sprite_mut(index) = Sprite { - glyph: Glyph { glyph_id, palette_id }, - x, - y, - bank_id, - active, - flip_x, - flip_y, - priority, - }; - ret.push_int(GfxOpStatus::Ok as i64); - Ok(()) - } Syscall::GfxDrawText => { let x = expect_int(args, 0)? as i32; let y = expect_int(args, 1)? as i32; @@ -191,6 +168,100 @@ impl NativeInterface for VirtualMachineRuntime { hw.gfx_mut().clear(Color::from_raw(color_val as u16)); Ok(()) } + Syscall::ComposerBindScene => { + let scene_bank_id = match Self::int_arg_to_usize_status(expect_int(args, 0)?) { + Ok(id) => id, + Err(status) => { + ret.push_int(status as i64); + return Ok(()); + } + }; + + let status = if hw.bind_scene(scene_bank_id) { + ComposerOpStatus::Ok + } else { + ComposerOpStatus::SceneUnavailable + }; + ret.push_int(status as i64); + Ok(()) + } + Syscall::ComposerUnbindScene => { + hw.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); + Ok(()) + } + Syscall::ComposerEmitSprite => { + let glyph_id = match Self::int_arg_to_u16_status(expect_int(args, 0)?) { + Ok(value) => value, + Err(status) => { + ret.push_int(status as i64); + return Ok(()); + } + }; + let palette_id = match Self::int_arg_to_u8_status(expect_int(args, 1)?) { + Ok(value) if value < 64 => value, + _ => { + ret.push_int(ComposerOpStatus::ArgRangeInvalid as i64); + return Ok(()); + } + }; + let x = Self::int_arg_to_i32_trap(expect_int(args, 2)?, "sprite x")?; + let y = Self::int_arg_to_i32_trap(expect_int(args, 3)?, "sprite y")?; + let layer = match Self::int_arg_to_u8_status(expect_int(args, 4)?) { + Ok(value) if value < 4 => value, + Ok(_) => { + ret.push_int(ComposerOpStatus::LayerInvalid as i64); + return Ok(()); + } + Err(status) => { + ret.push_int(status as i64); + return Ok(()); + } + }; + let bank_id = match Self::int_arg_to_u8_status(expect_int(args, 5)?) { + Ok(value) => value, + Err(status) => { + ret.push_int(status as i64); + return Ok(()); + } + }; + let flip_x = expect_bool(args, 6)?; + let flip_y = expect_bool(args, 7)?; + let priority = match Self::int_arg_to_u8_status(expect_int(args, 8)?) { + Ok(value) => value, + Err(status) => { + ret.push_int(status as i64); + return Ok(()); + } + }; + + if !hw.has_glyph_bank(bank_id as usize) { + ret.push_int(ComposerOpStatus::BankInvalid as i64); + return Ok(()); + } + + let emitted = hw.emit_sprite(Sprite { + glyph: Glyph { glyph_id, palette_id }, + x, + y, + layer, + bank_id, + active: false, + flip_x, + flip_y, + priority, + }); + let status = + if emitted { ComposerOpStatus::Ok } else { ComposerOpStatus::SpriteOverflow }; + ret.push_int(status as i64); + Ok(()) + } Syscall::AudioPlaySample => { let sample_id_raw = expect_int(args, 0)?; let voice_id_raw = expect_int(args, 1)?; diff --git a/crates/console/prometeu-system/src/virtual_machine_runtime/tests.rs b/crates/console/prometeu-system/src/virtual_machine_runtime/tests.rs index 4c0c3b38..6ca08d2b 100644 --- a/crates/console/prometeu-system/src/virtual_machine_runtime/tests.rs +++ b/crates/console/prometeu-system/src/virtual_machine_runtime/tests.rs @@ -5,17 +5,25 @@ 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::{GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller}; use prometeu_hal::AudioOpStatus; -use prometeu_hal::GfxOpStatus; +use prometeu_hal::ComposerOpStatus; use prometeu_hal::InputSignals; use prometeu_hal::asset::{ AssetCodec, AssetEntry, AssetLoadError, AssetOpStatus, BankType, LoadStatus, }; use prometeu_hal::cartridge::{AssetsPayloadSource, Cartridge}; -use prometeu_hal::glyph_bank::GLYPH_BANK_PALETTE_COUNT_V1; +use prometeu_hal::color::Color; +use prometeu_hal::glyph::Glyph; +use prometeu_hal::glyph_bank::{GLYPH_BANK_PALETTE_COUNT_V1, GlyphBank, TileSize}; +use prometeu_hal::scene_bank::SceneBank; +use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer}; use prometeu_hal::syscalls::caps; +use prometeu_hal::tile::Tile; +use prometeu_hal::tilemap::TileMap; use prometeu_vm::VmInitError; use std::collections::HashMap; +use std::sync::Arc; use std::sync::atomic::Ordering; #[derive(Default)] @@ -129,6 +137,40 @@ fn test_glyph_asset_data() -> Vec { data } +fn runtime_test_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank { + let size = tile_size as usize; + let mut bank = GlyphBank::new(tile_size, size, size); + bank.palettes[palette_id as usize][1] = color; + for pixel in &mut bank.pixel_indices { + *pixel = 1; + } + bank +} + +fn runtime_test_scene(glyph_bank_id: u8, palette_id: u8, tile_size: TileSize) -> SceneBank { + let layer = SceneLayer { + active: true, + glyph_bank_id, + tile_size, + parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, + tilemap: TileMap { + width: 2, + height: 2, + tiles: vec![ + Tile { + active: true, + glyph: Glyph { glyph_id: 0, palette_id }, + flip_x: false, + flip_y: false, + }; + 4 + ], + }, + }; + + SceneBank { layers: std::array::from_fn(|_| layer.clone()) } +} + #[test] fn initialize_vm_applies_cartridge_capabilities_before_loader_resolution() { let mut runtime = VirtualMachineRuntime::new(None); @@ -233,6 +275,174 @@ fn tick_returns_panic_report_distinct_from_trap() { assert!(matches!(runtime.last_crash_report, Some(CrashReport::VmPanic { .. }))); } +#[test] +fn tick_renders_bound_eight_pixel_scene_through_frame_composer_path() { + let mut runtime = VirtualMachineRuntime::new(None); + let mut vm = VirtualMachine::default(); + let signals = InputSignals::default(); + let program = + serialized_single_function_module(assemble("FRAME_SYNC\nHALT").expect("assemble"), vec![]); + let cartridge = cartridge_with_program(program, caps::NONE); + + 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)); + assert!(hardware.frame_composer.bind_scene(0)); + + runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); + let report = runtime.tick(&mut vm, &signals, &mut hardware); + + assert!(report.is_none(), "frame render path must not crash"); + hardware.gfx.present(); + assert_eq!(hardware.gfx.front_buffer()[0], Color::BLUE.raw()); +} + +#[test] +fn tick_renders_scene_through_public_composer_syscalls() { + let mut runtime = VirtualMachineRuntime::new(None); + let mut vm = VirtualMachine::default(); + let signals = InputSignals::default(); + let code = assemble( + "PUSH_I32 0\nHOSTCALL 0\nPOP_N 1\n\ + PUSH_I32 0\nPUSH_I32 0\nHOSTCALL 1\n\ + PUSH_I32 0\nPUSH_I32 2\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 2\n\ + FRAME_SYNC\nHALT", + ) + .expect("assemble"); + let program = serialized_single_function_module( + code, + vec![ + SyscallDecl { + module: "composer".into(), + name: "bind_scene".into(), + version: 1, + arg_slots: 1, + ret_slots: 1, + }, + SyscallDecl { + module: "composer".into(), + name: "set_camera".into(), + version: 1, + arg_slots: 2, + ret_slots: 0, + }, + SyscallDecl { + module: "composer".into(), + name: "emit_sprite".into(), + version: 1, + arg_slots: 9, + ret_slots: 1, + }, + ], + ); + let cartridge = cartridge_with_program(program, caps::GFX); + + 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)); + hardware.gfx.scene_fade_level = 31; + hardware.gfx.hud_fade_level = 31; + + runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); + let report = runtime.tick(&mut vm, &signals, &mut hardware); + + assert!(report.is_none(), "public composer path must not crash"); + assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(ComposerOpStatus::Ok as i64)]); + hardware.gfx.present(); + assert_eq!(hardware.gfx.front_buffer()[0], Color::BLUE.raw()); +} + +#[test] +fn tick_draw_text_survives_scene_backed_frame_composition() { + let mut runtime = VirtualMachineRuntime::new(None); + let mut vm = VirtualMachine::default(); + let signals = InputSignals::default(); + let code = assemble( + "PUSH_I32 0\nHOSTCALL 0\nPOP_N 1\n\ + PUSH_I32 0\nPUSH_I32 0\nHOSTCALL 1\n\ + PUSH_I32 0\nPUSH_I32 0\nPUSH_CONST 0\nPUSH_I32 65535\nHOSTCALL 2\n\ + FRAME_SYNC\nHALT", + ) + .expect("assemble"); + let program = serialized_single_function_module_with_consts( + code, + vec![ConstantPoolEntry::String("I".into())], + vec![ + SyscallDecl { + module: "composer".into(), + name: "bind_scene".into(), + version: 1, + arg_slots: 1, + ret_slots: 1, + }, + SyscallDecl { + module: "composer".into(), + name: "set_camera".into(), + version: 1, + arg_slots: 2, + ret_slots: 0, + }, + SyscallDecl { + module: "gfx".into(), + name: "draw_text".into(), + version: 1, + arg_slots: 4, + ret_slots: 0, + }, + ], + ); + let cartridge = cartridge_with_program(program, caps::GFX); + + 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)); + hardware.gfx.scene_fade_level = 31; + hardware.gfx.hud_fade_level = 31; + + runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); + let report = runtime.tick(&mut vm, &signals, &mut hardware); + + assert!(report.is_none(), "scene-backed overlay text must not crash"); + hardware.gfx.present(); + assert_eq!(hardware.gfx.front_buffer()[0], Color::WHITE.raw()); +} + +#[test] +fn tick_draw_text_survives_no_scene_frame_path() { + let mut runtime = VirtualMachineRuntime::new(None); + let mut vm = VirtualMachine::default(); + let signals = InputSignals::default(); + let code = assemble( + "PUSH_I32 0\nPUSH_I32 0\nPUSH_CONST 0\nPUSH_I32 65535\nHOSTCALL 0\nFRAME_SYNC\nHALT", + ) + .expect("assemble"); + let program = serialized_single_function_module_with_consts( + code, + vec![ConstantPoolEntry::String("I".into())], + vec![SyscallDecl { + module: "gfx".into(), + name: "draw_text".into(), + version: 1, + arg_slots: 4, + ret_slots: 0, + }], + ); + let cartridge = cartridge_with_program(program, caps::GFX); + let mut hardware = Hardware::new(); + hardware.gfx.scene_fade_level = 31; + hardware.gfx.hud_fade_level = 31; + + runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); + let report = runtime.tick(&mut vm, &signals, &mut hardware); + + assert!(report.is_none(), "no-scene overlay text must not crash"); + hardware.gfx.present(); + assert_eq!(hardware.gfx.front_buffer()[0], Color::WHITE.raw()); +} + #[test] fn initialize_vm_success_clears_previous_crash_report() { let mut runtime = VirtualMachineRuntime::new(None); @@ -364,22 +574,19 @@ fn initialize_vm_failure_clears_previous_identity_and_handles() { } #[test] -fn tick_gfx_set_sprite_operational_error_returns_status_not_crash() { +fn tick_composer_bind_scene_operational_error_returns_status_not_crash() { let mut runtime = VirtualMachineRuntime::new(None); let mut vm = VirtualMachine::default(); let mut hardware = Hardware::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 1\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT", - ) - .expect("assemble"); + let code = assemble("PUSH_I32 99\nHOSTCALL 0\nHALT").expect("assemble"); let program = serialized_single_function_module( code, vec![SyscallDecl { - module: "gfx".into(), - name: "set_sprite".into(), + module: "composer".into(), + name: "bind_scene".into(), version: 1, - arg_slots: 10, + arg_slots: 1, ret_slots: 1, }], ); @@ -389,26 +596,29 @@ fn tick_gfx_set_sprite_operational_error_returns_status_not_crash() { let report = runtime.tick(&mut vm, &signals, &mut hardware); assert!(report.is_none(), "operational error must not crash"); assert!(vm.is_halted()); - assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(GfxOpStatus::BankInvalid as i64)]); + assert_eq!( + vm.operand_stack_top(1), + vec![Value::Int64(ComposerOpStatus::SceneUnavailable as i64)] + ); } #[test] -fn tick_gfx_set_sprite_invalid_index_returns_status_not_crash() { +fn tick_composer_emit_sprite_operational_error_returns_status_not_crash() { let mut runtime = VirtualMachineRuntime::new(None); let mut vm = VirtualMachine::default(); let mut hardware = Hardware::new(); let signals = InputSignals::default(); let code = assemble( - "PUSH_I32 0\nPUSH_I32 512\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 1\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT", + "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", ) .expect("assemble"); let program = serialized_single_function_module( code, vec![SyscallDecl { - module: "gfx".into(), - name: "set_sprite".into(), + module: "composer".into(), + name: "emit_sprite".into(), version: 1, - arg_slots: 10, + arg_slots: 9, ret_slots: 1, }], ); @@ -416,28 +626,57 @@ fn tick_gfx_set_sprite_invalid_index_returns_status_not_crash() { runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); let report = runtime.tick(&mut vm, &signals, &mut hardware); - assert!(report.is_none(), "invalid sprite index must not crash"); + assert!(report.is_none(), "operational error must not crash"); assert!(vm.is_halted()); - assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(GfxOpStatus::InvalidSpriteIndex as i64)]); + assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(ComposerOpStatus::BankInvalid as i64)]); } #[test] -fn tick_gfx_set_sprite_invalid_range_returns_status_not_crash() { +fn tick_composer_emit_sprite_invalid_layer_returns_status_not_crash() { let mut runtime = VirtualMachineRuntime::new(None); let mut vm = VirtualMachine::default(); let mut hardware = Hardware::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 64\nPUSH_BOOL 1\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT", + "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", ) .expect("assemble"); let program = serialized_single_function_module( code, vec![SyscallDecl { - module: "gfx".into(), - name: "set_sprite".into(), + module: "composer".into(), + name: "emit_sprite".into(), version: 1, - arg_slots: 10, + arg_slots: 9, + ret_slots: 1, + }], + ); + let cartridge = cartridge_with_program(program, caps::GFX); + + runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); + let report = runtime.tick(&mut vm, &signals, &mut hardware); + assert!(report.is_none(), "invalid layer must not crash"); + assert!(vm.is_halted()); + assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(ComposerOpStatus::LayerInvalid as i64)]); +} + +#[test] +fn tick_composer_emit_sprite_invalid_range_returns_status_not_crash() { + let mut runtime = VirtualMachineRuntime::new(None); + let mut vm = VirtualMachine::default(); + let mut hardware = Hardware::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", + ) + .expect("assemble"); + let program = serialized_single_function_module( + code, + vec![SyscallDecl { + module: "composer".into(), + name: "emit_sprite".into(), + version: 1, + arg_slots: 9, ret_slots: 1, }], ); @@ -452,9 +691,12 @@ fn tick_gfx_set_sprite_invalid_range_returns_status_not_crash() { runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); let report = runtime.tick(&mut vm, &signals, &mut hardware); - assert!(report.is_none(), "invalid gfx parameter range must not crash"); + assert!(report.is_none(), "invalid composer parameter range must not crash"); assert!(vm.is_halted()); - assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(GfxOpStatus::ArgRangeInvalid as i64)]); + assert_eq!( + vm.operand_stack_top(1), + vec![Value::Int64(ComposerOpStatus::ArgRangeInvalid as i64)] + ); } #[test] @@ -816,13 +1058,13 @@ fn tick_asset_cancel_invalid_transition_returns_status_not_crash() { } #[test] -fn tick_status_first_surface_smoke_across_gfx_audio_and_asset() { +fn tick_status_first_surface_smoke_across_composer_audio_and_asset() { let mut runtime = VirtualMachineRuntime::new(None); let mut vm = VirtualMachine::default(); let mut hardware = Hardware::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 1\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\n\ + "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\ PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 255\nPUSH_I32 128\nPUSH_I32 1\nPUSH_I32 0\nHOSTCALL 1\n\ PUSH_I32 999\nPUSH_I32 0\nHOSTCALL 2\n\ HALT" @@ -832,10 +1074,10 @@ fn tick_status_first_surface_smoke_across_gfx_audio_and_asset() { code, vec![ SyscallDecl { - module: "gfx".into(), - name: "set_sprite".into(), + module: "composer".into(), + name: "emit_sprite".into(), version: 1, - arg_slots: 10, + arg_slots: 9, ret_slots: 1, }, SyscallDecl { @@ -866,28 +1108,28 @@ fn tick_status_first_surface_smoke_across_gfx_audio_and_asset() { Value::Int64(0), Value::Int64(AssetLoadError::AssetNotFound as i64), Value::Int64(AudioOpStatus::BankInvalid as i64), - Value::Int64(GfxOpStatus::BankInvalid as i64), + Value::Int64(ComposerOpStatus::BankInvalid as i64), ] ); } #[test] -fn tick_gfx_set_sprite_type_mismatch_surfaces_trap_not_panic() { +fn tick_composer_emit_sprite_type_mismatch_surfaces_trap_not_panic() { let mut runtime = VirtualMachineRuntime::new(None); let mut vm = VirtualMachine::default(); let mut hardware = Hardware::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 1\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT", + "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", ) .expect("assemble"); let program = serialized_single_function_module( code, vec![SyscallDecl { - module: "gfx".into(), - name: "set_sprite".into(), + module: "composer".into(), + name: "emit_sprite".into(), version: 1, - arg_slots: 10, + arg_slots: 9, ret_slots: 1, }], ); diff --git a/crates/console/prometeu-system/src/virtual_machine_runtime/tick.rs b/crates/console/prometeu-system/src/virtual_machine_runtime/tick.rs index ba58892b..4a281f66 100644 --- a/crates/console/prometeu-system/src/virtual_machine_runtime/tick.rs +++ b/crates/console/prometeu-system/src/virtual_machine_runtime/tick.rs @@ -145,7 +145,7 @@ impl VirtualMachineRuntime { if run.reason == LogicalFrameEndingReason::FrameSync || run.reason == LogicalFrameEndingReason::EndOfRom { - hw.gfx_mut().render_all(); + hw.render_frame(); // 1. Snapshot full telemetry at logical frame end let (glyph_bank, sound_bank) = Self::bank_telemetry_summary(hw); @@ -250,6 +250,7 @@ impl VirtualMachineRuntime { _signals: &InputSignals, hw: &mut dyn HardwareBridge, ) { + hw.begin_frame(); hw.audio_mut().clear_commands(); self.logs_written_this_frame.clear(); } diff --git a/crates/console/prometeu-vm/src/virtual_machine.rs b/crates/console/prometeu-vm/src/virtual_machine.rs index 52bbb659..cef12bfa 100644 --- a/crates/console/prometeu-vm/src/virtual_machine.rs +++ b/crates/console/prometeu-vm/src/virtual_machine.rs @@ -2567,11 +2567,8 @@ mod tests { #[test] fn test_status_first_syscall_results_count_mismatch_panic() { - // GfxSetSprite (0x1007) expects 1 result. - let code = assemble( - "PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nSYSCALL 0x1007", - ) - .expect("assemble"); + // ComposerBindScene (0x1101) expects 1 result. + let code = assemble("PUSH_I32 0\nSYSCALL 0x1101").expect("assemble"); struct BadNativeNoReturn; impl NativeInterface for BadNativeNoReturn { @@ -2921,10 +2918,24 @@ mod tests { fn test_loader_patching_accepts_status_first_signatures() { let cases = vec![ SyscallDecl { - module: "gfx".into(), - name: "set_sprite".into(), + module: "composer".into(), + name: "bind_scene".into(), version: 1, - arg_slots: 10, + arg_slots: 1, + ret_slots: 1, + }, + SyscallDecl { + module: "composer".into(), + name: "unbind_scene".into(), + version: 1, + arg_slots: 0, + ret_slots: 1, + }, + SyscallDecl { + module: "composer".into(), + name: "emit_sprite".into(), + version: 1, + arg_slots: 9, ret_slots: 1, }, SyscallDecl { @@ -2977,10 +2988,10 @@ mod tests { fn test_loader_patching_rejects_legacy_status_first_ret_slots() { let cases = vec![ SyscallDecl { - module: "gfx".into(), - name: "set_sprite".into(), + module: "composer".into(), + name: "bind_scene".into(), version: 1, - arg_slots: 10, + arg_slots: 1, ret_slots: 0, }, SyscallDecl { diff --git a/crates/tools/pbxgen-stress/Cargo.toml b/crates/tools/pbxgen-stress/Cargo.toml index b2ce9839..31da4f74 100644 --- a/crates/tools/pbxgen-stress/Cargo.toml +++ b/crates/tools/pbxgen-stress/Cargo.toml @@ -5,4 +5,6 @@ edition = "2021" [dependencies] prometeu-bytecode = { path = "../../console/prometeu-bytecode" } +prometeu-hal = { path = "../../console/prometeu-hal" } anyhow = "1" +serde_json = "1" diff --git a/crates/tools/pbxgen-stress/src/lib.rs b/crates/tools/pbxgen-stress/src/lib.rs index 79a42dfc..7f165b8c 100644 --- a/crates/tools/pbxgen-stress/src/lib.rs +++ b/crates/tools/pbxgen-stress/src/lib.rs @@ -3,7 +3,25 @@ use prometeu_bytecode::assembler::assemble; use prometeu_bytecode::model::{ BytecodeModule, ConstantPoolEntry, DebugInfo, Export, FunctionMeta, SyscallDecl, }; +use prometeu_hal::asset::{ + AssetCodec, AssetEntry, BankType, PreloadEntry, SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1, + SCENE_LAYER_COUNT_V1, SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1, +}; +use prometeu_hal::cartridge::{ + AssetsPackHeader, AssetsPackPrelude, ASSETS_PA_MAGIC, ASSETS_PA_PRELUDE_SIZE, + ASSETS_PA_SCHEMA_VERSION, +}; +use prometeu_hal::color::Color; +use prometeu_hal::glyph::Glyph; +use prometeu_hal::glyph_bank::{ + TileSize, GLYPH_BANK_COLORS_PER_PALETTE, GLYPH_BANK_PALETTE_COUNT_V1, +}; +use prometeu_hal::scene_bank::SceneBank; +use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer}; +use prometeu_hal::tile::Tile; +use prometeu_hal::tilemap::TileMap; use std::fs; +use std::mem::size_of; use std::path::PathBuf; fn asm(s: &str) -> Vec { @@ -20,13 +38,6 @@ pub fn generate() -> Result<()> { arg_slots: 1, ret_slots: 0, }, - SyscallDecl { - module: "gfx".into(), - name: "draw_disc".into(), - version: 1, - arg_slots: 5, - ret_slots: 0, - }, SyscallDecl { module: "gfx".into(), name: "draw_text".into(), @@ -42,10 +53,24 @@ pub fn generate() -> Result<()> { ret_slots: 0, }, SyscallDecl { - module: "gfx".into(), - name: "set_sprite".into(), + module: "composer".into(), + name: "bind_scene".into(), version: 1, - arg_slots: 10, + arg_slots: 1, + ret_slots: 1, + }, + SyscallDecl { + module: "composer".into(), + name: "set_camera".into(), + version: 1, + arg_slots: 2, + ret_slots: 0, + }, + SyscallDecl { + module: "composer".into(), + name: "emit_sprite".into(), + version: 1, + arg_slots: 9, ret_slots: 1, }, ]; @@ -59,7 +84,7 @@ pub fn generate() -> Result<()> { param_slots: 0, local_slots: 2, return_slots: 0, - max_stack_slots: 16, + max_stack_slots: 32, }]; let module = BytecodeModule { @@ -67,7 +92,8 @@ pub fn generate() -> Result<()> { const_pool: vec![ ConstantPoolEntry::String("stress".into()), ConstantPoolEntry::String("frame".into()), - ConstantPoolEntry::String("missing_glyph_bank".into()), + ConstantPoolEntry::String("overlay".into()), + ConstantPoolEntry::String("composer".into()), ], functions, code: rom, @@ -89,129 +115,332 @@ pub fn generate() -> Result<()> { out_dir.push("stress-console"); fs::create_dir_all(&out_dir)?; fs::write(out_dir.join("program.pbx"), bytes)?; - let assets_pa_path = out_dir.join("assets.pa"); - if assets_pa_path.exists() { - fs::remove_file(&assets_pa_path)?; - } - fs::write(out_dir.join("manifest.json"), b"{\n \"magic\": \"PMTU\",\n \"cartridge_version\": 1,\n \"app_id\": 1,\n \"title\": \"Stress Console\",\n \"app_version\": \"0.1.0\",\n \"app_mode\": \"Game\",\n \"capabilities\": [\"gfx\", \"log\"]\n}\n")?; + fs::write(out_dir.join("assets.pa"), build_assets_pack()?)?; + fs::write(out_dir.join("manifest.json"), b"{\n \"magic\": \"PMTU\",\n \"cartridge_version\": 1,\n \"app_id\": 1,\n \"title\": \"Stress Console\",\n \"app_version\": \"0.1.0\",\n \"app_mode\": \"Game\",\n \"capabilities\": [\"gfx\", \"log\", \"asset\"]\n}\n")?; Ok(()) } -#[allow(dead_code)] fn heavy_load(rom: &mut Vec) { // Single function 0: main - // Everything runs here — no coroutines, no SPAWN, no YIELD. - // - // Global 0 = t (frame counter) - // Local 0 = scratch - // Local 1 = loop counter for discs - // - // Loop: - // t = (t + 1) - // clear screen - // draw 500 discs using t for animation - // draw 20 texts using t for animation - // RET (runtime handles the frame loop) + // Global 0 = frame counter + // Global 1 = scene bound flag + // Local 0 = sprite row + // Local 1 = sprite col - // --- init locals --- - // local 0: scratch - // local 1: loop counter for discs - rom.extend(asm("PUSH_I32 0\nSET_LOCAL 0\nPUSH_I32 0\nSET_LOCAL 1")); - - // --- t = (t + 1) --- - // t is global 0 to persist across prepare_call resets rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 1\nADD\nSET_GLOBAL 0")); - // --- clear screen --- + rom.extend(asm("GET_GLOBAL 1\nPUSH_I32 0\nEQ")); + 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")); + let bind_done_target = rom.len() as u32; + rom.extend(asm("PUSH_I32 0\nHOSTCALL 0")); - // --- call status-first syscall path once per frame and drop status --- + rom.extend(asm( - "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_BOOL 0\nPUSH_I32 0\nHOSTCALL 4\nPOP_N 1", + "GET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 192\nMOD\nGET_GLOBAL 0\nPUSH_I32 76\nMOD\nHOSTCALL 4", )); - // --- draw 500 discs --- - rom.extend(asm("PUSH_I32 0\nSET_LOCAL 1")); - let disc_loop_start = rom.len() as u32; - rom.extend(asm("GET_LOCAL 1\nPUSH_I32 500\nLT")); - let jif_disc_end_offset = rom.len() + 2; + rom.extend(asm("PUSH_I32 0\nSET_LOCAL 0")); + let row_loop_start = rom.len() as u32; + rom.extend(asm("GET_LOCAL 0\nPUSH_I32 16\nLT")); + let jif_row_end_offset = rom.len() + 2; rom.extend(asm("JMP_IF_FALSE 0")); - // x = (t * (i+7) + i * 13) % 320 - rom.extend(asm("GET_GLOBAL 0\nGET_LOCAL 1\nPUSH_I32 7\nADD\nMUL\nGET_LOCAL 1\nPUSH_I32 13\nMUL\nADD\nPUSH_I32 320\nMOD")); - // y = (t * (i+11) + i * 17) % 180 - rom.extend(asm("GET_GLOBAL 0\nGET_LOCAL 1\nPUSH_I32 11\nADD\nMUL\nGET_LOCAL 1\nPUSH_I32 17\nMUL\nADD\nPUSH_I32 180\nMOD")); - // r = ( (i*13) % 20 ) + 5 - rom.extend(asm("GET_LOCAL 1\nPUSH_I32 13\nMUL\nPUSH_I32 20\nMOD\nPUSH_I32 5\nADD")); - // border color = (i * 1234) & 0xFFFF - rom.extend(asm("GET_LOCAL 1\nPUSH_I32 1234\nMUL\nPUSH_I32 65535\nBIT_AND")); - // fill color = (i * 5678 + t) & 0xFFFF - rom.extend(asm("GET_LOCAL 1\nPUSH_I32 5678\nMUL\nGET_GLOBAL 0\nADD\nPUSH_I32 65535\nBIT_AND")); - // HOSTCALL gfx.draw_disc (x, y, r, border, fill) - rom.extend(asm("HOSTCALL 1")); - - // i++ - rom.extend(asm("GET_LOCAL 1\nPUSH_I32 1\nADD\nSET_LOCAL 1")); - let jmp_disc_loop_offset = rom.len() + 2; - rom.extend(asm("JMP 0")); - let disc_loop_end = rom.len() as u32; - - // --- draw 20 texts --- rom.extend(asm("PUSH_I32 0\nSET_LOCAL 1")); - let text_loop_start = rom.len() as u32; - rom.extend(asm("GET_LOCAL 1\nPUSH_I32 20\nLT")); - let jif_text_end_offset = rom.len() + 2; + let col_loop_start = rom.len() as u32; + rom.extend(asm("GET_LOCAL 1\nPUSH_I32 32\nLT")); + let jif_col_end_offset = rom.len() + 2; rom.extend(asm("JMP_IF_FALSE 0")); - // x = (t * 3 + i * 40) % 320 rom.extend(asm( - "GET_GLOBAL 0\nPUSH_I32 3\nMUL\nGET_LOCAL 1\nPUSH_I32 40\nMUL\nADD\nPUSH_I32 320\nMOD", + "PUSH_I32 0\n\ + GET_LOCAL 0\nPUSH_I32 32\nMUL\nGET_LOCAL 1\nADD\nGET_GLOBAL 0\nADD\nPUSH_I32 15\nMOD\nPUSH_I32 1\nADD\n\ + GET_LOCAL 1\nPUSH_I32 10\nMUL\nGET_GLOBAL 0\nPUSH_I32 10\nMOD\nADD\nPUSH_I32 320\nMOD\n\ + GET_LOCAL 0\nPUSH_I32 10\nMUL\nGET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 10\nMOD\nADD\nPUSH_I32 180\nMOD\n\ + GET_LOCAL 0\nGET_LOCAL 1\nADD\nGET_GLOBAL 0\nADD\nPUSH_I32 4\nMOD\n\ + PUSH_I32 0\n\ + 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", )); - // y = (i * 30 + t) % 180 - rom.extend(asm("GET_LOCAL 1\nPUSH_I32 30\nMUL\nGET_GLOBAL 0\nADD\nPUSH_I32 180\nMOD")); - // string (toggle between "stress" and "frame") - rom.extend(asm("GET_LOCAL 1\nPUSH_I32 1\nBIT_AND\nPUSH_I32 0\nNEQ")); - let jif_text_alt_offset = rom.len() + 2; - rom.extend(asm("JMP_IF_FALSE 0")); - rom.extend(asm("PUSH_CONST 0")); // "stress" - let jmp_text_join_offset = rom.len() + 2; - rom.extend(asm("JMP 0")); - let text_alt_target = rom.len() as u32; - rom.extend(asm("PUSH_CONST 1")); // "frame" - let text_join_target = rom.len() as u32; - // color = (t * 10 + i * 1000) & 0xFFFF - rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 10\nMUL\nGET_LOCAL 1\nPUSH_I32 1000\nMUL\nADD\nPUSH_I32 65535\nBIT_AND")); - // HOSTCALL gfx.draw_text (x, y, str, color) - rom.extend(asm("HOSTCALL 2")); - - // i++ rom.extend(asm("GET_LOCAL 1\nPUSH_I32 1\nADD\nSET_LOCAL 1")); - let jmp_text_loop_offset = rom.len() + 2; + let jmp_col_loop_offset = rom.len() + 2; rom.extend(asm("JMP 0")); - let text_loop_end = rom.len() as u32; + let col_loop_end = rom.len() as u32; + + rom.extend(asm("GET_LOCAL 0\nPUSH_I32 1\nADD\nSET_LOCAL 0")); + let jmp_row_loop_offset = rom.len() + 2; + rom.extend(asm("JMP 0")); + let row_loop_end = rom.len() as u32; + + rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 3\nMUL\nPUSH_I32 220\nMOD\n\ + PUSH_I32 8\n\ + PUSH_CONST 0\n\ + GET_GLOBAL 0\nPUSH_I32 2047\nMUL\nPUSH_I32 65535\nBIT_AND\n\ + HOSTCALL 1")); + rom.extend(asm("PUSH_I32 12\n\ + GET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 120\nMOD\nPUSH_I32 24\nADD\n\ + PUSH_CONST 1\n\ + GET_GLOBAL 0\nPUSH_I32 4093\nMUL\nPUSH_I32 65535\nBIT_AND\n\ + HOSTCALL 1")); + rom.extend(asm("PUSH_I32 220\n\ + GET_GLOBAL 0\nPUSH_I32 5\nMUL\nPUSH_I32 140\nMOD\n\ + PUSH_CONST 2\n\ + GET_GLOBAL 0\nPUSH_I32 1237\nMUL\nPUSH_I32 65535\nBIT_AND\n\ + HOSTCALL 1")); + rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 4\nMUL\nPUSH_I32 180\nMOD\nPUSH_I32 80\nADD\n\ + GET_GLOBAL 0\nPUSH_I32 3\nMUL\nPUSH_I32 90\nMOD\nPUSH_I32 70\nADD\n\ + PUSH_CONST 3\n\ + GET_GLOBAL 0\nPUSH_I32 3001\nMUL\nPUSH_I32 65535\nBIT_AND\n\ + HOSTCALL 1")); - // --- log every 60 frames --- 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 3")); + rom.extend(asm("PUSH_I32 2\nPUSH_CONST 0\nHOSTCALL 2")); let after_log = rom.len() as u32; - // --- end of function --- rom.extend(asm("FRAME_SYNC\nRET")); - // --- Patch jump targets --- let patch = |buf: &mut Vec, imm_offset: usize, target: u32| { buf[imm_offset..imm_offset + 4].copy_from_slice(&target.to_le_bytes()); }; - patch(rom, jif_disc_end_offset, disc_loop_end); - patch(rom, jmp_disc_loop_offset, disc_loop_start); - - patch(rom, jif_text_end_offset, text_loop_end); - patch(rom, jif_text_alt_offset, text_alt_target); - patch(rom, jmp_text_join_offset, text_join_target); - patch(rom, jmp_text_loop_offset, text_loop_start); - + patch(rom, jif_bind_done_offset, bind_done_target); + patch(rom, jif_row_end_offset, row_loop_end); + patch(rom, jif_col_end_offset, col_loop_end); + patch(rom, jmp_col_loop_offset, col_loop_start); + patch(rom, jmp_row_loop_offset, row_loop_start); patch(rom, jif_log_offset, after_log); } + +fn build_assets_pack() -> Result> { + let (glyph_entry, glyph_payload) = build_glyph_asset(); + let scene = build_scene_bank(); + let scene_payload = encode_scene_payload(&scene); + let scene_entry = AssetEntry { + asset_id: 1, + asset_name: "stress_scene".into(), + bank_type: BankType::SCENE, + offset: glyph_payload.len() as u64, + size: scene_payload.len() as u64, + decoded_size: expected_scene_decoded_size(&scene) as u64, + codec: AssetCodec::None, + metadata: serde_json::json!({}), + }; + + let asset_table = vec![glyph_entry, scene_entry]; + let preload = + vec![PreloadEntry { asset_id: 0, slot: 0 }, PreloadEntry { asset_id: 1, slot: 0 }]; + let payload_len = glyph_payload.len() + scene_payload.len(); + let header = serde_json::to_vec(&AssetsPackHeader { asset_table, preload })?; + let payload_offset = (ASSETS_PA_PRELUDE_SIZE + header.len()) as u64; + let prelude = AssetsPackPrelude { + magic: ASSETS_PA_MAGIC, + schema_version: ASSETS_PA_SCHEMA_VERSION, + header_len: header.len() as u32, + payload_offset, + flags: 0, + reserved: 0, + header_checksum: 0, + }; + + let mut bytes = prelude.to_bytes().to_vec(); + bytes.extend_from_slice(&header); + bytes.extend_from_slice(&glyph_payload); + bytes.extend_from_slice(&scene_payload); + debug_assert_eq!(bytes.len(), payload_offset as usize + payload_len); + Ok(bytes) +} + +fn build_glyph_asset() -> (AssetEntry, Vec) { + let pixel_indices = vec![1_u8; 8 * 8]; + let mut payload = pack_4bpp(&pixel_indices); + payload.extend_from_slice(&build_palette_bytes()); + + let entry = AssetEntry { + asset_id: 0, + asset_name: "stress_square".into(), + bank_type: BankType::GLYPH, + offset: 0, + size: payload.len() as u64, + decoded_size: (8 * 8 + GLYPH_BANK_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * 2) + as u64, + codec: AssetCodec::None, + metadata: serde_json::json!({ + "tile_size": 8, + "width": 8, + "height": 8, + "palette_count": GLYPH_BANK_PALETTE_COUNT_V1, + "palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 + }), + }; + + (entry, payload) +} + +fn build_palette_bytes() -> Vec { + let mut bytes = + Vec::with_capacity(GLYPH_BANK_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * 2); + for palette_id in 0..GLYPH_BANK_PALETTE_COUNT_V1 { + for color_index in 0..GLYPH_BANK_COLORS_PER_PALETTE { + let color = if color_index == 1 { stress_color(palette_id) } else { Color::BLACK }; + bytes.extend_from_slice(&color.raw().to_le_bytes()); + } + } + bytes +} + +fn stress_color(palette_id: usize) -> Color { + let r = ((palette_id * 53) % 256) as u8; + let g = ((palette_id * 97 + 64) % 256) as u8; + let b = ((palette_id * 29 + 128) % 256) as u8; + Color::rgb(r, g, b) +} + +fn pack_4bpp(indices: &[u8]) -> Vec { + let mut packed = Vec::with_capacity(indices.len().div_ceil(2)); + for chunk in indices.chunks(2) { + let hi = chunk[0] & 0x0f; + let lo = chunk.get(1).copied().unwrap_or(0) & 0x0f; + packed.push((hi << 4) | lo); + } + packed +} + +fn build_scene_bank() -> SceneBank { + let mut layers = std::array::from_fn(|layer_index| { + let mut tiles = vec![ + Tile { + active: false, + glyph: Glyph { glyph_id: 0, palette_id: layer_index as u8 + 1 }, + flip_x: false, + flip_y: false, + }; + 64 * 32 + ]; + + for step in 0..8 { + let x = 4 + step * 7 + layer_index * 2; + let y = 2 + step * 3 + layer_index * 2; + let index = y * 64 + x; + tiles[index] = Tile { + active: true, + glyph: Glyph { glyph_id: 0, palette_id: layer_index as u8 + 1 }, + flip_x: false, + flip_y: false, + }; + } + + SceneLayer { + active: true, + glyph_bank_id: 0, + tile_size: TileSize::Size8, + parallax_factor: match layer_index { + 0 => ParallaxFactor { x: 1.0, y: 1.0 }, + 1 => ParallaxFactor { x: 0.75, y: 0.75 }, + 2 => ParallaxFactor { x: 0.5, y: 0.5 }, + _ => ParallaxFactor { x: 0.25, y: 0.25 }, + }, + tilemap: TileMap { width: 64, height: 32, tiles }, + } + }); + + // Keep the farthest layer a bit sparser so the diagonal remains visually readable. + for step in 0..4 { + let x = 10 + step * 12; + let y = 4 + step * 5; + let index = y * 64 + x; + layers[3].tilemap.tiles[index].active = false; + } + + SceneBank { layers } +} + +fn expected_scene_decoded_size(scene: &SceneBank) -> usize { + scene + .layers + .iter() + .map(|layer| { + SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1 + layer.tilemap.tiles.len() * size_of::() + }) + .sum() +} + +fn encode_scene_payload(scene: &SceneBank) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(&SCENE_PAYLOAD_MAGIC_V1); + data.extend_from_slice(&SCENE_PAYLOAD_VERSION_V1.to_le_bytes()); + data.extend_from_slice(&(SCENE_LAYER_COUNT_V1 as u16).to_le_bytes()); + data.extend_from_slice(&0_u32.to_le_bytes()); + + for layer in &scene.layers { + let layer_flags = if layer.active { 0b0000_0001 } else { 0 }; + data.push(layer_flags); + data.push(layer.glyph_bank_id); + data.push(layer.tile_size as u8); + data.push(0); + data.extend_from_slice(&layer.parallax_factor.x.to_le_bytes()); + data.extend_from_slice(&layer.parallax_factor.y.to_le_bytes()); + data.extend_from_slice(&(layer.tilemap.width as u32).to_le_bytes()); + data.extend_from_slice(&(layer.tilemap.height as u32).to_le_bytes()); + data.extend_from_slice(&(layer.tilemap.tiles.len() as u32).to_le_bytes()); + data.extend_from_slice(&0_u32.to_le_bytes()); + + for tile in &layer.tilemap.tiles { + let mut tile_flags = 0_u8; + if tile.active { + tile_flags |= 0b0000_0001; + } + if tile.flip_x { + tile_flags |= 0b0000_0010; + } + if tile.flip_y { + tile_flags |= 0b0000_0100; + } + data.push(tile_flags); + data.push(tile.glyph.palette_id); + data.extend_from_slice(&tile.glyph.glyph_id.to_le_bytes()); + } + } + + data +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn assets_pack_contains_preloaded_glyph_and_scene_assets() { + let bytes = build_assets_pack().expect("assets pack"); + let prelude = + AssetsPackPrelude::from_bytes(&bytes[..ASSETS_PA_PRELUDE_SIZE]).expect("prelude"); + assert_eq!(prelude.magic, ASSETS_PA_MAGIC); + assert_eq!(prelude.schema_version, ASSETS_PA_SCHEMA_VERSION); + + let header_start = ASSETS_PA_PRELUDE_SIZE; + let header_end = header_start + prelude.header_len as usize; + let header: AssetsPackHeader = + serde_json::from_slice(&bytes[header_start..header_end]).expect("header"); + + assert_eq!(header.asset_table.len(), 2); + assert_eq!(header.preload.len(), 2); + assert_eq!(header.asset_table[0].bank_type, BankType::GLYPH); + assert_eq!(header.asset_table[1].bank_type, BankType::SCENE); + assert_eq!(header.preload[0].slot, 0); + assert_eq!(header.preload[1].slot, 0); + assert_eq!(header.asset_table[0].offset, 0); + assert_eq!(header.asset_table[1].offset, header.asset_table[0].size); + assert_eq!( + bytes.len(), + prelude.payload_offset as usize + + header.asset_table[0].size as usize + + header.asset_table[1].size as usize + ); + } +} diff --git a/discussion/.backups/index.ndjson.20260418-162736.bak b/discussion/.backups/index.ndjson.20260418-162736.bak new file mode 100644 index 00000000..68838273 --- /dev/null +++ b/discussion/.backups/index.ndjson.20260418-162736.bak @@ -0,0 +1,29 @@ +{"type":"meta","next_id":{"DSC":29,"AGD":29,"DEC":17,"PLN":30,"LSN":31,"CLSN":1}} +{"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":"workflow/agendas/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":"workflow/decisions/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":"workflow/plans/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":"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":"workflow/agendas/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":"workflow/decisions/DEC-0003-jenkins-gitea-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0003","file":"workflow/plans/PLN-0003-jenkins-gitea-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0021","file":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0018-mental-model-touch.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]} +{"type":"discussion","id":"DSC-0002","status":"open","ticket":"runtime-edge-test-plan","title":"Agenda - Runtime Edge Test Plan","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0001","file":"workflow/agendas/AGD-0001-runtime-edge-test-plan.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"type":"discussion","id":"DSC-0003","status":"open","ticket":"packed-cartridge-loader-pmc","title":"Agenda - Packed Cartridge Loader PMC","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0002","file":"workflow/agendas/AGD-0002-packed-cartridge-loader-pmc.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"type":"discussion","id":"DSC-0004","status":"open","ticket":"system-run-cart","title":"Agenda - System Run Cart","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0003","file":"workflow/agendas/AGD-0003-system-run-cart.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"type":"discussion","id":"DSC-0005","status":"open","ticket":"system-fault-semantics-and-control-surface","title":"Agenda - System Fault Semantics and Control Surface","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0004","file":"workflow/agendas/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":"workflow/agendas/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":"workflow/agendas/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":"workflow/agendas/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":"workflow/decisions/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":"workflow/plans/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":"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":"workflow/agendas/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":"open","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-03-27","tags":[],"agendas":[{"id":"AGD-0009","file":"workflow/agendas/AGD-0009-perf-host-desktop-frame-pacing-and-presentation.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"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":"workflow/agendas/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":[]} +{"type":"discussion","id":"DSC-0012","status":"open","ticket":"perf-runtime-introspection-syscalls","title":"Agenda - [PERF] Runtime Introspection Syscalls","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0011","file":"workflow/agendas/AGD-0011-perf-runtime-introspection-syscalls.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"type":"discussion","id":"DSC-0013","status":"done","ticket":"perf-host-debug-overlay-isolation","title":"Agenda - [PERF] Host Debug Overlay Isolation","created_at":"2026-03-27","updated_at":"2026-04-10","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"lessons/DSC-0013-perf-host-debug-overlay-isolation/LSN-0027-host-debug-overlay-isolation.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} +{"type":"discussion","id":"DSC-0024","status":"done","ticket":"generic-memory-bank-slot-contract","title":"Agenda - Generic Memory Bank Slot Contract","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["runtime","asset","memory-bank","slots","host"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0029","file":"lessons/DSC-0024-generic-memory-bank-slot-contract/LSN-0029-slot-first-bank-telemetry-belongs-in-asset-manager.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} +{"type":"discussion","id":"DSC-0025","status":"done","ticket":"scene-bank-and-viewport-cache-refactor","title":"Scene Bank and Viewport Cache Refactor","created_at":"2026-04-11","updated_at":"2026-04-14","tags":["gfx","tilemap","runtime","render"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0030","file":"lessons/DSC-0025-scene-bank-and-viewport-cache-refactor/LSN-0030-canonical-scene-cache-and-resolver-split.md","status":"done","created_at":"2026-04-14","updated_at":"2026-04-14"}]} +{"type":"discussion","id":"DSC-0026","status":"open","ticket":"render-all-scene-cache-and-camera-integration","title":"Integrate render_all with Scene Cache and Camera","created_at":"2026-04-14","updated_at":"2026-04-15","tags":["gfx","runtime","render","camera","scene"],"agendas":[{"id":"AGD-0026","file":"workflow/agendas/AGD-0026-render-all-scene-cache-and-camera-integration.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15"}],"decisions":[{"id":"DEC-0014","file":"workflow/decisions/DEC-0014-frame-composer-render-integration.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15","ref_agenda":"AGD-0026"}],"plans":[{"id":"PLN-0017","file":"workflow/plans/PLN-0017-frame-composer-core-and-hardware-ownership.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0014"]},{"id":"PLN-0018","file":"workflow/plans/PLN-0018-sprite-controller-and-frame-emission-model.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-14","ref_decisions":["DEC-0014"]},{"id":"PLN-0019","file":"workflow/plans/PLN-0019-scene-binding-camera-and-scene-status.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0014"]},{"id":"PLN-0020","file":"workflow/plans/PLN-0020-cache-refresh-and-render-frame-path.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0014"]},{"id":"PLN-0021","file":"workflow/plans/PLN-0021-service-retirement-callsite-migration-and-regression.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0014"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0027","status":"open","ticket":"frame-composer-public-syscall-surface","title":"Agenda - FrameComposer Public Syscall Surface","created_at":"2026-04-17","updated_at":"2026-04-17","tags":["gfx","runtime","syscall","abi","frame-composer","scene","camera","sprites"],"agendas":[{"id":"AGD-0027","file":"workflow/agendas/AGD-0027-frame-composer-public-syscall-surface.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17"}],"decisions":[{"id":"DEC-0015","file":"workflow/decisions/DEC-0015-frame-composer-public-syscall-surface.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_agenda":"AGD-0027"}],"plans":[{"id":"PLN-0022","file":"workflow/plans/PLN-0022-composer-syscall-domain-and-spec-propagation.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0023","file":"workflow/plans/PLN-0023-composer-runtime-dispatch-and-legacy-removal.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0024","file":"workflow/plans/PLN-0024-composer-cartridge-tooling-and-regression-migration.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0025","file":"workflow/plans/PLN-0025-final-ci-validation-and-polish.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0028","status":"open","ticket":"deferred-overlay-and-primitive-composition","title":"Deferred Overlay and Primitive Composition over FrameComposer","created_at":"2026-04-18","updated_at":"2026-04-18","tags":["gfx","runtime","render","frame-composer","overlay","primitives","hud"],"agendas":[{"id":"AGD-0028","file":"workflow/agendas/AGD-0028-deferred-overlay-and-primitive-composition.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18"}],"decisions":[{"id":"DEC-0016","file":"workflow/decisions/DEC-0016-deferred-gfx-overlay-outside-frame-composer.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_agenda":"AGD-0028"}],"plans":[{"id":"PLN-0026","file":"workflow/plans/PLN-0026-gfx-overlay-contract-and-spec-propagation.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0027","file":"workflow/plans/PLN-0027-deferred-gfx-overlay-subsystem.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0028","file":"workflow/plans/PLN-0028-runtime-frame-end-overlay-integration-and-parity.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0029","file":"workflow/plans/PLN-0029-final-overlay-ci-validation-and-polish.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0014","status":"open","ticket":"perf-vm-allocation-and-copy-pressure","title":"Agenda - [PERF] VM Allocation and Copy Pressure","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0013","file":"workflow/agendas/AGD-0013-perf-vm-allocation-and-copy-pressure.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"type":"discussion","id":"DSC-0015","status":"open","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0014","file":"workflow/agendas/AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"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":"workflow/agendas/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":"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":"workflow/agendas/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":"workflow/decisions/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":"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":"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":"workflow/agendas/AGD-0017-jenkinsfile-correction.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0002","file":"workflow/decisions/DEC-0002-jenkinsfile-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0002","file":"workflow/plans/PLN-0002-jenkinsfile-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0020","file":"lessons/DSC-0019-jenkins-ci-standardization/LSN-0020-jenkins-standard-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]} diff --git a/discussion/.backups/index.ndjson.20260418-162925.bak b/discussion/.backups/index.ndjson.20260418-162925.bak new file mode 100644 index 00000000..9ca7cece --- /dev/null +++ b/discussion/.backups/index.ndjson.20260418-162925.bak @@ -0,0 +1,29 @@ +{"type":"meta","next_id":{"DSC":29,"AGD":29,"DEC":17,"PLN":30,"LSN":32,"CLSN":1}} +{"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":"workflow/agendas/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":"workflow/decisions/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":"workflow/plans/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":"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":"workflow/agendas/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":"workflow/decisions/DEC-0003-jenkins-gitea-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0003","file":"workflow/plans/PLN-0003-jenkins-gitea-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0021","file":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0018-mental-model-touch.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]} +{"type":"discussion","id":"DSC-0002","status":"open","ticket":"runtime-edge-test-plan","title":"Agenda - Runtime Edge Test Plan","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0001","file":"workflow/agendas/AGD-0001-runtime-edge-test-plan.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"type":"discussion","id":"DSC-0003","status":"open","ticket":"packed-cartridge-loader-pmc","title":"Agenda - Packed Cartridge Loader PMC","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0002","file":"workflow/agendas/AGD-0002-packed-cartridge-loader-pmc.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"type":"discussion","id":"DSC-0004","status":"open","ticket":"system-run-cart","title":"Agenda - System Run Cart","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0003","file":"workflow/agendas/AGD-0003-system-run-cart.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"type":"discussion","id":"DSC-0005","status":"open","ticket":"system-fault-semantics-and-control-surface","title":"Agenda - System Fault Semantics and Control Surface","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0004","file":"workflow/agendas/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":"workflow/agendas/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":"workflow/agendas/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":"workflow/agendas/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":"workflow/decisions/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":"workflow/plans/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":"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":"workflow/agendas/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":"open","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-03-27","tags":[],"agendas":[{"id":"AGD-0009","file":"workflow/agendas/AGD-0009-perf-host-desktop-frame-pacing-and-presentation.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"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":"workflow/agendas/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":[]} +{"type":"discussion","id":"DSC-0012","status":"open","ticket":"perf-runtime-introspection-syscalls","title":"Agenda - [PERF] Runtime Introspection Syscalls","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0011","file":"workflow/agendas/AGD-0011-perf-runtime-introspection-syscalls.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"type":"discussion","id":"DSC-0013","status":"done","ticket":"perf-host-debug-overlay-isolation","title":"Agenda - [PERF] Host Debug Overlay Isolation","created_at":"2026-03-27","updated_at":"2026-04-10","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"lessons/DSC-0013-perf-host-debug-overlay-isolation/LSN-0027-host-debug-overlay-isolation.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} +{"type":"discussion","id":"DSC-0024","status":"done","ticket":"generic-memory-bank-slot-contract","title":"Agenda - Generic Memory Bank Slot Contract","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["runtime","asset","memory-bank","slots","host"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0029","file":"lessons/DSC-0024-generic-memory-bank-slot-contract/LSN-0029-slot-first-bank-telemetry-belongs-in-asset-manager.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} +{"type":"discussion","id":"DSC-0025","status":"done","ticket":"scene-bank-and-viewport-cache-refactor","title":"Scene Bank and Viewport Cache Refactor","created_at":"2026-04-11","updated_at":"2026-04-14","tags":["gfx","tilemap","runtime","render"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0030","file":"lessons/DSC-0025-scene-bank-and-viewport-cache-refactor/LSN-0030-canonical-scene-cache-and-resolver-split.md","status":"done","created_at":"2026-04-14","updated_at":"2026-04-14"}]} +{"type":"discussion","id":"DSC-0026","status":"done","ticket":"render-all-scene-cache-and-camera-integration","title":"Integrate render_all with Scene Cache and Camera","created_at":"2026-04-14","updated_at":"2026-04-18","tags":["gfx","runtime","render","camera","scene"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0031","file":"lessons/DSC-0026-render-all-scene-cache-and-camera-integration/LSN-0031-frame-composition-belongs-above-the-render-backend.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]} +{"type":"discussion","id":"DSC-0027","status":"open","ticket":"frame-composer-public-syscall-surface","title":"Agenda - FrameComposer Public Syscall Surface","created_at":"2026-04-17","updated_at":"2026-04-17","tags":["gfx","runtime","syscall","abi","frame-composer","scene","camera","sprites"],"agendas":[{"id":"AGD-0027","file":"workflow/agendas/AGD-0027-frame-composer-public-syscall-surface.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17"}],"decisions":[{"id":"DEC-0015","file":"workflow/decisions/DEC-0015-frame-composer-public-syscall-surface.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_agenda":"AGD-0027"}],"plans":[{"id":"PLN-0022","file":"workflow/plans/PLN-0022-composer-syscall-domain-and-spec-propagation.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0023","file":"workflow/plans/PLN-0023-composer-runtime-dispatch-and-legacy-removal.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0024","file":"workflow/plans/PLN-0024-composer-cartridge-tooling-and-regression-migration.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0025","file":"workflow/plans/PLN-0025-final-ci-validation-and-polish.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0028","status":"open","ticket":"deferred-overlay-and-primitive-composition","title":"Deferred Overlay and Primitive Composition over FrameComposer","created_at":"2026-04-18","updated_at":"2026-04-18","tags":["gfx","runtime","render","frame-composer","overlay","primitives","hud"],"agendas":[{"id":"AGD-0028","file":"workflow/agendas/AGD-0028-deferred-overlay-and-primitive-composition.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18"}],"decisions":[{"id":"DEC-0016","file":"workflow/decisions/DEC-0016-deferred-gfx-overlay-outside-frame-composer.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_agenda":"AGD-0028"}],"plans":[{"id":"PLN-0026","file":"workflow/plans/PLN-0026-gfx-overlay-contract-and-spec-propagation.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0027","file":"workflow/plans/PLN-0027-deferred-gfx-overlay-subsystem.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0028","file":"workflow/plans/PLN-0028-runtime-frame-end-overlay-integration-and-parity.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0029","file":"workflow/plans/PLN-0029-final-overlay-ci-validation-and-polish.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0014","status":"open","ticket":"perf-vm-allocation-and-copy-pressure","title":"Agenda - [PERF] VM Allocation and Copy Pressure","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0013","file":"workflow/agendas/AGD-0013-perf-vm-allocation-and-copy-pressure.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"type":"discussion","id":"DSC-0015","status":"open","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0014","file":"workflow/agendas/AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} +{"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":"workflow/agendas/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":"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":"workflow/agendas/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":"workflow/decisions/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":"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":"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":"workflow/agendas/AGD-0017-jenkinsfile-correction.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0002","file":"workflow/decisions/DEC-0002-jenkinsfile-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0002","file":"workflow/plans/PLN-0002-jenkinsfile-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0020","file":"lessons/DSC-0019-jenkins-ci-standardization/LSN-0020-jenkins-standard-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]} diff --git a/discussion/index.ndjson b/discussion/index.ndjson index e80e3372..6c1c7a0d 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,4 +1,4 @@ -{"type":"meta","next_id":{"DSC":26,"AGD":26,"DEC":14,"PLN":17,"LSN":31,"CLSN":1}} +{"type":"meta","next_id":{"DSC":29,"AGD":29,"DEC":17,"PLN":30,"LSN":34,"CLSN":1}} {"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":"workflow/agendas/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":"workflow/decisions/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":"workflow/plans/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":"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":"workflow/agendas/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":"workflow/decisions/DEC-0003-jenkins-gitea-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0003","file":"workflow/plans/PLN-0003-jenkins-gitea-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0021","file":"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":"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"}]} @@ -18,6 +18,9 @@ {"type":"discussion","id":"DSC-0013","status":"done","ticket":"perf-host-debug-overlay-isolation","title":"Agenda - [PERF] Host Debug Overlay Isolation","created_at":"2026-03-27","updated_at":"2026-04-10","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"lessons/DSC-0013-perf-host-debug-overlay-isolation/LSN-0027-host-debug-overlay-isolation.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} {"type":"discussion","id":"DSC-0024","status":"done","ticket":"generic-memory-bank-slot-contract","title":"Agenda - Generic Memory Bank Slot Contract","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["runtime","asset","memory-bank","slots","host"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0029","file":"lessons/DSC-0024-generic-memory-bank-slot-contract/LSN-0029-slot-first-bank-telemetry-belongs-in-asset-manager.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} {"type":"discussion","id":"DSC-0025","status":"done","ticket":"scene-bank-and-viewport-cache-refactor","title":"Scene Bank and Viewport Cache Refactor","created_at":"2026-04-11","updated_at":"2026-04-14","tags":["gfx","tilemap","runtime","render"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0030","file":"lessons/DSC-0025-scene-bank-and-viewport-cache-refactor/LSN-0030-canonical-scene-cache-and-resolver-split.md","status":"done","created_at":"2026-04-14","updated_at":"2026-04-14"}]} +{"type":"discussion","id":"DSC-0026","status":"done","ticket":"render-all-scene-cache-and-camera-integration","title":"Integrate render_all with Scene Cache and Camera","created_at":"2026-04-14","updated_at":"2026-04-18","tags":["gfx","runtime","render","camera","scene"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0031","file":"lessons/DSC-0026-render-all-scene-cache-and-camera-integration/LSN-0031-frame-composition-belongs-above-the-render-backend.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]} +{"type":"discussion","id":"DSC-0027","status":"done","ticket":"frame-composer-public-syscall-surface","title":"Agenda - FrameComposer Public Syscall Surface","created_at":"2026-04-17","updated_at":"2026-04-18","tags":["gfx","runtime","syscall","abi","frame-composer","scene","camera","sprites"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0032","file":"lessons/DSC-0027-frame-composer-public-syscall-surface/LSN-0032-public-abi-must-follow-the-canonical-service-boundary.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]} +{"type":"discussion","id":"DSC-0028","status":"done","ticket":"deferred-overlay-and-primitive-composition","title":"Deferred Overlay and Primitive Composition over FrameComposer","created_at":"2026-04-18","updated_at":"2026-04-18","tags":["gfx","runtime","render","frame-composer","overlay","primitives","hud"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0033","file":"lessons/DSC-0028-deferred-overlay-and-primitive-composition/LSN-0033-debug-primitives-should-be-a-final-overlay-not-part-of-game-composition.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]} {"type":"discussion","id":"DSC-0014","status":"open","ticket":"perf-vm-allocation-and-copy-pressure","title":"Agenda - [PERF] VM Allocation and Copy Pressure","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0013","file":"workflow/agendas/AGD-0013-perf-vm-allocation-and-copy-pressure.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0015","status":"open","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0014","file":"workflow/agendas/AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} {"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":"workflow/agendas/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":"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"}]} diff --git a/discussion/lessons/DSC-0026-render-all-scene-cache-and-camera-integration/LSN-0031-frame-composition-belongs-above-the-render-backend.md b/discussion/lessons/DSC-0026-render-all-scene-cache-and-camera-integration/LSN-0031-frame-composition-belongs-above-the-render-backend.md new file mode 100644 index 00000000..ace30888 --- /dev/null +++ b/discussion/lessons/DSC-0026-render-all-scene-cache-and-camera-integration/LSN-0031-frame-composition-belongs-above-the-render-backend.md @@ -0,0 +1,74 @@ +--- +id: LSN-0031 +ticket: render-all-scene-cache-and-camera-integration +title: Frame Composition Belongs Above the Render Backend +created: 2026-04-18 +tags: [gfx, runtime, render, camera, scene, sprites, frame-composer] +--- + +## Context + +`DSC-0025` split canonical scene ownership from viewport caching and resolver policy, but the runtime still treated `Gfx.render_all()` as the operational frame entrypoint. That left scene binding, camera state, cache refresh, and sprite submission spread across the wrong layer. + +`DSC-0026` completed the integration by making `FrameComposer` the owner of frame orchestration and reducing `Gfx` to a backend that consumes already prepared render state. + +## Key Decisions + +### Frame Orchestration Must Not Live in the Backend + +**What:** +`FrameComposer.render_frame()` became the canonical frame service, while `Gfx.render_all()` was retired from the runtime-facing flow. + +**Why:** +The backend should execute composition, not decide scene binding, camera policy, cache refresh, or sprite lifecycle. Keeping those responsibilities above `Gfx` preserves a cleaner ownership model and avoids re-entangling policy with raster code. + +**Trade-offs:** +This adds an explicit orchestration layer between runtime callsites and the renderer, but the resulting boundaries are easier to evolve and test. + +### Scene Binding, Camera, Cache, and Sprites Form One Operational Unit + +**What:** +`FrameComposer` owns: +- active scene binding by bank id and shared scene reference; +- camera coordinates in top-left world pixel space; +- `SceneViewportCache` and `SceneViewportResolver`; +- a frame-emission `SpriteController`. + +**Why:** +These concerns all define what a frame is. Splitting them across multiple owners would recreate stale-state bugs and make no-scene behavior ambiguous. + +**Trade-offs:** +The composer becomes a richer subsystem, but it carries policy in one place instead of leaking it into unrelated APIs. + +### The World Path Must Stay Tile-Size Agnostic + +**What:** +The integrated frame path derives cache sizing, resolver math, and world-copy behavior from per-layer scene metadata instead of a hard-coded `16x16` assumption. + +**Why:** +The scene contract already allows canonical `8x8`, `16x16`, and `32x32` tile sizes. The frame service has to consume that contract faithfully or it becomes a hidden compatibility break. + +**Trade-offs:** +The integration and tests need to exercise more than the legacy default path, but the renderer no longer bakes in a false invariant. + +## Patterns and Algorithms + +- Put frame policy in a dedicated orchestration layer and keep the renderer backend-oriented. +- Treat scene binding, camera state, cache lifetime, and sprite submission as one cohesive frame model. +- Refresh cache state inside the orchestrator before composition instead of letting the renderer discover refresh policy. +- Prefer frame-emission sprite submission with internal ordering over caller-owned sprite slots. +- Keep the no-scene path valid so world composition remains optional, not mandatory. + +## Pitfalls + +- Leaving `render_all()` alive as a canonical path creates a fragile dual-service model. +- Letting `Gfx` own cache refresh semantics collapses the boundary between policy and execution. +- Requiring a scene for every frame quietly breaks sprite-only or fade-only operation. +- Testing only `16x16` scenes hides regressions against valid `8x8` or `32x32` content. + +## Takeaways + +- Frame composition belongs in a subsystem that owns policy, not in the backend that draws pixels. +- Scene binding, camera, cache, resolver, and sprite submission should converge under one frame owner. +- No-scene rendering is part of the contract and should stay valid throughout integration work. +- Tile-size assumptions must be derived from canonical scene metadata, never from renderer habit. diff --git a/discussion/lessons/DSC-0027-frame-composer-public-syscall-surface/LSN-0032-public-abi-must-follow-the-canonical-service-boundary.md b/discussion/lessons/DSC-0027-frame-composer-public-syscall-surface/LSN-0032-public-abi-must-follow-the-canonical-service-boundary.md new file mode 100644 index 00000000..c0012645 --- /dev/null +++ b/discussion/lessons/DSC-0027-frame-composer-public-syscall-surface/LSN-0032-public-abi-must-follow-the-canonical-service-boundary.md @@ -0,0 +1,56 @@ +--- +id: LSN-0032 +ticket: frame-composer-public-syscall-surface +title: Public ABI Must Follow the Canonical Service Boundary +created: 2026-04-18 +tags: [gfx, runtime, syscall, abi, frame-composer, scene, camera, sprites] +--- + +## Context + +`DSC-0026` finished the internal migration to `FrameComposer` as the canonical frame-orchestration owner, but the public VM-facing ABI still exposed part of that behavior through legacy `gfx`-domain calls. That left the codebase with a mismatch between the real runtime ownership model and the surface visible to cartridges, tooling, and syscall declarations. + +`DSC-0027` closed that gap by introducing the `composer.*` public domain and removing the old public sprite path. + +## Key Decisions + +### Public Syscalls Must Expose the Real Owner + +**What:** +Scene binding, camera control, and sprite emission now live under `composer.*`, and the legacy public `gfx.set_sprite` path is gone. + +**Why:** +Once `FrameComposer` became the canonical orchestration service, keeping public orchestration under `gfx.*` would preserve the wrong mental model and encourage callers to treat the render backend as the owner of frame policy. + +**Trade-offs:** +This forces migration across ABI declarations, runtime dispatch, tests, and tooling, but it removes the long-term cost of a misleading public boundary. + +### Domain-Specific Status Types Preserve Architectural Meaning + +**What:** +Mutating public composer operations return `ComposerOpStatus` instead of reusing backend-oriented status naming. + +**Why:** +Operational outcomes for scene binding or sprite emission are not backend-domain results. Reusing `GfxOpStatus` would blur the boundary that the migration was trying to make explicit. + +**Trade-offs:** +This adds one more status family to maintain, but it keeps the public ABI semantically aligned with the actual service contract. + +## Patterns and Algorithms + +- Promote internal ownership changes into the public ABI as part of the same migration thread. +- Use syscall domains to encode service boundaries, not just namespace aesthetics. +- Remove obsolete public fallbacks completely when they preserve the wrong operational model. +- Keep runtime dispatch, bytecode declarations, and tooling aligned so the public path is exercised end to end. + +## Pitfalls + +- Leaving a legacy public syscall alive after the internal model changes creates a dual-contract system that is harder to remove later. +- Migrating runtime dispatch without migrating declarations and tooling can leave hidden ABI drift in tests and generators. +- Reusing backend-specific status names in the wrong domain quietly leaks old ownership assumptions into new APIs. + +## Takeaways + +- The public ABI should mirror the canonical service boundary, not historical implementation leftovers. +- Namespace changes are architectural when they change who is responsible for a behavior. +- Removing a legacy public entrypoint is often safer than preserving a compatibility shim that encodes the wrong model. diff --git a/discussion/lessons/DSC-0028-deferred-overlay-and-primitive-composition/LSN-0033-debug-primitives-should-be-a-final-overlay-not-part-of-game-composition.md b/discussion/lessons/DSC-0028-deferred-overlay-and-primitive-composition/LSN-0033-debug-primitives-should-be-a-final-overlay-not-part-of-game-composition.md new file mode 100644 index 00000000..76a16cc1 --- /dev/null +++ b/discussion/lessons/DSC-0028-deferred-overlay-and-primitive-composition/LSN-0033-debug-primitives-should-be-a-final-overlay-not-part-of-game-composition.md @@ -0,0 +1,56 @@ +--- +id: LSN-0033 +ticket: deferred-overlay-and-primitive-composition +title: Debug Primitives Should Be a Final Overlay, Not Part of Game Composition +created: 2026-04-18 +tags: [gfx, runtime, render, frame-composer, overlay, primitives, hud, debug] +--- + +## Context + +After `FrameComposer.render_frame()` became the canonical game-frame entrypoint, immediate `gfx.*` primitive writes were no longer stable. Scene-backed composition could rebuild the framebuffer after `draw_text(...)` or other debug primitives had already written to it. + +`DSC-0028` resolved that conflict by moving `gfx.*` primitives into a deferred overlay/debug stage outside `FrameComposer`, drained only after canonical game composition and fades are complete. + +## Key Decisions + +### Debug Overlay Must Stay Outside the Canonical Game Pipeline + +**What:** +`FrameComposer` keeps ownership of canonical game composition, while debug/text/primitive commands are captured separately and drained later as a final overlay. + +**Why:** +Game composition and debug overlay have different purposes. The first must remain canonical and deterministic; the second must remain opportunistic, screen-space, and independent from scene or sprite semantics. + +**Trade-offs:** +The renderer needs a second deferred path, but the game pipeline no longer depends on transient debug state. + +### Final Visual Ordering Matters More Than Immediate Writes + +**What:** +Overlay/debug commands are drained after scene composition, sprite composition, and fades, with parity between scene-bound and no-scene frame paths. + +**Why:** +The stable user-visible contract is that debug primitives appear on top. Immediate writes were only an implementation detail, and they stopped preserving that contract once frame composition became deferred and canonical. + +**Trade-offs:** +This changes primitive semantics from "write now" to "show at frame end," but it produces the behavior users actually rely on. + +## Patterns and Algorithms + +- Separate canonical composition state from debug-overlay state even when both reuse the same raster backend. +- Capture primitives as commands first, then drain them at the final stage where visual priority is unambiguous. +- Preserve the same overlay semantics whether a scene is bound or not. +- Keep implementation reuse internal while maintaining a clear semantic boundary in the public model. + +## Pitfalls + +- Treating debug primitives as part of HUD or scene composition will eventually couple tooling/debug behavior to gameplay pipeline rules. +- Draining overlay before fades or before final frame composition breaks the visible "always on top" contract. +- Reusing `FrameComposer` storage for overlay state collapses the ownership split that prevents these bugs. + +## Takeaways + +- Immediate framebuffer writes are not a reliable contract once final composition is orchestrated elsewhere. +- Debug primitives work best as a dedicated final overlay layer. +- Ownership separation is what keeps debug behavior stable while the canonical render pipeline evolves. diff --git a/docs/specs/runtime/04-gfx-peripheral.md b/docs/specs/runtime/04-gfx-peripheral.md index 518744f2..8b63b63a 100644 --- a/docs/specs/runtime/04-gfx-peripheral.md +++ b/docs/specs/runtime/04-gfx-peripheral.md @@ -48,10 +48,12 @@ The GFX maintains two buffers: Per-frame flow: -1. The system draws to the back buffer -2. Calls `present()` -3. Buffers are swapped -4. The host displays the front buffer +1. The system prepares the logical frame +2. Canonical game composition is rendered into the back buffer +3. Deferred final overlay/debug primitives are drained on top of the completed game frame +4. Calls `present()` +5. Buffers are swapped +6. The host displays the front buffer This guarantees: @@ -183,7 +185,7 @@ Access: --- -## 10. Projection to the Back Buffer +## 10. Canonical Game Projection to the Back Buffer For each frame: @@ -198,6 +200,10 @@ For each frame: 3. Draw HUD layer last +This section describes only the canonical game composition path. + +`gfx.*` primitives such as `draw_text`, `draw_line`, and `draw_disc` are not part of this canonical game projection order. In v1 they belong to a deferred final overlay/debug stage that is drained after canonical game composition is complete. + --- ## 11. Drawing Order and Priority @@ -214,6 +220,15 @@ Base order: 4. Tile Layer 3 5. Sprites (by priority between layers) 6. HUD Layer +7. Scene Fade +8. HUD Fade +9. Deferred `gfx.*` overlay/debug primitives + +Normative boundary: + +- Items 1 through 8 belong to canonical game-frame composition. +- Item 9 is a separate overlay/debug stage. +- Deferred `gfx.*` primitives MUST NOT be interpreted as scene, sprite, or canonical HUD content. --- @@ -258,8 +273,9 @@ Everything is: ## 14. Where Blend is Applied - Blending occurs during drawing -- The result goes directly to the back buffer -- There is no automatic post-composition +- For canonical game composition, the result goes to the back buffer during composition +- For deferred `gfx.*` overlay/debug primitives, the result is applied during the final overlay/debug drain stage +- There is no automatic GPU-style post-processing pipeline --- @@ -296,6 +312,8 @@ controls: - **Scene Fade**: affects the entire scene (Tile Layers 0–3 + Sprites) - **HUD Fade**: affects only the HUD Layer (always composed last) +In v1, deferred `gfx.*` overlay/debug primitives are drained after both fades and therefore are not themselves part of scene or HUD fade application. + The fade is implemented without continuous per-pixel alpha and without floats. It uses a **discrete integer level** (0..31), which in practice produces an "almost continuous" visual result in 320×180 pixel art. @@ -536,7 +554,12 @@ The system can measure: ## 19. Syscall Return and Fault Policy -`gfx` follows status-first policy for operations with operational failure modes. +Graphics-related public ABI in v1 is split between: + +- `gfx.*` for direct drawing/backend-oriented operations; +- `composer.*` for frame orchestration operations. + +Only operations with real operational rejection paths return explicit status values. Fault boundary: @@ -544,50 +567,62 @@ Fault boundary: - `status`: operational failure; - `Panic`: internal runtime invariant break only. -### 19.1 `gfx.set_sprite` +### 19.1 Return-shape matrix in v1 -Return-shape matrix in v1: +| Syscall | Return | Policy basis | +| ----------------------- | ------------- | --------------------------------------------------- | +| `gfx.clear` | `void` | no real operational failure path in v1 | +| `gfx.fill_rect` | `void` | no real operational failure path in v1 | +| `gfx.draw_line` | `void` | no real operational failure path in v1 | +| `gfx.draw_circle` | `void` | no real operational failure path in v1 | +| `gfx.draw_disc` | `void` | no real operational failure path in v1 | +| `gfx.draw_square` | `void` | no real operational failure path in v1 | +| `gfx.draw_text` | `void` | no real operational failure path in v1 | +| `gfx.clear_565` | `void` | no real operational failure path in v1 | +| `composer.bind_scene` | `status:int` | explicit orchestration-domain operational result | +| `composer.unbind_scene` | `status:int` | explicit orchestration-domain operational result | +| `composer.set_camera` | `void` | no real operational failure path in v1 | +| `composer.emit_sprite` | `status:int` | explicit orchestration-domain operational rejection | -| Syscall | Return | Policy basis | -| ------------------ | ------------- | ---------------------------------------------------- | -| `gfx.clear` | `void` | no real operational failure path in v1 | -| `gfx.fill_rect` | `void` | no real operational failure path in v1 | -| `gfx.draw_line` | `void` | no real operational failure path in v1 | -| `gfx.draw_circle` | `void` | no real operational failure path in v1 | -| `gfx.draw_disc` | `void` | no real operational failure path in v1 | -| `gfx.draw_square` | `void` | no real operational failure path in v1 | -| `gfx.set_sprite` | `status:int` | operational rejection must be explicit | -| `gfx.draw_text` | `void` | no real operational failure path in v1 | -| `gfx.clear_565` | `void` | no real operational failure path in v1 | +### 19.1.a Deferred overlay/debug semantics for `gfx.*` -Only `gfx.set_sprite` is status-returning in v1. -All other `gfx` syscalls remain `void` unless a future domain revision introduces a real operational failure path. +The public `gfx.*` primitive family remains valid in v1, but its stable operational meaning is: -### 19.2 `gfx.set_sprite` +- deferred final overlay/debug composition; +- screen-space and pipeline-agnostic relative to `composer.*`; +- outside `FrameComposer`; +- above scene, sprites, and canonical HUD; +- drained after `hud_fade`. -`gfx.set_sprite` returns `status:int`. +This means callers MUST NOT rely on stable immediate writes to the working back buffer as the public contract for `gfx.draw_text(...)` or sibling primitives. + +### 19.2 `composer.emit_sprite` + +`composer.emit_sprite` returns `status:int`. ABI: -1. `bank_id: int` — index of the tile bank -2. `index: int` — sprite index (0..511) +1. `glyph_id: int` — glyph index within the bank +2. `palette_id: int` — palette index 3. `x: int` — x coordinate 4. `y: int` — y coordinate -5. `tile_id: int` — tile index within the bank -6. `palette_id: int` — palette index (0..63) -7. `active: bool` — visibility toggle -8. `flip_x: bool` — horizontal flip -9. `flip_y: bool` — vertical flip -10. `priority: int` — layer priority (0..4) +5. `layer: int` — composition layer reference +6. `bank_id: int` — glyph bank index +7. `flip_x: bool` — horizontal flip +8. `flip_y: bool` — vertical flip +9. `priority: int` — within-layer ordering priority Minimum status table: - `0` = `OK` -- `2` = `INVALID_SPRITE_INDEX` -- `3` = `INVALID_ARG_RANGE` -- `4` = `BANK_INVALID` +- `1` = `SCENE_UNAVAILABLE` +- `2` = `INVALID_ARG_RANGE` +- `3` = `BANK_INVALID` +- `4` = `LAYER_INVALID` +- `5` = `SPRITE_OVERFLOW` Operational notes: -- no fallback to default bank when the sprite bank id cannot be resolved; -- no silent no-op for invalid index/range; -- `palette_id` and `priority` must be validated against runtime-supported ranges. +- the canonical public sprite contract is frame-emission based; +- no caller-provided sprite index exists in the v1 canonical ABI; +- no `active` flag exists in the v1 canonical ABI; +- overflow remains non-fatal and must not escalate to trap in v1. diff --git a/docs/specs/runtime/16-host-abi-and-syscalls.md b/docs/specs/runtime/16-host-abi-and-syscalls.md index 7ca43033..0e2b4c1b 100644 --- a/docs/specs/runtime/16-host-abi-and-syscalls.md +++ b/docs/specs/runtime/16-host-abi-and-syscalls.md @@ -39,6 +39,7 @@ Example: ``` ("gfx", "present", 1) ("audio", "play", 2) +("composer", "emit_sprite", 1) ``` This identity is: @@ -198,6 +199,24 @@ For `asset.load`: - `slot` is the target slot index; - bank kind is resolved from `asset_table` by `asset_id`, not supplied by the caller. +### Composition surface (`composer`, v1) + +The canonical frame-orchestration public ABI uses module `composer`. + +Canonical operations in v1 are: + +- `composer.bind_scene(bank_id) -> (status)` +- `composer.unbind_scene() -> (status)` +- `composer.set_camera(x, y) -> void` +- `composer.emit_sprite(glyph_id, palette_id, x, y, layer, bank_id, flip_x, flip_y, priority) -> (status)` + +For mutating composer operations: + +- `status` is a `ComposerOpStatus` value; +- `bind_scene`, `unbind_scene`, and `emit_sprite` are status-returning; +- `set_camera` remains `void` in v1; +- no caller-provided sprite index or `active` flag is part of the canonical contract. + ## 7 Syscalls as Callable Entities (Not First-Class) Syscalls behave like call sites, not like first-class guest values. diff --git a/docs/vm-arch/ISA_CORE.md b/docs/vm-arch/ISA_CORE.md index f2cea12d..d6f704d5 100644 --- a/docs/vm-arch/ISA_CORE.md +++ b/docs/vm-arch/ISA_CORE.md @@ -85,6 +85,9 @@ Example: - `asset.load` currently resolves with `arg_slots = 2` and `ret_slots = 2`. - The canonical stack contract is `asset_id, slot -> status, handle`. - Callers do not provide an explicit asset kind; the runtime derives it from `asset_table`. +- `composer.bind_scene` resolves with `arg_slots = 1` and `ret_slots = 1`. +- The canonical stack contract is `bank_id -> status`. +- `composer.emit_sprite` resolves with `arg_slots = 9` and `ret_slots = 1`. #### Canonical Intrinsic Registry Artifact diff --git a/test-cartridges/stress-console/assets.pa b/test-cartridges/stress-console/assets.pa new file mode 100644 index 00000000..77c82efc Binary files /dev/null and b/test-cartridges/stress-console/assets.pa differ diff --git a/test-cartridges/stress-console/manifest.json b/test-cartridges/stress-console/manifest.json index d3d34606..6d77693e 100644 --- a/test-cartridges/stress-console/manifest.json +++ b/test-cartridges/stress-console/manifest.json @@ -5,5 +5,5 @@ "title": "Stress Console", "app_version": "0.1.0", "app_mode": "Game", - "capabilities": ["gfx", "log"] + "capabilities": ["gfx", "log", "asset"] } diff --git a/test-cartridges/stress-console/program.pbx b/test-cartridges/stress-console/program.pbx index 83b01e19..5f278669 100644 Binary files a/test-cartridges/stress-console/program.pbx and b/test-cartridges/stress-console/program.pbx differ