diff --git a/crates/console/prometeu-drivers/src/asset.rs b/crates/console/prometeu-drivers/src/asset.rs index 99d33cf1..6e67a941 100644 --- a/crates/console/prometeu-drivers/src/asset.rs +++ b/crates/console/prometeu-drivers/src/asset.rs @@ -32,6 +32,35 @@ type StagingMap = HashMap>; type AssetTable = HashMap; type HandleTable = HashMap; +#[derive(Clone, Default)] +pub struct GlyphAssetSlotIndex { + slots_by_asset_id: Arc>>, +} + +impl GlyphAssetSlotIndex { + pub fn new() -> Self { + Self { slots_by_asset_id: Arc::new(RwLock::new(HashMap::new())) } + } + + pub fn glyph_slot_for_asset(&self, asset_id: AssetId) -> Option { + self.slots_by_asset_id.read().unwrap().get(&asset_id).copied() + } + + pub fn rebuild_from_slots(&self, slots: &[Option; 16]) { + let mut map = self.slots_by_asset_id.write().unwrap(); + map.clear(); + for (slot, asset_id) in slots.iter().enumerate() { + if let Some(asset_id) = asset_id { + map.insert(*asset_id, slot); + } + } + } + + fn clear(&self) { + self.slots_by_asset_id.write().unwrap().clear(); + } +} + const GLYPH_BANK_PALETTE_COUNT_V1: usize = 64; const GLYPH_BANK_COLORS_PER_PALETTE: usize = 16; const GLYPH_BANK_PALETTE_BYTES_V1: usize = @@ -163,6 +192,7 @@ pub struct AssetManager { /// Track what is installed in each hardware slot (for stats/info). gfx_slots: Arc; 16]>>, + glyph_slot_index: GlyphAssetSlotIndex, sound_slots: Arc; 16]>>, scene_slots: Arc; 16]>>, @@ -309,6 +339,7 @@ impl AssetManager { sound_installer, scene_installer, gfx_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))), + glyph_slot_index: GlyphAssetSlotIndex::new(), sound_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))), scene_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))), gfx_policy: BankPolicy::new(), @@ -361,6 +392,7 @@ impl AssetManager { let mut slots = self.gfx_slots.write().unwrap(); if slot_index < slots.len() { slots[slot_index] = Some(entry.asset_id); + self.glyph_slot_index.rebuild_from_slots(&slots); } } } @@ -746,7 +778,7 @@ impl AssetManager { let mut decoded_size = 0_usize; let layers = std::array::from_fn(|_| SceneLayer { active: false, - glyph_bank_id: 0, + glyph_asset_id: 0, tile_size: TileSize::Size8, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, tilemap: TileMap { width: 0, height: 0, tiles: Vec::new() }, @@ -762,8 +794,13 @@ impl AssetManager { } let flags = buffer[offset]; - let glyph_bank_id = buffer[offset + 1]; - let tile_size_raw = buffer[offset + 2]; + let glyph_asset_id = i32::from_le_bytes([ + buffer[offset + 1], + buffer[offset + 2], + buffer[offset + 3], + buffer[offset + 4], + ]); + let tile_size_raw = buffer[offset + 5]; let tile_size = match tile_size_raw { 8 => TileSize::Size8, 16 => TileSize::Size16, @@ -771,39 +808,39 @@ impl AssetManager { other => return Err(format!("Invalid SCENE tile size: {}", other)), }; let parallax_factor_x = f32::from_le_bytes([ - buffer[offset + 4], - buffer[offset + 5], - buffer[offset + 6], - buffer[offset + 7], - ]); - let parallax_factor_y = f32::from_le_bytes([ buffer[offset + 8], buffer[offset + 9], buffer[offset + 10], buffer[offset + 11], ]); + let parallax_factor_y = f32::from_le_bytes([ + buffer[offset + 12], + buffer[offset + 13], + buffer[offset + 14], + buffer[offset + 15], + ]); 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([ - buffer[offset + 12], - buffer[offset + 13], - buffer[offset + 14], - buffer[offset + 15], - ]) as usize; - let height = u32::from_le_bytes([ buffer[offset + 16], buffer[offset + 17], buffer[offset + 18], buffer[offset + 19], ]) as usize; - let tile_count = u32::from_le_bytes([ + let height = u32::from_le_bytes([ buffer[offset + 20], buffer[offset + 21], buffer[offset + 22], buffer[offset + 23], ]) as usize; + let tile_count = u32::from_le_bytes([ + buffer[offset + 24], + buffer[offset + 25], + buffer[offset + 26], + buffer[offset + 27], + ]) as usize; let expected_tile_count = width.checked_mul(height).ok_or("SCENE tile count overflow")?; @@ -845,7 +882,7 @@ impl AssetManager { *layer = SceneLayer { active: (flags & 0b0000_0001) != 0, - glyph_bank_id, + glyph_asset_id, tile_size, parallax_factor: ParallaxFactor { x: parallax_factor_x, y: parallax_factor_y }, tilemap: TileMap { width, height, tiles }, @@ -960,6 +997,7 @@ impl AssetManager { let mut slots = self.gfx_slots.write().unwrap(); if h.slot.index < slots.len() { slots[h.slot.index] = Some(h._asset_id); + self.glyph_slot_index.rebuild_from_slots(&slots); } h.status = LoadStatus::COMMITTED; } @@ -1089,9 +1127,14 @@ impl AssetManager { self.handles.write().unwrap().clear(); self.pending_commits.lock().unwrap().clear(); self.gfx_slots.write().unwrap().fill(None); + self.glyph_slot_index.clear(); self.sound_slots.write().unwrap().fill(None); self.scene_slots.write().unwrap().fill(None); } + + pub fn glyph_asset_slot_index(&self) -> GlyphAssetSlotIndex { + self.glyph_slot_index.clone() + } } #[cfg(test)] @@ -1143,62 +1186,53 @@ mod tests { } fn test_scene() -> SceneBank { - let make_layer = - |glyph_bank_id: u8, parallax_x: f32, parallax_y: f32, tile_size: TileSize| SceneLayer { - active: glyph_bank_id != 3, - glyph_bank_id, - tile_size, - parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y }, - tilemap: TileMap { - width: 2, - height: 2, - tiles: vec![ - Tile { - active: true, - glyph: Glyph { - glyph_id: 10 + glyph_bank_id as u16, - palette_id: glyph_bank_id, - }, - flip_x: false, - flip_y: false, - }, - Tile { - active: true, - glyph: Glyph { - glyph_id: 20 + glyph_bank_id as u16, - palette_id: glyph_bank_id + 1, - }, - flip_x: true, - flip_y: false, - }, - Tile { - active: true, - glyph: Glyph { - glyph_id: 30 + glyph_bank_id as u16, - palette_id: glyph_bank_id + 2, - }, - flip_x: false, - flip_y: true, - }, - Tile { - active: glyph_bank_id != 2, - glyph: Glyph { - glyph_id: 40 + glyph_bank_id as u16, - palette_id: glyph_bank_id + 3, - }, - flip_x: true, - flip_y: true, - }, - ], - }, - }; + let make_layer = |glyph_asset_id: AssetId, + active: bool, + parallax_x: f32, + parallax_y: f32, + tile_size: TileSize| SceneLayer { + active, + glyph_asset_id, + tile_size, + parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y }, + tilemap: TileMap { + width: 2, + height: 2, + tiles: vec![ + Tile { + active: true, + glyph: Glyph { glyph_id: 10, palette_id: 1 }, + flip_x: false, + flip_y: false, + }, + Tile { + active: true, + glyph: Glyph { glyph_id: 20, palette_id: 2 }, + flip_x: true, + flip_y: false, + }, + Tile { + active: true, + glyph: Glyph { glyph_id: 30, palette_id: 3 }, + flip_x: false, + flip_y: true, + }, + Tile { + active, + glyph: Glyph { glyph_id: 40, palette_id: 4 }, + flip_x: true, + flip_y: true, + }, + ], + }, + }; SceneBank { layers: [ - make_layer(0, 1.0, 1.0, TileSize::Size16), - make_layer(1, 0.5, 0.75, TileSize::Size8), - make_layer(2, 1.0, 0.5, TileSize::Size32), - make_layer(3, 0.25, 0.25, TileSize::Size16), + make_layer(100, true, 1.0, 1.0, TileSize::Size16), + make_layer(101, true, 0.5, 0.75, TileSize::Size8), + make_layer(102, true, 1.0, 0.5, TileSize::Size32), + make_layer(103, false, 0.25, 0.25, TileSize::Size16), ], } } @@ -1224,9 +1258,9 @@ mod tests { 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.extend_from_slice(&layer.glyph_asset_id.to_le_bytes()); data.push(layer.tile_size as u8); - data.push(0); + data.extend_from_slice(&0_u16.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()); @@ -1358,12 +1392,12 @@ 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].glyph_asset_id, 101); 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); - assert_eq!(decoded.layers[3].active, false); + assert!(decoded.layers[0].tilemap.tiles[1].flip_x); + assert!(decoded.layers[2].tilemap.tiles[2].flip_y); + assert!(!decoded.layers[3].active); } #[test] @@ -1382,7 +1416,7 @@ mod tests { fn test_decode_scene_bank_rejects_invalid_tile_size() { let scene = test_scene(); let mut data = encode_scene_payload(&scene); - data[14] = 12; + data[17] = 12; let entry = test_scene_asset_entry("scene", &data, &scene); let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err(); @@ -1406,7 +1440,7 @@ mod tests { fn test_decode_scene_bank_rejects_tile_count_mismatch() { let scene = test_scene(); let mut data = encode_scene_payload(&scene); - data[32..36].copy_from_slice(&5_u32.to_le_bytes()); + data[36..40].copy_from_slice(&5_u32.to_le_bytes()); let entry = test_scene_asset_entry("scene", &data, &scene); let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err(); diff --git a/crates/console/prometeu-drivers/src/frame_composer.rs b/crates/console/prometeu-drivers/src/frame_composer.rs index 02a2f471..0520c16a 100644 --- a/crates/console/prometeu-drivers/src/frame_composer.rs +++ b/crates/console/prometeu-drivers/src/frame_composer.rs @@ -1,3 +1,4 @@ +use crate::asset::GlyphAssetSlotIndex; use crate::memory_banks::SceneBankPoolAccess; use prometeu_hal::GfxBridge; use prometeu_hal::glyph::Glyph; @@ -116,6 +117,7 @@ impl SpriteController { pub struct FrameComposer { scene_bank_pool: Arc, + glyph_slot_index: GlyphAssetSlotIndex, viewport_width_px: usize, viewport_height_px: usize, active_scene_id: Option, @@ -133,9 +135,11 @@ impl FrameComposer { viewport_width_px: usize, viewport_height_px: usize, scene_bank_pool: Arc, + glyph_slot_index: GlyphAssetSlotIndex, ) -> Self { Self { scene_bank_pool, + glyph_slot_index, viewport_width_px, viewport_height_px, active_scene_id: None, @@ -187,6 +191,8 @@ impl FrameComposer { return false; }; + let _ = self.resolve_glyph_slots(&scene, scene_bank_id, "bind_scene"); + let (cache, resolver) = Self::build_scene_runtime(self.viewport_width_px, self.viewport_height_px, &scene); self.active_scene_id = Some(scene_bank_id); @@ -242,15 +248,20 @@ impl FrameComposer { 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()) - { + if let Some(scene) = self.active_scene.as_deref() { + let scene_bank_id = self.active_scene_id.expect("active scene id must exist"); + let resolved_glyph_slots = + self.resolve_glyph_slots(scene, scene_bank_id, "render_frame"); + let (Some(cache), Some(resolver)) = (self.cache.as_mut(), self.resolver.as_mut()) + else { + panic!("SCENE runtime invariant broken: active scene without cache/resolver"); + }; 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); + gfx.render_scene_from_cache(cache, &update, &resolved_glyph_slots); return; } @@ -284,6 +295,23 @@ impl FrameComposer { ) } + fn resolve_glyph_slots( + &self, + scene: &SceneBank, + scene_bank_id: usize, + phase: &str, + ) -> [usize; 4] { + std::array::from_fn(|layer_index| { + let layer = &scene.layers[layer_index]; + self.glyph_slot_index.glyph_slot_for_asset(layer.glyph_asset_id).unwrap_or_else(|| { + panic!( + "SCENE dependency fatal during {}: scene_bank_id={}, layer={}, glyph_asset_id={}", + phase, scene_bank_id, layer_index, layer.glyph_asset_id + ) + }) + }) + } + fn apply_refresh_requests( cache: &mut SceneViewportCache, scene: &SceneBank, @@ -315,24 +343,26 @@ mod tests { use crate::memory_banks::{ GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller, }; + use prometeu_hal::asset::AssetId; 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; + use std::panic::{AssertUnwindSafe, catch_unwind}; fn make_scene() -> SceneBank { make_scene_with_palette(1, 1, TileSize::Size8) } fn make_scene_with_palette( - glyph_bank_id: u8, + glyph_asset_id: AssetId, palette_id: u8, tile_size: TileSize, ) -> SceneBank { let layer = SceneLayer { active: true, - glyph_bank_id, + glyph_asset_id, tile_size, parallax_factor: ParallaxFactor { x: 1.0, y: 0.5 }, tilemap: TileMap { @@ -353,6 +383,16 @@ mod tests { SceneBank { layers: std::array::from_fn(|_| layer.clone()) } } + fn make_glyph_slot_index(bindings: &[(AssetId, usize)]) -> GlyphAssetSlotIndex { + let index = GlyphAssetSlotIndex::new(); + let mut slots = [None; 16]; + for (asset_id, slot) in bindings { + slots[*slot] = Some(*asset_id); + } + index.rebuild_from_slots(&slots); + index + } + 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); @@ -365,7 +405,8 @@ mod tests { #[test] fn frame_composer_starts_unbound_with_empty_owned_state() { - let frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); + let frame_composer = + FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[])); assert_eq!(frame_composer.viewport_size(), (320, 180)); assert_eq!(frame_composer.active_scene_id(), None); @@ -384,7 +425,7 @@ mod tests { let banks = Arc::new(MemoryBanks::new()); banks.install_scene_bank(3, Arc::new(make_scene())); - let frame_composer = FrameComposer::new(320, 180, banks); + let frame_composer = FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)])); let scene = frame_composer.scene_bank_slot(3).expect("scene bank slot 3 should be resident"); @@ -400,7 +441,8 @@ mod tests { 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); + let mut frame_composer = + FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)])); assert!(frame_composer.bind_scene(3)); @@ -419,7 +461,8 @@ mod tests { let banks = Arc::new(MemoryBanks::new()); banks.install_scene_bank(1, Arc::new(make_scene())); - let mut frame_composer = FrameComposer::new(320, 180, banks); + let mut frame_composer = + FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)])); assert!(frame_composer.bind_scene(1)); frame_composer.unbind_scene(); @@ -433,7 +476,8 @@ mod tests { #[test] fn set_camera_stores_top_left_pixel_coordinates() { - let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); + let mut frame_composer = + FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[])); frame_composer.set_camera(-12, 48); @@ -445,7 +489,8 @@ mod tests { let banks = Arc::new(MemoryBanks::new()); banks.install_scene_bank(0, Arc::new(make_scene())); - let mut frame_composer = FrameComposer::new(320, 180, banks); + let mut frame_composer = + FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)])); assert!(frame_composer.bind_scene(0)); let cache = frame_composer.cache().expect("cache should exist for bound scene"); @@ -454,7 +499,8 @@ mod tests { #[test] fn missing_scene_binding_falls_back_to_no_scene_state() { - let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); + let mut frame_composer = + FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[])); assert!(!frame_composer.bind_scene(7)); @@ -463,6 +509,27 @@ mod tests { assert!(frame_composer.resolver().is_none()); } + #[test] + fn bind_scene_panics_when_scene_dependency_is_not_resident() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_scene_bank(0, Arc::new(make_scene())); + + let mut frame_composer = FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[])); + + let panic = catch_unwind(AssertUnwindSafe(|| frame_composer.bind_scene(0))) + .expect_err("bind_scene should panic when dependency is missing"); + let message = if let Some(message) = panic.downcast_ref::() { + message.clone() + } else if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_string() + } else { + String::new() + }; + + assert!(message.contains("SCENE dependency fatal during bind_scene")); + assert!(message.contains("glyph_asset_id=1")); + } + #[test] fn sprite_controller_begin_frame_resets_sprite_count_and_buckets() { let mut controller = SpriteController::new(); @@ -582,7 +649,8 @@ mod tests { #[test] fn frame_composer_emits_ordered_sprites_for_rendering() { - let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); + let mut frame_composer = + FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[])); frame_composer.begin_frame(); assert!(frame_composer.emit_sprite(Sprite { @@ -620,8 +688,12 @@ mod tests { 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); + let mut frame_composer = FrameComposer::new( + 16, + 16, + Arc::clone(&banks) as Arc, + make_glyph_slot_index(&[]), + ); frame_composer.begin_frame(); assert!(frame_composer.emit_sprite(Sprite { glyph: Glyph { glyph_id: 0, palette_id: 3 }, @@ -651,8 +723,12 @@ mod tests { 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); + let mut frame_composer = FrameComposer::new( + 16, + 16, + Arc::clone(&banks) as Arc, + make_glyph_slot_index(&[(0, 0)]), + ); assert!(frame_composer.bind_scene(0)); let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc); @@ -682,8 +758,12 @@ mod tests { 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 frame_composer = FrameComposer::new( + 16, + 16, + Arc::clone(&banks) as Arc, + make_glyph_slot_index(&[(0, 0), (1, 1)]), + ); let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc); gfx.scene_fade_level = 31; gfx.hud_fade_level = 31; @@ -716,4 +796,59 @@ mod tests { gfx.present(); assert_eq!(gfx.front_buffer()[0], Color::BLUE.raw()); } + + #[test] + fn render_frame_panics_when_dependency_disappears_after_bind() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_glyph_bank(0, Arc::new(make_glyph_bank(TileSize::Size8, 1, Color::RED))); + banks.install_scene_bank(0, Arc::new(make_scene_with_palette(1, 1, TileSize::Size8))); + + let glyph_slot_index = make_glyph_slot_index(&[(1, 0)]); + let mut frame_composer = FrameComposer::new( + 16, + 16, + Arc::clone(&banks) as Arc, + glyph_slot_index.clone(), + ); + assert!(frame_composer.bind_scene(0)); + + glyph_slot_index.rebuild_from_slots(&[None; 16]); + + let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc); + let panic = catch_unwind(AssertUnwindSafe(|| frame_composer.render_frame(&mut gfx))) + .expect_err("render_frame should panic when dependency disappears"); + let message = if let Some(message) = panic.downcast_ref::() { + message.clone() + } else if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_string() + } else { + String::new() + }; + + assert!(message.contains("SCENE dependency fatal during render_frame")); + assert!(message.contains("glyph_asset_id=1")); + } + + #[test] + fn render_frame_resolves_glyph_dependency_by_asset_id_not_by_slot_number() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_glyph_bank(7, Arc::new(make_glyph_bank(TileSize::Size8, 2, Color::BLUE))); + banks.install_scene_bank(0, Arc::new(make_scene_with_palette(42, 2, TileSize::Size8))); + + let mut frame_composer = FrameComposer::new( + 16, + 16, + Arc::clone(&banks) as Arc, + make_glyph_slot_index(&[(42, 7)]), + ); + 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_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 a39e0658..b74176f6 100644 --- a/crates/console/prometeu-drivers/src/gfx.rs +++ b/crates/console/prometeu-drivers/src/gfx.rs @@ -222,8 +222,13 @@ impl GfxBridge for Gfx { 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 render_scene_from_cache( + &mut self, + cache: &SceneViewportCache, + update: &ResolverUpdate, + resolved_glyph_slots: &[usize; 4], + ) { + self.render_scene_from_cache(cache, update, resolved_glyph_slots) } fn load_frame_sprites(&mut self, sprites: &[Sprite]) { self.load_frame_sprites(sprites) @@ -646,11 +651,18 @@ impl Gfx { /// This is the cache-backed world path accepted by DEC-0013. The canonical scene /// is not consulted here; the renderer only consumes prepared cache materialization /// plus sprite state and fade controls. - pub fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) { + pub fn render_scene_from_cache( + &mut self, + cache: &SceneViewportCache, + update: &ResolverUpdate, + resolved_glyph_slots: &[usize; 4], + ) { self.back.fill(Color::BLACK.raw()); self.populate_layer_buckets(); - for layer_index in 0..cache.layers.len() { + for (layer_index, glyph_slot) in + resolved_glyph_slots.iter().enumerate().take(cache.layers.len()) + { Self::draw_bucket_on_buffer( &mut self.back, self.w, @@ -665,6 +677,7 @@ impl Gfx { self.h, cache, &update.copy_requests[layer_index], + *glyph_slot, &*self.glyph_banks, ); } @@ -695,6 +708,7 @@ impl Gfx { screen_h: usize, cache: &SceneViewportCache, request: &LayerCopyRequest, + glyph_slot: usize, glyph_banks: &dyn GlyphBankPoolAccess, ) { let mut target = RenderTarget { back, screen_w, screen_h }; @@ -703,8 +717,11 @@ impl Gfx { return; } - let Some(bank) = glyph_banks.glyph_bank_slot(layer_cache.glyph_bank_id as usize) else { - return; + let Some(bank) = glyph_banks.glyph_bank_slot(glyph_slot) else { + panic!( + "SCENE composition fatal: resolved glyph slot {} for layer {} is not resident", + glyph_slot, request.layer_index + ); }; let tile_size_px = request.tile_size as i32; @@ -891,11 +908,63 @@ impl Gfx { } } +/// Blends in RGB565 per channel with saturation. +/// `dst` and `src` are RGB565 pixels (u16). +fn blend_rgb565(dst: u16, src: u16, mode: BlendMode) -> u16 { + match mode { + BlendMode::None => src, + + BlendMode::Half => { + let (dr, dg, db) = Color::unpack_to_native(dst); + let (sr, sg, sb) = Color::unpack_to_native(src); + let r = ((dr as u16 + sr as u16) >> 1) as u8; + let g = ((dg as u16 + sg as u16) >> 1) as u8; + let b = ((db as u16 + sb as u16) >> 1) as u8; + Color::pack_from_native(r, g, b) + } + + BlendMode::HalfPlus => { + let (dr, dg, db) = Color::unpack_to_native(dst); + let (sr, sg, sb) = Color::unpack_to_native(src); + + let r = (dr as u16 + ((sr as u16) >> 1)).min(31) as u8; + let g = (dg as u16 + ((sg as u16) >> 1)).min(63) as u8; + let b = (db as u16 + ((sb as u16) >> 1)).min(31) as u8; + + Color::pack_from_native(r, g, b) + } + + BlendMode::HalfMinus => { + let (dr, dg, db) = Color::unpack_to_native(dst); + let (sr, sg, sb) = Color::unpack_to_native(src); + + let r = (dr as i16 - ((sr as i16) >> 1)).max(0) as u8; + let g = (dg as i16 - ((sg as i16) >> 1)).max(0) as u8; + let b = (db as i16 - ((sb as i16) >> 1)).max(0) as u8; + + Color::pack_from_native(r, g, b) + } + + BlendMode::Full => { + let (dr, dg, db) = Color::unpack_to_native(dst); + let (sr, sg, sb) = Color::unpack_to_native(src); + + let r = (dr as u16 + sr as u16).min(31) as u8; + let g = (dg as u16 + sg as u16).min(63) as u8; + let b = (db as u16 + sb as u16).min(31) as u8; + + Color::pack_from_native(r, g, b) + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::FrameComposer; + use crate::asset::GlyphAssetSlotIndex; use crate::memory_banks::{GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess}; + use prometeu_hal::asset::AssetId; use prometeu_hal::glyph_bank::TileSize; use prometeu_hal::scene_bank::SceneBank; use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer}; @@ -919,7 +988,7 @@ mod tests { } fn make_layer( - glyph_bank_id: u8, + glyph_asset_id: AssetId, glyph_id: u16, palette_id: u8, width: usize, @@ -927,7 +996,7 @@ mod tests { ) -> SceneLayer { SceneLayer { active: true, - glyph_bank_id, + glyph_asset_id, tile_size: TileSize::Size8, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, tilemap: TileMap { @@ -946,10 +1015,10 @@ mod tests { } } - fn make_inactive_layer(glyph_bank_id: u8, width: usize, height: usize) -> SceneLayer { + fn make_inactive_layer(glyph_asset_id: AssetId, width: usize, height: usize) -> SceneLayer { SceneLayer { active: false, - glyph_bank_id, + glyph_asset_id, tile_size: TileSize::Size8, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, tilemap: TileMap { width, height, tiles: vec![Tile::default(); width * height] }, @@ -978,6 +1047,16 @@ mod tests { } } + fn make_glyph_slot_index(bindings: &[(AssetId, usize)]) -> GlyphAssetSlotIndex { + let index = GlyphAssetSlotIndex::new(); + let mut slots = [None; 16]; + for (asset_id, slot) in bindings { + slots[*slot] = Some(*asset_id); + } + index.rebuild_from_slots(&slots); + index + } + #[test] fn test_draw_pixel() { let banks = Arc::new(MemoryBanks::new()); @@ -1028,9 +1107,7 @@ mod tests { 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); } @@ -1053,8 +1130,12 @@ mod tests { 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); + let mut frame_composer = FrameComposer::new( + 32, + 18, + Arc::clone(&banks) as Arc, + make_glyph_slot_index(&[]), + ); gfx.begin_overlay_frame(); frame_composer.begin_frame(); @@ -1095,7 +1176,7 @@ mod tests { let mut gfx = Gfx::new(16, 16, banks); gfx.scene_fade_level = 31; gfx.hud_fade_level = 31; - gfx.render_scene_from_cache(&cache, &update); + gfx.render_scene_from_cache(&cache, &update, &[0, 0, 0, 0]); assert_eq!(gfx.back[0], Color::RED.raw()); } @@ -1146,7 +1227,7 @@ mod tests { }; gfx.sprite_count = 2; - gfx.render_scene_from_cache(&cache, &update); + gfx.render_scene_from_cache(&cache, &update, &[0, 0, 0, 0]); assert_eq!(gfx.back[0], Color::BLUE.raw()); } @@ -1189,53 +1270,3 @@ mod tests { assert_eq!(gfx.sprites[1].glyph.glyph_id, 5); } } - -/// Blends in RGB565 per channel with saturation. -/// `dst` and `src` are RGB565 pixels (u16). -fn blend_rgb565(dst: u16, src: u16, mode: BlendMode) -> u16 { - match mode { - BlendMode::None => src, - - BlendMode::Half => { - let (dr, dg, db) = Color::unpack_to_native(dst); - let (sr, sg, sb) = Color::unpack_to_native(src); - let r = ((dr as u16 + sr as u16) >> 1) as u8; - let g = ((dg as u16 + sg as u16) >> 1) as u8; - let b = ((db as u16 + sb as u16) >> 1) as u8; - Color::pack_from_native(r, g, b) - } - - BlendMode::HalfPlus => { - let (dr, dg, db) = Color::unpack_to_native(dst); - let (sr, sg, sb) = Color::unpack_to_native(src); - - let r = (dr as u16 + ((sr as u16) >> 1)).min(31) as u8; - let g = (dg as u16 + ((sg as u16) >> 1)).min(63) as u8; - let b = (db as u16 + ((sb as u16) >> 1)).min(31) as u8; - - Color::pack_from_native(r, g, b) - } - - BlendMode::HalfMinus => { - let (dr, dg, db) = Color::unpack_to_native(dst); - let (sr, sg, sb) = Color::unpack_to_native(src); - - let r = (dr as i16 - ((sr as i16) >> 1)).max(0) as u8; - let g = (dg as i16 - ((sg as i16) >> 1)).max(0) as u8; - let b = (db as i16 - ((sb as i16) >> 1)).max(0) as u8; - - Color::pack_from_native(r, g, b) - } - - BlendMode::Full => { - let (dr, dg, db) = Color::unpack_to_native(dst); - let (sr, sg, sb) = Color::unpack_to_native(src); - - let r = (dr as u16 + sr as u16).min(31) as u8; - let g = (dg as u16 + sg as u16).min(63) as u8; - let b = (db as u16 + sb as u16).min(31) as u8; - - Color::pack_from_native(r, g, b) - } - } -} diff --git a/crates/console/prometeu-drivers/src/hardware.rs b/crates/console/prometeu-drivers/src/hardware.rs index a7f09aec..4a6138f4 100644 --- a/crates/console/prometeu-drivers/src/hardware.rs +++ b/crates/console/prometeu-drivers/src/hardware.rs @@ -126,6 +126,14 @@ impl Hardware { /// Creates hardware with explicit shared bank ownership. pub fn new_with_memory_banks(memory_banks: Arc) -> Self { + let assets = AssetManager::new( + vec![], + AssetsPayloadSource::empty(), + Arc::clone(&memory_banks) as Arc, + Arc::clone(&memory_banks) as Arc, + Arc::clone(&memory_banks) as Arc, + ); + let glyph_slot_index = assets.glyph_asset_slot_index(); Self { gfx: Gfx::new( Self::W, @@ -136,17 +144,12 @@ impl Hardware { Self::W, Self::H, Arc::clone(&memory_banks) as Arc, + glyph_slot_index, ), audio: Audio::new(Arc::clone(&memory_banks) as Arc), pad: Pad::default(), touch: Touch::default(), - assets: AssetManager::new( - vec![], - AssetsPayloadSource::empty(), - Arc::clone(&memory_banks) as Arc, - Arc::clone(&memory_banks) as Arc, - Arc::clone(&memory_banks) as Arc, - ), + assets, } } } @@ -179,7 +182,7 @@ mod tests { fn make_scene() -> SceneBank { let layer = SceneLayer { active: true, - glyph_bank_id: 0, + glyph_asset_id: 1, tile_size: TileSize::Size8, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, tilemap: TileMap { @@ -216,7 +219,7 @@ mod tests { hardware.gfx.scene_fade_level = 31; hardware.gfx.hud_fade_level = 31; - hardware.gfx.render_scene_from_cache(&cache, &update); + hardware.gfx.render_scene_from_cache(&cache, &update, &[0, 0, 0, 0]); hardware.gfx.present(); assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw()); diff --git a/crates/console/prometeu-hal/src/asset.rs b/crates/console/prometeu-hal/src/asset.rs index 36aaf3f9..aa451b0f 100644 --- a/crates/console/prometeu-hal/src/asset.rs +++ b/crates/console/prometeu-hal/src/asset.rs @@ -53,7 +53,11 @@ pub const SCENE_PAYLOAD_MAGIC_V1: [u8; 4] = *b"SCNE"; pub const SCENE_PAYLOAD_VERSION_V1: u16 = 1; pub const SCENE_LAYER_COUNT_V1: usize = 4; pub const SCENE_HEADER_BYTES_V1: usize = 12; -pub const SCENE_LAYER_HEADER_BYTES_V1: usize = 28; +// Per-layer header: +// flags:u8 + glyph_asset_id:i32 + tile_size:u8 + reserved:u16 +// + parallax_x:f32 + parallax_y:f32 + width:u32 + height:u32 +// + tile_count:u32 + reserved:u32 +pub const SCENE_LAYER_HEADER_BYTES_V1: usize = 32; pub const SCENE_TILE_RECORD_BYTES_V1: usize = 4; pub const SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1: usize = 16; diff --git a/crates/console/prometeu-hal/src/gfx_bridge.rs b/crates/console/prometeu-hal/src/gfx_bridge.rs index dc989f82..34dd1465 100644 --- a/crates/console/prometeu-hal/src/gfx_bridge.rs +++ b/crates/console/prometeu-hal/src/gfx_bridge.rs @@ -57,7 +57,12 @@ pub trait GfxBridge { /// /// 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 render_scene_from_cache( + &mut self, + cache: &SceneViewportCache, + update: &ResolverUpdate, + resolved_glyph_slots: &[usize; 4], + ); fn load_frame_sprites(&mut self, sprites: &[Sprite]); /// Submit text into the `gfx.*` primitive path. /// diff --git a/crates/console/prometeu-hal/src/scene_bank.rs b/crates/console/prometeu-hal/src/scene_bank.rs index 6abb47d5..67390aa4 100644 --- a/crates/console/prometeu-hal/src/scene_bank.rs +++ b/crates/console/prometeu-hal/src/scene_bank.rs @@ -8,16 +8,22 @@ pub struct SceneBank { #[cfg(test)] mod tests { use super::*; + use crate::asset::AssetId; use crate::glyph::Glyph; use crate::glyph_bank::TileSize; use crate::scene_layer::ParallaxFactor; use crate::tile::Tile; use crate::tilemap::TileMap; - fn layer(glyph_bank_id: u8, parallax_x: f32, parallax_y: f32, glyph_id: u16) -> SceneLayer { + fn layer( + glyph_asset_id: AssetId, + parallax_x: f32, + parallax_y: f32, + glyph_id: u16, + ) -> SceneLayer { SceneLayer { active: true, - glyph_bank_id, + glyph_asset_id, tile_size: TileSize::Size16, parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y }, tilemap: TileMap { @@ -25,7 +31,7 @@ mod tests { height: 1, tiles: vec![Tile { active: true, - glyph: Glyph { glyph_id, palette_id: glyph_bank_id }, + glyph: Glyph { glyph_id, palette_id: 1 }, flip_x: false, flip_y: false, }], diff --git a/crates/console/prometeu-hal/src/scene_layer.rs b/crates/console/prometeu-hal/src/scene_layer.rs index 90feec93..21427d22 100644 --- a/crates/console/prometeu-hal/src/scene_layer.rs +++ b/crates/console/prometeu-hal/src/scene_layer.rs @@ -1,3 +1,4 @@ +use crate::asset::AssetId; use crate::glyph_bank::TileSize; use crate::tilemap::TileMap; @@ -10,7 +11,7 @@ pub struct ParallaxFactor { #[derive(Clone, Debug)] pub struct SceneLayer { pub active: bool, - pub glyph_bank_id: u8, + pub glyph_asset_id: AssetId, pub tile_size: TileSize, pub parallax_factor: ParallaxFactor, pub tilemap: TileMap, @@ -26,7 +27,7 @@ mod tests { fn scene_layer_preserves_parallax_factor_and_tilemap_ownership() { let layer = SceneLayer { active: true, - glyph_bank_id: 7, + glyph_asset_id: 7, tile_size: TileSize::Size16, parallax_factor: ParallaxFactor { x: 0.5, y: 0.75 }, tilemap: TileMap { @@ -49,7 +50,7 @@ mod tests { }, }; - assert_eq!(layer.glyph_bank_id, 7); + assert_eq!(layer.glyph_asset_id, 7); assert_eq!(layer.parallax_factor.x, 0.5); assert_eq!(layer.parallax_factor.y, 0.75); assert_eq!(layer.tilemap.width, 2); diff --git a/crates/console/prometeu-hal/src/scene_viewport_cache.rs b/crates/console/prometeu-hal/src/scene_viewport_cache.rs index 028cc7ff..ca44a390 100644 --- a/crates/console/prometeu-hal/src/scene_viewport_cache.rs +++ b/crates/console/prometeu-hal/src/scene_viewport_cache.rs @@ -1,3 +1,4 @@ +use crate::asset::AssetId; use crate::glyph_bank::TileSize; use crate::scene_bank::SceneBank; use crate::scene_layer::SceneLayer; @@ -12,7 +13,7 @@ pub struct CachedTileEntry { pub glyph_id: u16, pub palette_id: u8, pub flags: u8, - pub glyph_bank_id: u8, + pub glyph_asset_id: AssetId, } impl CachedTileEntry { @@ -38,7 +39,7 @@ impl CachedTileEntry { glyph_id: tile.glyph.glyph_id, palette_id: tile.glyph.palette_id, flags, - glyph_bank_id: layer.glyph_bank_id, + glyph_asset_id: layer.glyph_asset_id, } } } @@ -65,7 +66,7 @@ pub struct SceneViewportLayerCache { logical_origin_y: i32, ring_origin_x: usize, ring_origin_y: usize, - pub glyph_bank_id: u8, + pub glyph_asset_id: AssetId, pub tile_size: TileSize, entries: Vec, pub valid: bool, @@ -80,7 +81,7 @@ impl SceneViewportLayerCache { logical_origin_y: 0, ring_origin_x: 0, ring_origin_y: 0, - glyph_bank_id: layer.glyph_bank_id, + glyph_asset_id: layer.glyph_asset_id, tile_size: layer.tile_size, entries: vec![CachedTileEntry::default(); width * height], valid: false, @@ -136,7 +137,7 @@ impl SceneViewportLayerCache { } pub fn refresh_region(&mut self, layer: &SceneLayer, region: ViewportRegion) { - self.glyph_bank_id = layer.glyph_bank_id; + self.glyph_asset_id = layer.glyph_asset_id; self.tile_size = layer.tile_size; let max_x = region.x.saturating_add(region.width).min(self.width); @@ -268,6 +269,7 @@ impl SceneViewportCache { #[cfg(test)] mod tests { use super::*; + use crate::asset::AssetId; use crate::glyph::Glyph; use crate::glyph_bank::TileSize; use crate::scene_layer::ParallaxFactor; @@ -278,22 +280,17 @@ mod tests { Tile { active: true, glyph: Glyph { glyph_id, palette_id }, flip_x, flip_y } } - fn make_layer(glyph_bank_id: u8, base_glyph: u16) -> SceneLayer { + fn make_layer(glyph_asset_id: AssetId, base_glyph: u16) -> SceneLayer { let mut tiles = Vec::new(); for y in 0..4 { for x in 0..4 { - tiles.push(make_tile( - base_glyph + (y * 4 + x) as u16, - glyph_bank_id, - x % 2 == 0, - y % 2 == 1, - )); + tiles.push(make_tile(base_glyph + (y * 4 + x) as u16, 1, x % 2 == 0, y % 2 == 1)); } } SceneLayer { active: true, - glyph_bank_id, + glyph_asset_id, tile_size: TileSize::Size16, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, tilemap: TileMap { width: 4, height: 4, tiles }, @@ -364,7 +361,7 @@ mod tests { assert!(entry.active); assert_eq!(entry.glyph_id, 105); assert_eq!(entry.palette_id, 1); - assert_eq!(entry.glyph_bank_id, 1); + assert_eq!(entry.glyph_asset_id, 1); assert!(!entry.flip_x()); assert!(entry.flip_y()); } diff --git a/crates/console/prometeu-hal/src/scene_viewport_resolver.rs b/crates/console/prometeu-hal/src/scene_viewport_resolver.rs index a9e99902..b2a65a50 100644 --- a/crates/console/prometeu-hal/src/scene_viewport_resolver.rs +++ b/crates/console/prometeu-hal/src/scene_viewport_resolver.rs @@ -386,6 +386,7 @@ impl SceneViewportResolver { #[cfg(test)] mod tests { use super::*; + use crate::asset::AssetId; use crate::glyph::Glyph; use crate::glyph_bank::TileSize; use crate::scene_layer::{ParallaxFactor, SceneLayer}; @@ -411,7 +412,7 @@ mod tests { SceneLayer { active: true, - glyph_bank_id: 1, + glyph_asset_id: 1 as AssetId, tile_size, parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y }, tilemap: TileMap { width, height, tiles }, 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 02ded60d..d3eff1b6 100644 --- a/crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs +++ b/crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs @@ -13,9 +13,20 @@ use prometeu_hal::{ AudioOpStatus, ComposerOpStatus, HostContext, HostReturn, NativeInterface, SyscallId, expect_bool, expect_int, }; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::Ordering; impl VirtualMachineRuntime { + fn panic_payload_to_string(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + if let Some(message) = payload.downcast_ref::<&str>() { + return (*message).to_string(); + } + "host panic without string payload".to_string() + } + fn syscall_log_write(&mut self, level_val: i64, tag: u16, msg: String) -> Result<(), VmFault> { let level = match level_val { 0 => LogLevel::Trace, @@ -177,11 +188,11 @@ impl NativeInterface for VirtualMachineRuntime { } }; - let status = if hw.bind_scene(scene_bank_id) { - ComposerOpStatus::Ok - } else { - ComposerOpStatus::SceneUnavailable - }; + let bound = catch_unwind(AssertUnwindSafe(|| hw.bind_scene(scene_bank_id))) + .map_err(|payload| VmFault::Panic(Self::panic_payload_to_string(payload)))?; + + let status = + if bound { ComposerOpStatus::Ok } else { ComposerOpStatus::SceneUnavailable }; ret.push_int(status as i64); Ok(()) } 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 dbe3eee5..e8152934 100644 --- a/crates/console/prometeu-system/src/virtual_machine_runtime/tests.rs +++ b/crates/console/prometeu-system/src/virtual_machine_runtime/tests.rs @@ -147,10 +147,10 @@ fn runtime_test_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> bank } -fn runtime_test_scene(glyph_bank_id: u8, palette_id: u8, tile_size: TileSize) -> SceneBank { +fn runtime_test_scene(glyph_asset_id: i32, palette_id: u8, tile_size: TileSize) -> SceneBank { let layer = SceneLayer { active: true, - glyph_bank_id, + glyph_asset_id, tile_size, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, tilemap: TileMap { @@ -294,6 +294,10 @@ fn tick_renders_bound_eight_pixel_scene_through_frame_composer_path() { banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 2, Color::BLUE))); banks.install_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8))); let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks)); + let glyph_slot_index = hardware.assets.glyph_asset_slot_index(); + let mut slots = [None; 16]; + slots[0] = Some(0); + glyph_slot_index.rebuild_from_slots(&slots); assert!(hardware.frame_composer.bind_scene(0)); runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); @@ -348,6 +352,10 @@ fn tick_renders_scene_through_public_composer_syscalls() { banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 2, Color::BLUE))); banks.install_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8))); let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks)); + let glyph_slot_index = hardware.assets.glyph_asset_slot_index(); + let mut slots = [None; 16]; + slots[0] = Some(0); + glyph_slot_index.rebuild_from_slots(&slots); hardware.gfx.scene_fade_level = 31; hardware.gfx.hud_fade_level = 31; @@ -405,6 +413,10 @@ fn tick_draw_text_survives_scene_backed_frame_composition() { banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 2, Color::BLUE))); banks.install_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8))); let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks)); + let glyph_slot_index = hardware.assets.glyph_asset_slot_index(); + let mut slots = [None; 16]; + slots[0] = Some(0); + glyph_slot_index.rebuild_from_slots(&slots); hardware.gfx.scene_fade_level = 31; hardware.gfx.hud_fade_level = 31; 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 33191103..38c237f3 100644 --- a/crates/console/prometeu-system/src/virtual_machine_runtime/tick.rs +++ b/crates/console/prometeu-system/src/virtual_machine_runtime/tick.rs @@ -4,9 +4,20 @@ use prometeu_hal::asset::{BankTelemetry, BankType}; use prometeu_hal::log::{LogLevel, LogSource}; use prometeu_hal::{HardwareBridge, HostContext, InputSignals}; use prometeu_vm::LogicalFrameEndingReason; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::Ordering; impl VirtualMachineRuntime { + fn host_panic_payload_to_string(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + if let Some(message) = payload.downcast_ref::<&str>() { + return (*message).to_string(); + } + "host panic without string payload".to_string() + } + fn refresh_internal_allocation_telemetry(&self, vm: &VirtualMachine) { let heap_allocations = vm.heap().allocation_count().saturating_sub(self.frame_start_heap_allocations); @@ -167,7 +178,18 @@ impl VirtualMachineRuntime { if run.reason == LogicalFrameEndingReason::FrameSync || run.reason == LogicalFrameEndingReason::EndOfRom { - hw.render_frame(); + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| hw.render_frame())) { + let message = Self::host_panic_payload_to_string(payload); + let report = CrashReport::VmPanic { message, pc: Some(vm.pc() as u32) }; + self.log( + LogLevel::Error, + LogSource::Vm, + report.log_tag(), + report.summary(), + ); + self.last_crash_report = Some(report.clone()); + return Some(report); + } // 1. Snapshot full telemetry at logical frame end let (glyph_bank, sound_bank, scene_bank) = Self::bank_telemetry_summary(hw); diff --git a/crates/host/prometeu-host-desktop-winit/src/audio.rs b/crates/host/prometeu-host-desktop-winit/src/audio.rs index 7018be08..6d381735 100644 --- a/crates/host/prometeu-host-desktop-winit/src/audio.rs +++ b/crates/host/prometeu-host-desktop-winit/src/audio.rs @@ -175,6 +175,7 @@ impl HostAudio { } #[cfg(test)] +#[allow(clippy::items_after_test_module)] mod tests { use super::*; use crate::stats::HostStats; diff --git a/crates/host/prometeu-host-desktop-winit/src/input.rs b/crates/host/prometeu-host-desktop-winit/src/input.rs index 45764a68..21e7334c 100644 --- a/crates/host/prometeu-host-desktop-winit/src/input.rs +++ b/crates/host/prometeu-host-desktop-winit/src/input.rs @@ -54,15 +54,13 @@ impl HostInputHandler { self.signals.y_pos = v.1; } - WindowEvent::MouseInput { state, button, .. } => { - if *button == MouseButton::Left { - match state { - ElementState::Pressed => { - self.signals.f_signal = true; - } - ElementState::Released => { - self.signals.f_signal = false; - } + WindowEvent::MouseInput { state, button, .. } if *button == MouseButton::Left => { + match state { + ElementState::Pressed => { + self.signals.f_signal = true; + } + ElementState::Released => { + self.signals.f_signal = false; } } } diff --git a/crates/host/prometeu-host-desktop-winit/src/runner.rs b/crates/host/prometeu-host-desktop-winit/src/runner.rs index a8b28e39..239b0c59 100644 --- a/crates/host/prometeu-host-desktop-winit/src/runner.rs +++ b/crates/host/prometeu-host-desktop-winit/src/runner.rs @@ -782,10 +782,10 @@ mod tests { break; } - if let Ok(resp) = serde_json::from_str::(&line) { - if resp["type"] == "getState" { - return; - } + if let Ok(resp) = serde_json::from_str::(&line) + && resp["type"] == "getState" + { + return; } } panic!("Did not receive getState response"); diff --git a/crates/tools/pbxgen-stress/src/lib.rs b/crates/tools/pbxgen-stress/src/lib.rs index 7f165b8c..284f7b87 100644 --- a/crates/tools/pbxgen-stress/src/lib.rs +++ b/crates/tools/pbxgen-stress/src/lib.rs @@ -4,7 +4,7 @@ use prometeu_bytecode::model::{ BytecodeModule, ConstantPoolEntry, DebugInfo, Export, FunctionMeta, SyscallDecl, }; use prometeu_hal::asset::{ - AssetCodec, AssetEntry, BankType, PreloadEntry, SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1, + AssetCodec, AssetEntry, AssetId, BankType, PreloadEntry, SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1, SCENE_LAYER_COUNT_V1, SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1, }; use prometeu_hal::cartridge::{ @@ -24,6 +24,9 @@ use std::fs; use std::mem::size_of; use std::path::PathBuf; +const STRESS_GLYPH_ASSET_ID: AssetId = 100; +const STRESS_SCENE_ASSET_ID: AssetId = 1; + fn asm(s: &str) -> Vec { assemble(s).expect("assemble") } @@ -223,7 +226,7 @@ fn build_assets_pack() -> Result> { let scene = build_scene_bank(); let scene_payload = encode_scene_payload(&scene); let scene_entry = AssetEntry { - asset_id: 1, + asset_id: STRESS_SCENE_ASSET_ID, asset_name: "stress_scene".into(), bank_type: BankType::SCENE, offset: glyph_payload.len() as u64, @@ -234,8 +237,10 @@ fn build_assets_pack() -> Result> { }; let asset_table = vec![glyph_entry, scene_entry]; - let preload = - vec![PreloadEntry { asset_id: 0, slot: 0 }, PreloadEntry { asset_id: 1, slot: 0 }]; + let preload = vec![ + PreloadEntry { asset_id: STRESS_GLYPH_ASSET_ID, slot: 0 }, + PreloadEntry { asset_id: STRESS_SCENE_ASSET_ID, 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; @@ -263,7 +268,7 @@ fn build_glyph_asset() -> (AssetEntry, Vec) { payload.extend_from_slice(&build_palette_bytes()); let entry = AssetEntry { - asset_id: 0, + asset_id: STRESS_GLYPH_ASSET_ID, asset_name: "stress_square".into(), bank_type: BankType::GLYPH, offset: 0, @@ -338,7 +343,7 @@ fn build_scene_bank() -> SceneBank { SceneLayer { active: true, - glyph_bank_id: 0, + glyph_asset_id: STRESS_GLYPH_ASSET_ID, tile_size: TileSize::Size8, parallax_factor: match layer_index { 0 => ParallaxFactor { x: 1.0, y: 1.0 }, @@ -381,9 +386,9 @@ fn encode_scene_payload(scene: &SceneBank) -> Vec { 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.extend_from_slice(&(layer.glyph_asset_id as AssetId).to_le_bytes()); data.push(layer.tile_size as u8); - data.push(0); + data.extend_from_slice(&0_u16.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()); @@ -430,8 +435,12 @@ mod tests { assert_eq!(header.asset_table.len(), 2); assert_eq!(header.preload.len(), 2); + assert_eq!(header.asset_table[0].asset_id, STRESS_GLYPH_ASSET_ID); + assert_eq!(header.asset_table[1].asset_id, STRESS_SCENE_ASSET_ID); 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].asset_id, STRESS_GLYPH_ASSET_ID); + assert_eq!(header.preload[1].asset_id, STRESS_SCENE_ASSET_ID); assert_eq!(header.preload[0].slot, 0); assert_eq!(header.preload[1].slot, 0); assert_eq!(header.asset_table[0].offset, 0); diff --git a/discussion/index.ndjson b/discussion/index.ndjson index bc6b5c22..5a1fa6fa 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,4 +1,4 @@ -{"type":"meta","next_id":{"DSC":29,"AGD":29,"DEC":21,"PLN":40,"LSN":38,"CLSN":1}} +{"type":"meta","next_id":{"DSC":30,"AGD":30,"DEC":22,"PLN":43,"LSN":39,"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"}]} @@ -21,6 +21,7 @@ {"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-0029","status":"done","ticket":"scene-bank-glyph-runtime-binding-leak","title":"Scene Bank Glyph Runtime Binding Leak","created_at":"2026-04-24","updated_at":"2026-04-24","tags":["gfx","runtime","asset","scene","glyph","format","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0038","file":"lessons/DSC-0029-scene-bank-glyph-runtime-binding-leak/LSN-0038-cold-scene-dependencies-must-bind-by-asset-identity.md","status":"done","created_at":"2026-04-24","updated_at":"2026-04-24"}]} {"type":"discussion","id":"DSC-0014","status":"done","ticket":"perf-vm-allocation-and-copy-pressure","title":"Agenda - [PERF] VM Allocation and Copy Pressure","created_at":"2026-03-27","updated_at":"2026-04-20","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0035","file":"lessons/DSC-0014-perf-vm-allocation-and-copy-pressure/LSN-0035-first-materialization-is-not-the-same-as-hot-path-copy-pressure.md","status":"done","created_at":"2026-04-20","updated_at":"2026-04-20"}]} {"type":"discussion","id":"DSC-0015","status":"abandoned","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-04-20","tags":[],"agendas":[{"id":"AGD-0014","file":"workflow/agendas/AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"abandoned","created_at":"2026-03-27","updated_at":"2026-04-20","_override_reason":"User explicitly chose to close the discussion without decision because FS->memory copy for the program is already acceptable."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to abandon the discussion without creating a decision because FS->memory copy for the program is already acceptable."} {"type":"discussion","id":"DSC-0016","status":"done","ticket":"tilemap-empty-cell-vs-tile-id-zero","title":"Tilemap Empty Cell vs Tile ID Zero","created_at":"2026-03-27","updated_at":"2026-04-09","tags":[],"agendas":[{"id":"AGD-0015","file":"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-0029-scene-bank-glyph-runtime-binding-leak/LSN-0038-cold-scene-dependencies-must-bind-by-asset-identity.md b/discussion/lessons/DSC-0029-scene-bank-glyph-runtime-binding-leak/LSN-0038-cold-scene-dependencies-must-bind-by-asset-identity.md new file mode 100644 index 00000000..b782c6fe --- /dev/null +++ b/discussion/lessons/DSC-0029-scene-bank-glyph-runtime-binding-leak/LSN-0038-cold-scene-dependencies-must-bind-by-asset-identity.md @@ -0,0 +1,70 @@ +--- +id: LSN-0038 +ticket: scene-bank-glyph-runtime-binding-leak +title: Cold Scene Dependencies Must Bind by Asset Identity, Not Runtime Residency +created: 2026-04-24 +tags: [gfx, runtime, asset, scene, glyph, format, architecture] +--- + +## Context + +`SCENE` payloads had started to serialize a per-layer `glyph_bank_id`, and the runtime was consuming that byte as if it were the authoritative glyph residency binding. That meant a cold scene asset was no longer just describing its dependencies; it was leaking the runtime slot topology that happened to exist when the asset was authored. + +The implementation work for `DSC-0029` replaced that contract end to end. Scene layers now carry glyph dependencies by canonical `AssetId (i32)`, the runtime owns the reverse residency lookup, and scene activation/composition fail fatally when required glyph assets are not currently resident. + +## Key Decisions + +### Scene Assets Must Describe Dependencies, Not Slot Topology + +**What:** +Each `SceneLayer` now declares its glyph dependency by canonical `AssetId`, and the `SCENE` wire format serializes that dependency as `i32`. + +**Why:** +Runtime slot ids are operational state. They can change with preload order, slot reuse, and runtime relocation. Putting them on the wire couples a cold asset to an unstable residency layout and breaks the intended separation between authored content and runtime execution state. + +**Trade-offs:** +The payload widened and old payload compatibility was intentionally dropped. That makes the migration more abrupt, but it restores the correct contract boundary instead of preserving an invalid one. + +### Runtime Residency Must Be Resolved Through Runtime-Owned Indexes + +**What:** +The runtime now owns the authoritative `asset_id -> slot` reverse index for committed glyph assets, and scene bind/draw paths consult that index instead of trusting scene bytes as slot bindings. + +**Why:** +Residency is a runtime concern. A reverse index gives the renderer and scene binder the correct primitive while keeping slot ownership inside the asset system where preload, commit, overwrite, and invalidation already live. + +**Trade-offs:** +The asset manager has more bookkeeping responsibility, but the model becomes coherent and future public lookup APIs can emerge from the same primitive. + +### Missing Scene Dependencies Are Fatal, Not Passive Status Errors + +**What:** +If `bind_scene` cannot resolve a declared glyph dependency, or if composition later loses a required dependency, the machine fails fatally with explicit logging. + +**Why:** +A bound scene without its declared glyph dependencies is not operationally meaningful. Treating that condition as a soft status error would let the system continue from a broken render contract. + +**Trade-offs:** +This is stricter than a passive operational rejection. The runtime becomes less forgiving, but it also becomes more honest: malformed residency state cannot masquerade as a valid scene activation path. + +## Patterns and Algorithms + +- Keep authored asset formats in cold identity space and runtime systems in residency space. +- Use `AssetId` as the handoff boundary between content and runtime resolution. +- Maintain reverse residency indexes inside the asset manager whenever runtime services need dependency lookup by identity. +- Re-resolve runtime bindings in the active path when residency can change after activation. +- Prefer fatal failure over passive degradation when a declared scene dependency is mandatory for correct composition. + +## Pitfalls + +- A one-byte field can look harmless while still encoding the wrong domain concept; width is less dangerous than semantics. +- Migrating the wire format without updating tooling and generated fixtures leaves the runtime correct but the stress assets incoherent. +- Freezing slot bindings too early would turn runtime residency changes into stale scene state. +- Introducing a reverse lookup only as an optimization shortcut invites future API and ownership problems; it should be modeled as a first-class runtime primitive. + +## Takeaways + +- Cold scene payloads should carry dependency identity, never runtime residency topology. +- Runtime slot lookup belongs to the asset manager, not to authored scene data. +- A reverse `asset_id -> slot` index is the clean bridge between authored dependencies and runtime composition. +- Missing scene dependencies should fail loudly because the render contract is broken, not merely unavailable. diff --git a/docs/specs/runtime/04-gfx-peripheral.md b/docs/specs/runtime/04-gfx-peripheral.md index 8b63b63a..9948ec54 100644 --- a/docs/specs/runtime/04-gfx-peripheral.md +++ b/docs/specs/runtime/04-gfx-peripheral.md @@ -579,7 +579,7 @@ Fault boundary: | `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.bind_scene` | `status:int` | status-returning API, but missing scene glyph dependencies are fatal runtime errors | | `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 | @@ -596,6 +596,18 @@ The public `gfx.*` primitive family remains valid in v1, but its stable operatio 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.1.b Scene dependency fatal boundary + +`composer.bind_scene` remains a status-returning syscall in the public ABI, but scene glyph dependency absence is outside the accepted passive operational-error model. + +Rules: + +- the target scene slot may still be rejected through ordinary status-returning behavior when the scene itself is unavailable; +- missing glyph dependencies referenced by a resident scene are not a passive `status:int` case; +- if scene activation discovers that a layer dependency cannot be resolved to a committed glyph asset, the machine MUST fail fatally and emit a clear log; +- if scene composition later discovers that a layer dependency can no longer be resolved, the machine MUST fail fatally and emit a clear log; +- runtime MUST NOT continue canonical scene composition after such a dependency failure. + ### 19.2 `composer.emit_sprite` `composer.emit_sprite` returns `status:int`. diff --git a/docs/specs/runtime/15-asset-management.md b/docs/specs/runtime/15-asset-management.md index 9de63331..cf76e3c3 100644 --- a/docs/specs/runtime/15-asset-management.md +++ b/docs/specs/runtime/15-asset-management.md @@ -108,9 +108,9 @@ This table describes content identity and storage layout, not live residency. `codec` does not define the bank-specific serialized layout itself. Specialized banks may still have normative decode rules even when `codec = NONE`. -### 4.1 `TILES` asset contract in v1 +### 4.1 `GLYPH` asset contract in v1 -For `BankType::TILES`, the v1 runtime-facing contract is: +For `BankType::GLYPH`, the v1 runtime-facing contract is: - `codec = NONE` - serialized pixels use packed `u4` palette indices @@ -118,20 +118,20 @@ For `BankType::TILES`, the v1 runtime-facing contract is: - `palette_count = 64` - runtime materialization may expand pixel indices to one `u8` per pixel -For `TILES`, `NONE` means there is no additional generic codec layer beyond the bank contract itself. +For `GLYPH`, `NONE` means there is no additional generic codec layer beyond the bank contract itself. For the current transition window: - `RAW` is a legacy deprecated alias of `NONE` - newly published material must use `NONE` as the canonical value -Even with `codec = NONE`, `TILES` still requires bank-specific decode from its serialized payload. The serialized byte layout therefore does not need to match the in-memory layout. +Even with `codec = NONE`, `GLYPH` still requires bank-specific decode from its serialized payload. The serialized byte layout therefore does not need to match the in-memory layout. #### 4.1.1 Metadata Normalization Following `DEC-0004`, the `AssetEntry.metadata` field must be structured in segmented form to avoid ambiguity. -Required effective metadata fields for `TILES` at the root level: +Required effective metadata fields for `GLYPH` at the root level: - `tile_size`: tile edge in pixels; valid values are `8`, `16`, or `32` - `width`: total sheet width in pixels @@ -143,7 +143,7 @@ Optional informative subtrees: - `metadata.codec`: codec/compressor-specific configuration (for example dictionaries or compression flags). - `metadata.pipeline`: informative metadata from the Studio build process (for example source hashes, timestamps, or tool versions). -Validation rules for `TILES` v1: +Validation rules for `GLYPH` v1: - `palette_count` must be `64` - `width * height` defines the number of logical indexed pixels in the decoded sheet @@ -160,17 +160,74 @@ The tile-bank payload therefore separates serialized storage form from runtime m - decoded pixel plane: expanded `u8` indices, one entry per pixel - palette table: `64 * 16` colors in `RGB565` -For `TILES` v1: +For `GLYPH` v1: - `size` must match `ceil(width * height / 2) + (palette_count * 16 * 2)` - `decoded_size` must match `(width * height) + (palette_count * 16 * 2)` +### 4.2 `SCENE` asset contract in v1 + +For `BankType::SCENE`, the v1 runtime-facing contract is: + +- `codec = NONE` +- payload magic is `SCNE` +- payload version is `1` +- layer count is fixed at `4` +- each layer carries its own glyph dependency +- the glyph dependency is serialized as canonical `AssetId` using signed 32-bit little-endian width +- the payload MUST describe cold asset dependencies only and MUST NOT encode runtime glyph slot topology + +Even with `codec = NONE`, `SCENE` still has a bank-specific binary contract. The serialized layout is normative and distinct from any transient runtime residency state. + +#### 4.2.1 Layer Header Layout + +Each scene layer header is: + +1. `flags: u8` +2. `glyph_asset_id: i32` (little-endian) +3. `tile_size: u8` +4. `reserved: u16` +5. `parallax_x: f32` +6. `parallax_y: f32` +7. `width: u32` +8. `height: u32` +9. `tile_count: u32` +10. `reserved: u32` + +This makes the canonical per-layer header size `32` bytes. + +Normative rules: + +- `glyph_asset_id` MUST be interpreted as the cold identity of the glyph dependency for that layer; +- `glyph_asset_id` MUST NOT be interpreted as a runtime slot id; +- each layer MUST own exactly one glyph dependency reference; +- no shared dependency table exists in this contract; +- `tile_count` MUST equal `width * height`. + +#### 4.2.2 Runtime Binding Semantics + +`SCENE` assets do not own runtime residency. + +Therefore: + +- the runtime MUST resolve scene glyph dependencies through runtime-owned residency state; +- the runtime MUST maintain a runtime-owned mapping from committed glyph `AssetId` to glyph slot; +- scene bind and scene draw MUST consult runtime residency, not serialized slot topology; +- a missing scene glyph dependency is not a passive asset-status condition. + +If a scene glyph dependency cannot be resolved: + +- `bind_scene` MUST fail as a fatal machine error with clear logging; +- draw/composition MUST fail as a fatal machine error with clear logging; +- runtime MUST NOT continue scene composition after the missing dependency is detected. + ## 5 Banks and Slots The current runtime exposes bank types: - `GLYPH` - `SOUNDS` +- `SCENE` Assets are loaded into explicit slots identified by slot index at the public ABI boundary. diff --git a/docs/specs/runtime/16-host-abi-and-syscalls.md b/docs/specs/runtime/16-host-abi-and-syscalls.md index 77d66115..89595065 100644 --- a/docs/specs/runtime/16-host-abi-and-syscalls.md +++ b/docs/specs/runtime/16-host-abi-and-syscalls.md @@ -229,6 +229,12 @@ For mutating composer operations: - `set_camera` remains `void` in v1; - no caller-provided sprite index or `active` flag is part of the canonical contract. +Fatal boundary clarification for `bind_scene`: + +- `bind_scene` remains status-returning for ordinary orchestration-domain rejection such as unresolved target scene slot; +- missing glyph dependencies inside a resident scene are not accepted as passive `ComposerOpStatus` outcomes; +- if scene activation or later composition detects an unresolved scene glyph dependency, runtime MUST escalate that condition as a fatal machine error with clear logging. + ## 7 Syscalls as Callable Entities (Not First-Class) Syscalls behave like call sites, not like first-class guest values. diff --git a/test-cartridges/stress-console/assets.pa b/test-cartridges/stress-console/assets.pa index 77c82efc..ca43164c 100644 Binary files a/test-cartridges/stress-console/assets.pa and b/test-cartridges/stress-console/assets.pa differ