diff --git a/crates/console/prometeu-drivers/src/asset.rs b/crates/console/prometeu-drivers/src/asset.rs index cbb6c38f..d6541337 100644 --- a/crates/console/prometeu-drivers/src/asset.rs +++ b/crates/console/prometeu-drivers/src/asset.rs @@ -1,15 +1,23 @@ #![allow(clippy::collapsible_if)] -use crate::memory_banks::{GlyphBankPoolInstaller, SoundBankPoolInstaller}; +use crate::memory_banks::{GlyphBankPoolInstaller, SceneBankPoolInstaller, SoundBankPoolInstaller}; use prometeu_hal::AssetBridge; use prometeu_hal::asset::{ AssetCodec, AssetEntry, AssetId, AssetLoadError, AssetOpStatus, BankTelemetry, BankType, - HandleId, LoadStatus, PreloadEntry, SlotRef, SlotStats, + HandleId, LoadStatus, PreloadEntry, SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1, + SCENE_HEADER_BYTES_V1, SCENE_LAYER_COUNT_V1, SCENE_LAYER_HEADER_BYTES_V1, + SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1, SCENE_TILE_RECORD_BYTES_V1, SlotRef, + SlotStats, }; use prometeu_hal::cartridge::AssetsPayloadSource; use prometeu_hal::color::Color; +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::sound_bank::SoundBank; +use prometeu_hal::tile::Tile; +use prometeu_hal::tilemap::TileMap; use std::collections::HashMap; use std::io::Read; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -151,15 +159,19 @@ pub struct AssetManager { /// Narrow hardware interfaces gfx_installer: Arc, sound_installer: Arc, + scene_installer: Arc, /// Track what is installed in each hardware slot (for stats/info). gfx_slots: Arc; 16]>>, sound_slots: Arc; 16]>>, + scene_slots: Arc; 16]>>, /// Residency policy for GFX glyph banks. gfx_policy: BankPolicy, /// Residency policy for sound banks. sound_policy: BankPolicy, + /// Residency policy for scene banks. + scene_policy: BankPolicy, // Commits that are ready to be applied at the next frame boundary. pending_commits: Mutex>, @@ -275,6 +287,7 @@ impl AssetManager { match (entry.bank_type, entry.codec) { (BankType::GLYPH, AssetCodec::None) => Ok(AssetOpMode::StageInMemory), (BankType::SOUNDS, AssetCodec::None) => Ok(AssetOpMode::DirectFromSlice), + (BankType::SCENE, AssetCodec::None) => Ok(AssetOpMode::DirectFromSlice), } } @@ -283,6 +296,7 @@ impl AssetManager { assets_data: AssetsPayloadSource, gfx_installer: Arc, sound_installer: Arc, + scene_installer: Arc, ) -> Self { let mut asset_map = HashMap::new(); for entry in assets { @@ -293,10 +307,13 @@ impl AssetManager { assets: Arc::new(RwLock::new(asset_map)), gfx_installer, sound_installer, + scene_installer, gfx_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))), 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(), sound_policy: BankPolicy::new(), + scene_policy: BankPolicy::new(), handles: Arc::new(RwLock::new(HashMap::new())), next_handle_id: Mutex::new(1), assets_data: Arc::new(RwLock::new(assets_data)), @@ -345,17 +362,7 @@ impl AssetManager { if slot_index < slots.len() { slots[slot_index] = Some(entry.asset_id); } - // println!( - // "[AssetManager] Preloaded tile asset '{}' (id: {}) into slot {}", - // entry.asset_name, entry.asset_id, slot_index - // ); } - // else { - // eprintln!( - // "[AssetManager] Failed to preload tile asset '{}'", - // entry.asset_name - // ); - // } } BankType::SOUNDS => { if let Ok(bank) = @@ -372,23 +379,27 @@ impl AssetManager { if slot_index < slots.len() { slots[slot_index] = Some(entry.asset_id); } - // println!( - // "[AssetManager] Preloaded sound asset '{}' (id: {}) into slot {}", - // entry.asset_name, entry.asset_id, slot_index - // ); } - // else { - // eprintln!( - // "[AssetManager] Failed to preload sound asset '{}'", - // entry.asset_name - // ); - // } + } + BankType::SCENE => { + if let Ok(bank) = + Self::perform_load_scene_bank(&entry, self.assets_data.clone()) + { + let bank_arc = Arc::new(bank); + self.scene_policy.put_resident( + entry.asset_id, + Arc::clone(&bank_arc), + entry.decoded_size as usize, + ); + self.scene_installer.install_scene_bank(slot_index, bank_arc); + let mut slots = self.scene_slots.write().unwrap(); + if slot_index < slots.len() { + slots[slot_index] = Some(entry.asset_id); + } + } } } } - // else { - // eprintln!("[AssetManager] Preload failed: asset id '{}' not found in table", item.asset_id); - // } } } @@ -403,6 +414,7 @@ impl AssetManager { let slot = match entry.bank_type { BankType::GLYPH => SlotRef::gfx(slot_index), BankType::SOUNDS => SlotRef::audio(slot_index), + BankType::SCENE => SlotRef::scene(slot_index), }; let mut next_id = self.next_handle_id.lock().unwrap(); @@ -427,6 +439,14 @@ impl AssetManager { false } } + BankType::SCENE => { + if let Some(bank) = self.scene_policy.get_resident(asset_id) { + self.scene_policy.stage(handle_id, bank, entry.decoded_size as usize); + true + } else { + false + } + } }; if already_resident { @@ -450,6 +470,7 @@ impl AssetManager { // Capture policies for the worker thread let gfx_policy = self.gfx_policy.clone(); let sound_policy = self.sound_policy.clone(); + let scene_policy = self.scene_policy.clone(); thread::spawn(move || { // Update status to LOADING @@ -510,6 +531,34 @@ impl AssetManager { entry_clone.decoded_size as usize, ); + let mut handles_map = handles.write().unwrap(); + if let Some(h) = handles_map.get_mut(&handle_id) { + if h.status == LoadStatus::LOADING { + h.status = LoadStatus::READY; + } + } + } else { + let mut handles_map = handles.write().unwrap(); + if let Some(h) = handles_map.get_mut(&handle_id) { + h.status = LoadStatus::ERROR; + } + } + } + BankType::SCENE => { + let result = Self::perform_load_scene_bank(&entry_clone, assets_data); + if let Ok(scenebank) = result { + let bank_arc = Arc::new(scenebank); + let resident_arc = scene_policy.put_resident( + asset_id, + bank_arc, + entry_clone.decoded_size as usize, + ); + scene_policy.stage( + handle_id, + resident_arc, + entry_clone.decoded_size as usize, + ); + let mut handles_map = handles.write().unwrap(); if let Some(h) = handles_map.get_mut(&handle_id) { if h.status == LoadStatus::LOADING { @@ -642,6 +691,190 @@ impl AssetManager { } } + fn perform_load_scene_bank( + entry: &AssetEntry, + assets_data: Arc>, + ) -> Result { + let _ = entry.metadata_as_scene_bank()?; + let op_mode = Self::op_mode_for(entry)?; + let slice = { + let assets_data = assets_data.read().unwrap(); + assets_data + .open_slice(entry.offset, entry.size) + .map_err(|_| "Asset offset/size out of bounds".to_string())? + }; + + match op_mode { + AssetOpMode::DirectFromSlice => { + let mut reader = + slice.open_reader().map_err(|_| "Asset payload read failed".to_string())?; + Self::decode_scene_bank_from_reader(entry, &mut reader) + } + AssetOpMode::StageInMemory => { + let buffer = + slice.read_all().map_err(|_| "Asset payload read failed".to_string())?; + Self::decode_scene_bank_from_buffer(entry, &buffer) + } + } + } + + fn decode_scene_bank_from_buffer( + entry: &AssetEntry, + buffer: &[u8], + ) -> Result { + let _ = entry.metadata_as_scene_bank()?; + + if buffer.len() < SCENE_HEADER_BYTES_V1 { + return Err("Buffer too small for SCENE".to_string()); + } + + if buffer[0..4] != SCENE_PAYLOAD_MAGIC_V1 { + return Err("Invalid SCENE magic".to_string()); + } + + let version = u16::from_le_bytes([buffer[4], buffer[5]]); + if version != SCENE_PAYLOAD_VERSION_V1 { + return Err(format!("Unsupported SCENE version: {}", version)); + } + + let layer_count = u16::from_le_bytes([buffer[6], buffer[7]]) as usize; + if layer_count != SCENE_LAYER_COUNT_V1 { + return Err(format!("Invalid SCENE layer count: {}", layer_count)); + } + + let mut offset = SCENE_HEADER_BYTES_V1; + let mut decoded_size = 0_usize; + let layers = std::array::from_fn(|_| SceneLayer { + active: false, + glyph_bank_id: 0, + tile_size: TileSize::Size8, + motion_factor: MotionFactor { x: 1.0, y: 1.0 }, + tilemap: TileMap { width: 0, height: 0, tiles: Vec::new() }, + }); + let mut layers = layers; + + for layer in &mut layers { + let header_end = offset + .checked_add(SCENE_LAYER_HEADER_BYTES_V1) + .ok_or("SCENE layer header offset overflow")?; + if header_end > buffer.len() { + return Err("Buffer too small for SCENE layer header".to_string()); + } + + let flags = buffer[offset]; + let glyph_bank_id = buffer[offset + 1]; + let tile_size_raw = buffer[offset + 2]; + let tile_size = match tile_size_raw { + 8 => TileSize::Size8, + 16 => TileSize::Size16, + 32 => TileSize::Size32, + other => return Err(format!("Invalid SCENE tile size: {}", other)), + }; + let motion_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([ + 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()); + } + + 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([ + buffer[offset + 20], + buffer[offset + 21], + buffer[offset + 22], + buffer[offset + 23], + ]) as usize; + + let expected_tile_count = + width.checked_mul(height).ok_or("SCENE tile count overflow")?; + if tile_count != expected_tile_count { + return Err(format!( + "Invalid SCENE tile count for layer: expected {}, got {}", + expected_tile_count, tile_count + )); + } + + offset = header_end; + + let tile_bytes = tile_count + .checked_mul(SCENE_TILE_RECORD_BYTES_V1) + .ok_or("SCENE tile payload overflow")?; + let tiles_end = offset.checked_add(tile_bytes).ok_or("SCENE payload overflow")?; + if tiles_end > buffer.len() { + return Err("Buffer too small for SCENE tile data".to_string()); + } + + let mut tiles = Vec::with_capacity(tile_count); + for _ in 0..tile_count { + let tile_flags = buffer[offset]; + let palette_id = buffer[offset + 1]; + let glyph_id = u16::from_le_bytes([buffer[offset + 2], buffer[offset + 3]]); + tiles.push(Tile { + active: (tile_flags & 0b0000_0001) != 0, + glyph: Glyph { glyph_id, palette_id }, + flip_x: (tile_flags & 0b0000_0010) != 0, + flip_y: (tile_flags & 0b0000_0100) != 0, + }); + offset += SCENE_TILE_RECORD_BYTES_V1; + } + + decoded_size = decoded_size + .checked_add(SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1) + .and_then(|size| size.checked_add(tile_count * size_of::())) + .ok_or("SCENE decoded_size overflow")?; + + *layer = SceneLayer { + active: (flags & 0b0000_0001) != 0, + glyph_bank_id, + tile_size, + motion_factor: MotionFactor { x: motion_factor_x, y: motion_factor_y }, + tilemap: TileMap { width, height, tiles }, + }; + } + + if offset != buffer.len() { + return Err("Trailing bytes in SCENE payload".to_string()); + } + + if entry.decoded_size != decoded_size as u64 { + return Err(format!( + "Invalid SCENE decoded_size: expected {}, got {}", + decoded_size, entry.decoded_size + )); + } + + Ok(SceneBank { layers }) + } + + fn decode_scene_bank_from_reader( + entry: &AssetEntry, + reader: &mut impl Read, + ) -> Result { + let mut raw = Vec::new(); + reader.read_to_end(&mut raw).map_err(|_| "Asset payload read failed".to_string())?; + Self::decode_scene_bank_from_buffer(entry, &raw) + } + fn decode_sound_bank_from_buffer( entry: &AssetEntry, buffer: &[u8], @@ -709,6 +942,7 @@ impl AssetManager { } self.gfx_policy.take_staging(handle); self.sound_policy.take_staging(handle); + self.scene_policy.take_staging(handle); final_status } @@ -740,6 +974,16 @@ impl AssetManager { h.status = LoadStatus::COMMITTED; } } + BankType::SCENE => { + if let Some((bank, _)) = self.scene_policy.take_staging(handle_id) { + self.scene_installer.install_scene_bank(h.slot.index, bank); + let mut slots = self.scene_slots.write().unwrap(); + if h.slot.index < slots.len() { + slots[h.slot.index] = Some(h._asset_id); + } + h.status = LoadStatus::COMMITTED; + } + } } } } @@ -747,7 +991,11 @@ impl AssetManager { } pub fn bank_telemetry(&self) -> Vec { - vec![self.bank_telemetry_for(BankType::GLYPH), self.bank_telemetry_for(BankType::SOUNDS)] + vec![ + self.bank_telemetry_for(BankType::GLYPH), + self.bank_telemetry_for(BankType::SOUNDS), + self.bank_telemetry_for(BankType::SCENE), + ] } fn bank_telemetry_for(&self, kind: BankType) -> BankTelemetry { @@ -758,6 +1006,9 @@ impl AssetManager { BankType::SOUNDS => { self.sound_slots.read().unwrap().iter().filter(|slot| slot.is_some()).count() } + BankType::SCENE => { + self.scene_slots.read().unwrap().iter().filter(|slot| slot.is_some()).count() + } }; BankTelemetry { bank_type: kind, used_slots, total_slots: 16 } @@ -805,6 +1056,27 @@ impl AssetManager { (0, None) }; + SlotStats { asset_id, asset_name, generation: 0, resident_bytes: bytes } + } + BankType::SCENE => { + let slots = self.scene_slots.read().unwrap(); + let asset_id = slots.get(slot.index).and_then(|s| *s); + + let (bytes, asset_name) = if let Some(id) = &asset_id { + let bytes = self + .scene_policy + .resident + .read() + .unwrap() + .get(id) + .map(|entry| entry.bytes) + .unwrap_or(0); + let name = self.assets.read().unwrap().get(id).map(|e| e.asset_name.clone()); + (bytes, name) + } else { + (0, None) + }; + SlotStats { asset_id, asset_name, generation: 0, resident_bytes: bytes } } } @@ -813,18 +1085,29 @@ impl AssetManager { pub fn shutdown(&self) { self.gfx_policy.clear(); self.sound_policy.clear(); + self.scene_policy.clear(); self.handles.write().unwrap().clear(); self.pending_commits.lock().unwrap().clear(); self.gfx_slots.write().unwrap().fill(None); self.sound_slots.write().unwrap().fill(None); + self.scene_slots.write().unwrap().fill(None); } } #[cfg(test)] mod tests { use super::*; - use crate::memory_banks::{GlyphBankPoolAccess, MemoryBanks, SoundBankPoolAccess}; - use prometeu_hal::asset::AssetCodec; + use crate::memory_banks::{ + GlyphBankPoolAccess, MemoryBanks, SceneBankPoolAccess, SoundBankPoolAccess, + }; + use prometeu_hal::asset::{ + AssetCodec, SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1, SCENE_LAYER_COUNT_V1, + SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1, + }; + use prometeu_hal::glyph::Glyph; + use prometeu_hal::scene_layer::{MotionFactor, SceneLayer}; + use prometeu_hal::tile::Tile; + use prometeu_hal::tilemap::TileMap; fn expected_glyph_payload_size(width: usize, height: usize) -> usize { (width * height).div_ceil(2) + GLYPH_BANK_PALETTE_BYTES_V1 @@ -859,6 +1142,131 @@ mod tests { } } + fn test_scene() -> SceneBank { + let make_layer = + |glyph_bank_id: u8, motion_x: f32, motion_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 }, + 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, + }, + ], + }, + }; + + 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), + ], + } + } + + 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.motion_factor.x.to_le_bytes()); + data.extend_from_slice(&layer.motion_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 + } + + fn test_scene_asset_entry(asset_name: &str, data: &[u8], scene: &SceneBank) -> AssetEntry { + AssetEntry { + asset_id: 2, + asset_name: asset_name.to_string(), + bank_type: BankType::SCENE, + offset: 0, + size: data.len() as u64, + decoded_size: expected_scene_decoded_size(scene) as u64, + codec: AssetCodec::None, + metadata: serde_json::json!({}), + } + } + #[test] fn test_decode_glyph_bank_unpacks_packed_pixels_and_reads_palette_colors() { let entry = test_glyph_asset_entry("glyphs", 2, 2); @@ -933,11 +1341,85 @@ mod tests { assert_eq!(AssetManager::op_mode_for(&entry), Ok(AssetOpMode::DirectFromSlice)); } + #[test] + fn test_op_mode_for_scene_none_reads_direct_from_slice() { + let scene = test_scene(); + let data = encode_scene_payload(&scene); + let entry = test_scene_asset_entry("scene", &data, &scene); + + assert_eq!(AssetManager::op_mode_for(&entry), Ok(AssetOpMode::DirectFromSlice)); + } + + #[test] + fn test_decode_scene_bank_from_binary_payload() { + let scene = test_scene(); + let data = encode_scene_payload(&scene); + let entry = test_scene_asset_entry("scene", &data, &scene); + + 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[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); + } + + #[test] + fn test_decode_scene_bank_rejects_invalid_version() { + let scene = test_scene(); + let mut data = encode_scene_payload(&scene); + data[4..6].copy_from_slice(&2_u16.to_le_bytes()); + let entry = test_scene_asset_entry("scene", &data, &scene); + + let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err(); + + assert_eq!(err, "Unsupported SCENE version: 2"); + } + + #[test] + fn test_decode_scene_bank_rejects_invalid_tile_size() { + let scene = test_scene(); + let mut data = encode_scene_payload(&scene); + data[14] = 12; + let entry = test_scene_asset_entry("scene", &data, &scene); + + let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err(); + + assert_eq!(err, "Invalid SCENE tile size: 12"); + } + + #[test] + fn test_decode_scene_bank_rejects_layer_count_mismatch() { + let scene = test_scene(); + let mut data = encode_scene_payload(&scene); + data[6..8].copy_from_slice(&3_u16.to_le_bytes()); + let entry = test_scene_asset_entry("scene", &data, &scene); + + let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err(); + + assert_eq!(err, "Invalid SCENE layer count: 3"); + } + + #[test] + 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()); + let entry = test_scene_asset_entry("scene", &data, &scene); + + let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err(); + + assert_eq!(err, "Invalid SCENE tile count for layer: expected 4, got 5"); + } + #[test] fn test_asset_loading_flow() { let banks = Arc::new(MemoryBanks::new()); let gfx_installer = Arc::clone(&banks) as Arc; let sound_installer = Arc::clone(&banks) as Arc; + let scene_installer = Arc::clone(&banks) as Arc; let data = test_glyph_asset_data(); let asset_entry = test_glyph_asset_entry("test_glyphs", 16, 16); @@ -947,6 +1429,7 @@ mod tests { AssetsPayloadSource::from_bytes(data), gfx_installer, sound_installer, + scene_installer, ); let handle = am.load(0, 0).expect("Should start loading"); @@ -976,6 +1459,7 @@ mod tests { let banks = Arc::new(MemoryBanks::new()); let gfx_installer = Arc::clone(&banks) as Arc; let sound_installer = Arc::clone(&banks) as Arc; + let scene_installer = Arc::clone(&banks) as Arc; let data = test_glyph_asset_data(); let asset_entry = test_glyph_asset_entry("test_glyphs", 16, 16); @@ -985,6 +1469,7 @@ mod tests { AssetsPayloadSource::from_bytes(data), gfx_installer, sound_installer, + scene_installer, ); let handle1 = am.load(0, 0).unwrap(); @@ -1007,6 +1492,7 @@ mod tests { let banks = Arc::new(MemoryBanks::new()); let gfx_installer = Arc::clone(&banks) as Arc; let sound_installer = Arc::clone(&banks) as Arc; + let scene_installer = Arc::clone(&banks) as Arc; // 100 samples of 16-bit PCM (zeros) let data = vec![0u8; 200]; @@ -1030,6 +1516,7 @@ mod tests { AssetsPayloadSource::from_bytes(data), gfx_installer, sound_installer, + scene_installer, ); let handle = am.load(1, 0).expect("Should start loading"); @@ -1052,6 +1539,7 @@ mod tests { let banks = Arc::new(MemoryBanks::new()); let gfx_installer = Arc::clone(&banks) as Arc; let sound_installer = Arc::clone(&banks) as Arc; + let scene_installer = Arc::clone(&banks) as Arc; let data = vec![0u8; 200]; @@ -1071,8 +1559,13 @@ mod tests { let preload = vec![PreloadEntry { asset_id: 2, slot: 5 }]; - let am = - AssetManager::new(vec![], AssetsPayloadSource::empty(), gfx_installer, sound_installer); + let am = AssetManager::new( + vec![], + AssetsPayloadSource::empty(), + gfx_installer, + sound_installer, + scene_installer, + ); // Before init, slot 5 is empty assert!(banks.sound_bank_slot(5).is_none()); @@ -1088,13 +1581,83 @@ mod tests { assert_eq!(am.slot_info(SlotRef::audio(5)).asset_id, Some(2)); } + #[test] + fn test_scene_asset_loading() { + let banks = Arc::new(MemoryBanks::new()); + let gfx_installer = Arc::clone(&banks) as Arc; + let sound_installer = Arc::clone(&banks) as Arc; + let scene_installer = Arc::clone(&banks) as Arc; + + let scene = test_scene(); + let data = encode_scene_payload(&scene); + let asset_entry = test_scene_asset_entry("test_scene", &data, &scene); + + let am = AssetManager::new( + vec![asset_entry], + AssetsPayloadSource::from_bytes(data), + gfx_installer, + sound_installer, + scene_installer, + ); + let handle = am.load(2, 0).expect("Should start loading scene"); + + let start = Instant::now(); + while am.status(handle) != LoadStatus::READY && start.elapsed().as_secs() < 5 { + thread::sleep(std::time::Duration::from_millis(10)); + } + + assert_eq!(am.status(handle), LoadStatus::READY); + am.commit(handle); + am.apply_commits(); + + assert_eq!(am.status(handle), LoadStatus::COMMITTED); + assert!(banks.scene_bank_slot(0).is_some()); + } + + #[test] + fn test_scene_preload_on_init() { + let banks = Arc::new(MemoryBanks::new()); + let gfx_installer = Arc::clone(&banks) as Arc; + let sound_installer = Arc::clone(&banks) as Arc; + let scene_installer = Arc::clone(&banks) as Arc; + + let scene = test_scene(); + let data = encode_scene_payload(&scene); + let asset_entry = test_scene_asset_entry("preload_scene", &data, &scene); + let preload = vec![PreloadEntry { asset_id: 2, slot: 4 }]; + + let am = AssetManager::new( + vec![], + AssetsPayloadSource::empty(), + gfx_installer, + sound_installer, + scene_installer, + ); + + assert!(banks.scene_bank_slot(4).is_none()); + am.initialize_for_cartridge( + vec![asset_entry], + preload, + AssetsPayloadSource::from_bytes(data), + ); + + assert!(banks.scene_bank_slot(4).is_some()); + assert_eq!(am.slot_info(SlotRef::scene(4)).asset_id, Some(2)); + } + #[test] fn test_load_returns_asset_not_found() { let banks = Arc::new(MemoryBanks::new()); let gfx_installer = Arc::clone(&banks) as Arc; let sound_installer = Arc::clone(&banks) as Arc; - let am = - AssetManager::new(vec![], AssetsPayloadSource::empty(), gfx_installer, sound_installer); + let scene_installer = Arc::clone(&banks) as Arc; + let am = AssetManager::new( + vec![], + AssetsPayloadSource::empty(), + gfx_installer, + sound_installer, + scene_installer, + ); let result = am.load(999, 0); @@ -1106,12 +1669,14 @@ mod tests { let banks = Arc::new(MemoryBanks::new()); let gfx_installer = Arc::clone(&banks) as Arc; let sound_installer = Arc::clone(&banks) as Arc; + let scene_installer = Arc::clone(&banks) as Arc; let data = test_glyph_asset_data(); let am = AssetManager::new( vec![test_glyph_asset_entry("test_glyphs", 16, 16)], AssetsPayloadSource::from_bytes(data), gfx_installer, sound_installer, + scene_installer, ); let result = am.load(0, 16); @@ -1124,8 +1689,14 @@ mod tests { let banks = Arc::new(MemoryBanks::new()); let gfx_installer = Arc::clone(&banks) as Arc; let sound_installer = Arc::clone(&banks) as Arc; - let am = - AssetManager::new(vec![], AssetsPayloadSource::empty(), gfx_installer, sound_installer); + let scene_installer = Arc::clone(&banks) as Arc; + let am = AssetManager::new( + vec![], + AssetsPayloadSource::empty(), + gfx_installer, + sound_installer, + scene_installer, + ); assert_eq!(am.status(999), LoadStatus::UnknownHandle); } @@ -1135,12 +1706,14 @@ mod tests { let banks = Arc::new(MemoryBanks::new()); let gfx_installer = Arc::clone(&banks) as Arc; let sound_installer = Arc::clone(&banks) as Arc; + let scene_installer = Arc::clone(&banks) as Arc; let data = test_glyph_asset_data(); let am = AssetManager::new( vec![test_glyph_asset_entry("test_glyphs", 16, 16)], AssetsPayloadSource::from_bytes(data), gfx_installer, sound_installer, + scene_installer, ); assert_eq!(am.commit(999), AssetOpStatus::UnknownHandle); @@ -1162,6 +1735,7 @@ mod tests { let banks = Arc::new(MemoryBanks::new()); let gfx_installer = Arc::clone(&banks) as Arc; let sound_installer = Arc::clone(&banks) as Arc; + let scene_installer = Arc::clone(&banks) as Arc; let width = 16; let height = 16; @@ -1172,6 +1746,7 @@ mod tests { AssetsPayloadSource::from_bytes(data), gfx_installer, sound_installer, + scene_installer, ); // Initially zero diff --git a/crates/console/prometeu-drivers/src/gfx.rs b/crates/console/prometeu-drivers/src/gfx.rs index ae525130..26d09ebf 100644 --- a/crates/console/prometeu-drivers/src/gfx.rs +++ b/crates/console/prometeu-drivers/src/gfx.rs @@ -2,10 +2,10 @@ use crate::memory_banks::GlyphBankPoolAccess; use prometeu_hal::GfxBridge; use prometeu_hal::color::Color; use prometeu_hal::glyph::Glyph; -use prometeu_hal::glyph_bank::{GlyphBank, TileSize}; +use prometeu_hal::glyph_bank::GlyphBank; +use prometeu_hal::scene_viewport_cache::{CachedTileEntry, SceneViewportCache}; +use prometeu_hal::scene_viewport_resolver::{LayerCopyRequest, ResolverUpdate}; use prometeu_hal::sprite::Sprite; -use prometeu_hal::tile::Tile; -use prometeu_hal::tile_layer::{HudTileLayer, ScrollableTileLayer, TileMap}; use std::sync::Arc; /// Blending modes inspired by classic 16-bit hardware. @@ -57,10 +57,6 @@ pub struct Gfx { /// Back buffer: the "Work RAM" where new frames are composed. back: Vec, - /// 4 scrollable background layers. Each can have its own scroll (X, Y) and GlyphBank. - pub layers: [ScrollableTileLayer; 4], - /// 1 fixed layer for User Interface (HUD). It doesn't scroll. - pub hud: HudTileLayer, /// Shared access to graphical memory banks (tiles and palettes). pub glyph_banks: Arc, /// Hardware sprite list (512 slots). Equivalent to OAM (Object Attribute Memory). @@ -214,11 +210,8 @@ impl GfxBridge for Gfx { fn render_all(&mut self) { self.render_all() } - fn render_layer(&mut self, layer_idx: usize) { - self.render_layer(layer_idx) - } - fn render_hud(&mut self) { - self.render_hud() + fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) { + self.render_scene_from_cache(cache, update) } fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color) { self.draw_text(x, y, text, color) @@ -227,18 +220,6 @@ impl GfxBridge for Gfx { self.draw_char(x, y, c, color) } - fn layer(&self, index: usize) -> &ScrollableTileLayer { - &self.layers[index] - } - fn layer_mut(&mut self, index: usize) -> &mut ScrollableTileLayer { - &mut self.layers[index] - } - fn hud(&self) -> &HudTileLayer { - &self.hud - } - fn hud_mut(&mut self) -> &mut HudTileLayer { - &mut self.hud - } fn sprite(&self, index: usize) -> &Sprite { &self.sprites[index] } @@ -289,20 +270,11 @@ impl Gfx { }; let len = w * h; - let layers = [ - ScrollableTileLayer::new(64, 64, TileSize::Size16), - ScrollableTileLayer::new(64, 64, TileSize::Size16), - ScrollableTileLayer::new(64, 64, TileSize::Size16), - ScrollableTileLayer::new(64, 64, TileSize::Size16), - ]; - Self { w, h, front: vec![0; len], back: vec![0; len], - layers, - hud: HudTileLayer::new(64, 32), glyph_banks, sprites: [EMPTY_SPRITE; 512], scene_fade_level: 31, @@ -564,17 +536,7 @@ impl Gfx { /// 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) { - // 0. Preparation Phase: Filter and group sprites by their priority levels. - // This avoids iterating through all 512 sprites for every layer. - for bucket in self.priority_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); - } - } + self.populate_priority_buckets(); // 1. Priority 0 sprites: drawn at the very back, behind everything else. Self::draw_bucket_on_buffer( @@ -586,162 +548,128 @@ impl Gfx { &*self.glyph_banks, ); - // 2. Main layers and prioritized sprites. - // Order: Layer 0 -> Sprites 1 -> Layer 1 -> Sprites 2 ... - for i in 0..self.layers.len() { - let bank_id = self.layers[i].bank_id as usize; - if let Some(bank) = self.glyph_banks.glyph_bank_slot(bank_id) { - Self::draw_tile_map( - &mut self.back, - self.w, - self.h, - &self.layers[i].map, - &bank, - self.layers[i].scroll_x, - self.layers[i].scroll_y, - ); - } + // 2. Scene-only fallback path: sprites and fades still work even before a + // cache-backed world composition request is issued for the frame. + Self::apply_fade_to_buffer(&mut self.back, self.scene_fade_level, self.scene_fade_color); + + // 3. HUD Fade: independent from scene fade; HUD composition itself remains external. + Self::apply_fade_to_buffer(&mut self.back, self.hud_fade_level, self.hud_fade_color); + } + + /// Composes the world from the viewport cache using resolver copy requests. + /// + /// 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) { + 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, + ); + + for layer_index in 0..cache.layers.len() { + Self::draw_cache_layer_to_buffer( + &mut self.back, + self.w, + self.h, + cache, + &update.copy_requests[layer_index], + &*self.glyph_banks, + ); - // Draw sprites that belong to this depth level Self::draw_bucket_on_buffer( &mut self.back, self.w, self.h, - &self.priority_buckets[i + 1], + &self.priority_buckets[layer_index + 1], &self.sprites, &*self.glyph_banks, ); } - // 4. Scene Fade: Applies a color blend to the entire world (excluding HUD). Self::apply_fade_to_buffer(&mut self.back, self.scene_fade_level, self.scene_fade_color); - - // 5. HUD: The fixed interface layer, always drawn on top of the world. - Self::render_hud_with_pool(&mut self.back, self.w, self.h, &self.hud, &*self.glyph_banks); - - // 6. HUD Fade: Independent fade effect for the UI. Self::apply_fade_to_buffer(&mut self.back, self.hud_fade_level, self.hud_fade_color); } - /// Renders a specific game layer. - pub fn render_layer(&mut self, layer_idx: usize) { - if layer_idx >= self.layers.len() { - return; + fn populate_priority_buckets(&mut self) { + for bucket in self.priority_buckets.iter_mut() { + bucket.clear(); } - let bank_id = self.layers[layer_idx].bank_id as usize; - let scroll_x = self.layers[layer_idx].scroll_x; - let scroll_y = self.layers[layer_idx].scroll_y; - - let bank = match self.glyph_banks.glyph_bank_slot(bank_id) { - Some(b) => b, - _ => return, - }; - - Self::draw_tile_map( - &mut self.back, - self.w, - self.h, - &self.layers[layer_idx].map, - &bank, - scroll_x, - scroll_y, - ); + for (idx, sprite) in self.sprites.iter().enumerate() { + if sprite.active && sprite.priority < 5 { + self.priority_buckets[sprite.priority as usize].push(idx); + } + } } - /// Renders the HUD (fixed position, no scroll). - pub fn render_hud(&mut self) { - Self::render_hud_with_pool(&mut self.back, self.w, self.h, &self.hud, &*self.glyph_banks); - } - - fn render_hud_with_pool( - back: &mut [u16], - w: usize, - h: usize, - hud: &HudTileLayer, - glyph_banks: &dyn GlyphBankPoolAccess, - ) { - let bank_id = hud.bank_id as usize; - let bank = match glyph_banks.glyph_bank_slot(bank_id) { - Some(b) => b, - _ => return, - }; - - Self::draw_tile_map(back, w, h, &hud.map, &bank, 0, 0); - } - - /// Rasterizes a TileMap into the provided pixel buffer using scrolling. - fn draw_tile_map( + fn draw_cache_layer_to_buffer( back: &mut [u16], screen_w: usize, screen_h: usize, - map: &TileMap, - bank: &GlyphBank, - scroll_x: i32, - scroll_y: i32, + cache: &SceneViewportCache, + request: &LayerCopyRequest, + glyph_banks: &dyn GlyphBankPoolAccess, ) { - let tile_size = bank.tile_size as usize; + let layer_cache = &cache.layers[request.layer_index]; + if !layer_cache.valid { + return; + } - // 1. Determine the range of visible tiles based on the scroll position. - // We add a margin of 1 tile to ensure smooth pixel-perfect scrolling at the borders. - let visible_tiles_x = (screen_w / tile_size) + 1; - let visible_tiles_y = (screen_h / tile_size) + 1; + let Some(bank) = glyph_banks.glyph_bank_slot(layer_cache.glyph_bank_id as usize) else { + return; + }; - // 2. Calculate offsets within the tilemap. - let start_tile_x = scroll_x / tile_size as i32; - let start_tile_y = scroll_y / tile_size as i32; + let tile_size_px = request.tile_size as i32; + for cache_y in 0..layer_cache.height() { + let screen_tile_y = cache_y as i32 * tile_size_px - request.source_offset_y_px; + if screen_tile_y >= screen_h as i32 || screen_tile_y + tile_size_px <= 0 { + continue; + } - // 3. Fine scroll: how many pixels the tiles are shifted within the first visible cell. - let fine_scroll_x = scroll_x % tile_size as i32; - let fine_scroll_y = scroll_y % tile_size as i32; - - // 4. Iterate only through the tiles that are actually visible on screen. - for ty in 0..visible_tiles_y { - for tx in 0..visible_tiles_x { - let map_x = (start_tile_x + tx as i32) as usize; - let map_y = (start_tile_y + ty as i32) as usize; - - // Bounds check: don't draw if the camera is outside the map. - if map_x >= map.width || map_y >= map.height { + for cache_x in 0..layer_cache.width() { + let screen_tile_x = cache_x as i32 * tile_size_px - request.source_offset_x_px; + if screen_tile_x >= screen_w as i32 || screen_tile_x + tile_size_px <= 0 { continue; } - let tile = map.tiles[map_y * map.width + map_x]; - - // Optimized skip for empty (ID 0) tiles. - if !tile.active { + let entry = layer_cache.entry(cache_x, cache_y); + if !entry.active { continue; } - // 5. Project the tile pixels to screen space. - let screen_tile_x = (tx as i32 * tile_size as i32) - fine_scroll_x; - let screen_tile_y = (ty as i32 * tile_size as i32) - fine_scroll_y; - - Self::draw_tile_pixels( + Self::draw_cached_tile_pixels( back, screen_w, screen_h, screen_tile_x, screen_tile_y, - tile, - bank, + entry, + &bank, + request.tile_size, ); } } } - /// Internal helper to copy a single tile's pixels to the framebuffer. - /// Handles flipping and palette resolution. - fn draw_tile_pixels( + fn draw_cached_tile_pixels( back: &mut [u16], screen_w: usize, screen_h: usize, x: i32, y: i32, - tile: Tile, + entry: CachedTileEntry, bank: &GlyphBank, + tile_size: prometeu_hal::glyph_bank::TileSize, ) { - let size = bank.tile_size as usize; + let size = tile_size as usize; for local_y in 0..size { let world_y = y + local_y as i32; @@ -755,21 +683,14 @@ impl Gfx { continue; } - // Handle flip flags by reversing the fetch coordinates. - let fetch_x = if tile.flip_x { size - 1 - local_x } else { local_x }; - let fetch_y = if tile.flip_y { size - 1 - local_y } else { local_y }; - - // 1. Get the pixel color index (0-15) from the bank. - let px_index = bank.get_pixel_index(tile.glyph.glyph_id, fetch_x, fetch_y); - - // 2. Hardware rule: Color index 0 is always fully transparent. + 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); if px_index == 0 { continue; } - // 3. Resolve the virtual index to a real RGB565 color using the tile's assigned palette. - let color = bank.resolve_color(tile.glyph.palette_id, px_index); - + let color = bank.resolve_color(entry.palette_id, px_index); back[world_y as usize * screen_w + world_x as usize] = color.raw(); } } @@ -898,7 +819,88 @@ impl Gfx { #[cfg(test)] mod tests { use super::*; - use crate::memory_banks::MemoryBanks; + use crate::memory_banks::{GlyphBankPoolInstaller, MemoryBanks}; + use prometeu_hal::glyph_bank::TileSize; + use prometeu_hal::scene_bank::SceneBank; + use prometeu_hal::scene_layer::{MotionFactor, SceneLayer}; + use prometeu_hal::scene_viewport_cache::SceneViewportCache; + use prometeu_hal::scene_viewport_resolver::SceneViewportResolver; + use prometeu_hal::tile::Tile; + use prometeu_hal::tilemap::TileMap; + + fn make_glyph_bank(tile_size: TileSize, palette_colors: &[(u8, Color)]) -> GlyphBank { + let size = tile_size as usize; + let mut bank = GlyphBank::new(tile_size, size, size); + for (palette_id, color) in palette_colors { + bank.palettes[*palette_id as usize][1] = *color; + } + for y in 0..size { + for x in 0..size { + bank.pixel_indices[y * bank.width + x] = 1; + } + } + bank + } + + fn make_layer( + glyph_bank_id: u8, + glyph_id: u16, + palette_id: u8, + width: usize, + height: usize, + ) -> SceneLayer { + SceneLayer { + active: true, + glyph_bank_id, + tile_size: TileSize::Size8, + motion_factor: MotionFactor { x: 1.0, y: 1.0 }, + tilemap: TileMap { + width, + height, + tiles: vec![ + Tile { + active: true, + glyph: Glyph { glyph_id, palette_id }, + flip_x: false, + flip_y: false, + }; + width * height + ], + }, + } + } + + fn make_inactive_layer(glyph_bank_id: u8, width: usize, height: usize) -> SceneLayer { + SceneLayer { + active: false, + glyph_bank_id, + tile_size: TileSize::Size8, + motion_factor: MotionFactor { x: 1.0, y: 1.0 }, + tilemap: TileMap { width, height, tiles: vec![Tile::default(); width * height] }, + } + } + + fn make_scene(palette_ids: [u8; 4]) -> SceneBank { + SceneBank { + layers: [ + make_layer(0, 0, palette_ids[0], 8, 8), + make_layer(0, 0, palette_ids[1], 8, 8), + make_layer(0, 0, palette_ids[2], 8, 8), + make_layer(0, 0, palette_ids[3], 8, 8), + ], + } + } + + fn make_scene_with_inactive_top_layers() -> SceneBank { + SceneBank { + layers: [ + make_layer(0, 0, 0, 8, 8), + make_layer(0, 0, 1, 8, 8), + make_layer(0, 0, 2, 8, 8), + make_inactive_layer(0, 8, 8), + ], + } + } #[test] fn test_draw_pixel() { @@ -950,6 +952,79 @@ mod tests { // Fill assert_eq!(gfx.back[3 * 10 + 3], Color::BLACK.0); } + + #[test] + fn render_scene_from_cache_uses_materialized_cache_not_canonical_scene() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_glyph_bank( + 0, + Arc::new(make_glyph_bank(TileSize::Size8, &[(0, Color::RED), (1, Color::GREEN)])), + ); + + let mut scene = make_scene([0, 0, 0, 0]); + let mut cache = SceneViewportCache::new(&scene, 4, 4); + cache.materialize_all_layers(&scene); + + scene.layers[0].tilemap.tiles[0].glyph.palette_id = 1; + + let mut resolver = SceneViewportResolver::new(16, 16, 4, 4, 12, 20); + let update = resolver.update(&scene, 0, 0); + + 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); + + assert_eq!(gfx.back[0], Color::RED.raw()); + } + + #[test] + fn render_scene_from_cache_preserves_layer_and_sprite_order() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_glyph_bank( + 0, + Arc::new(make_glyph_bank( + TileSize::Size8, + &[(0, Color::RED), (1, Color::GREEN), (2, Color::BLUE), (4, Color::WHITE)], + )), + ); + + let scene = make_scene_with_inactive_top_layers(); + let mut cache = SceneViewportCache::new(&scene, 4, 4); + cache.materialize_all_layers(&scene); + + let mut resolver = SceneViewportResolver::new(16, 16, 4, 4, 12, 20); + let update = resolver.update(&scene, 0, 0); + + let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc); + gfx.scene_fade_level = 31; + gfx.hud_fade_level = 31; + + gfx.sprites[0] = Sprite { + glyph: Glyph { glyph_id: 0, palette_id: 4 }, + x: 0, + y: 0, + bank_id: 0, + active: true, + flip_x: false, + flip_y: false, + priority: 0, + }; + gfx.sprites[1] = Sprite { + glyph: Glyph { glyph_id: 0, palette_id: 4 }, + x: 0, + y: 0, + bank_id: 0, + active: true, + flip_x: false, + flip_y: false, + priority: 2, + }; + + gfx.render_scene_from_cache(&cache, &update); + + assert_eq!(gfx.back[0], Color::BLUE.raw()); + } } /// Blends in RGB565 per channel with saturation. diff --git a/crates/console/prometeu-drivers/src/hardware.rs b/crates/console/prometeu-drivers/src/hardware.rs index 1eb6598e..3d7a2194 100644 --- a/crates/console/prometeu-drivers/src/hardware.rs +++ b/crates/console/prometeu-drivers/src/hardware.rs @@ -2,8 +2,8 @@ use crate::asset::AssetManager; use crate::audio::Audio; use crate::gfx::Gfx; use crate::memory_banks::{ - GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SoundBankPoolAccess, - SoundBankPoolInstaller, + GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller, + SoundBankPoolAccess, SoundBankPoolInstaller, }; use crate::pad::Pad; use crate::touch::Touch; @@ -87,7 +87,11 @@ impl Hardware { /// Creates a fresh hardware instance with default settings. pub fn new() -> Self { - let memory_banks = Arc::new(MemoryBanks::new()); + Self::new_with_memory_banks(Arc::new(MemoryBanks::new())) + } + + /// Creates hardware with explicit shared bank ownership. + pub fn new_with_memory_banks(memory_banks: Arc) -> Self { Self { gfx: Gfx::new( Self::W, @@ -102,7 +106,80 @@ impl Hardware { AssetsPayloadSource::empty(), Arc::clone(&memory_banks) as Arc, Arc::clone(&memory_banks) as Arc, + Arc::clone(&memory_banks) as Arc, ), } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory_banks::{ + GlyphBankPoolInstaller, SceneBankPoolAccess, SceneBankPoolInstaller, + }; + use prometeu_hal::color::Color; + 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_viewport_cache::SceneViewportCache; + use prometeu_hal::scene_viewport_resolver::SceneViewportResolver; + use prometeu_hal::tile::Tile; + use prometeu_hal::tilemap::TileMap; + + fn make_glyph_bank() -> GlyphBank { + let mut bank = GlyphBank::new(TileSize::Size8, 8, 8); + bank.palettes[0][1] = Color::RED; + for pixel in &mut bank.pixel_indices { + *pixel = 1; + } + bank + } + + fn make_scene() -> SceneBank { + let layer = SceneLayer { + active: true, + glyph_bank_id: 0, + tile_size: TileSize::Size8, + motion_factor: MotionFactor { x: 1.0, y: 1.0 }, + tilemap: TileMap { + width: 4, + height: 4, + tiles: vec![ + Tile { + active: true, + glyph: Glyph { glyph_id: 0, palette_id: 0 }, + flip_x: false, + flip_y: false, + }; + 16 + ], + }, + }; + + SceneBank { layers: std::array::from_fn(|_| layer.clone()) } + } + + #[test] + fn hardware_can_render_scene_from_shared_scene_bank_pipeline() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_glyph_bank(0, Arc::new(make_glyph_bank())); + banks.install_scene_bank(0, Arc::new(make_scene())); + + let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks)); + let scene = banks.scene_bank_slot(0).expect("scene bank slot 0 should be resident"); + let mut cache = SceneViewportCache::new(&scene, 4, 4); + cache.materialize_all_layers(&scene); + + let mut resolver = SceneViewportResolver::new(16, 16, 4, 4, 12, 20); + let update = resolver.update(&scene, 0, 0); + + hardware.gfx.scene_fade_level = 31; + hardware.gfx.hud_fade_level = 31; + hardware.gfx.render_scene_from_cache(&cache, &update); + hardware.gfx.present(); + + assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw()); + } +} diff --git a/crates/console/prometeu-drivers/src/lib.rs b/crates/console/prometeu-drivers/src/lib.rs index eea0e5fc..f9d82bc5 100644 --- a/crates/console/prometeu-drivers/src/lib.rs +++ b/crates/console/prometeu-drivers/src/lib.rs @@ -9,5 +9,8 @@ mod touch; pub use crate::asset::AssetManager; pub use crate::audio::{Audio, AudioCommand, Channel, MAX_CHANNELS, OUTPUT_SAMPLE_RATE}; pub use crate::gfx::Gfx; -pub use crate::memory_banks::MemoryBanks; +pub use crate::memory_banks::{ + GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess, + SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller, +}; pub use crate::pad::Pad; diff --git a/crates/console/prometeu-drivers/src/memory_banks.rs b/crates/console/prometeu-drivers/src/memory_banks.rs index b2b48307..bdfd57a3 100644 --- a/crates/console/prometeu-drivers/src/memory_banks.rs +++ b/crates/console/prometeu-drivers/src/memory_banks.rs @@ -1,4 +1,5 @@ use prometeu_hal::glyph_bank::GlyphBank; +use prometeu_hal::scene_bank::SceneBank; use prometeu_hal::sound_bank::SoundBank; use std::sync::{Arc, RwLock}; @@ -30,6 +31,20 @@ pub trait SoundBankPoolInstaller: Send + Sync { fn install_sound_bank(&self, slot: usize, bank: Arc); } +/// Non-generic interface for peripherals to access scene banks. +pub trait SceneBankPoolAccess: Send + Sync { + /// Returns a reference to the resident SceneBank in the specified slot, if any. + fn scene_bank_slot(&self, slot: usize) -> Option>; + /// Returns the total number of slots available in this bank. + fn scene_bank_slot_count(&self) -> usize; +} + +/// Non-generic interface for the AssetManager to install scene banks. +pub trait SceneBankPoolInstaller: Send + Sync { + /// Atomically swaps the resident SceneBank in the specified slot. + fn install_scene_bank(&self, slot: usize, bank: Arc); +} + /// Centralized container for all hardware memory banks. /// /// MemoryBanks represent the actual hardware slot state. @@ -38,6 +53,7 @@ pub trait SoundBankPoolInstaller: Send + Sync { pub struct MemoryBanks { glyph_bank_pool: Arc>; 16]>>, sound_bank_pool: Arc>; 16]>>, + scene_bank_pool: Arc>; 16]>>, } impl Default for MemoryBanks { @@ -52,6 +68,7 @@ impl MemoryBanks { Self { glyph_bank_pool: Arc::new(RwLock::new(std::array::from_fn(|_| None))), sound_bank_pool: Arc::new(RwLock::new(std::array::from_fn(|_| None))), + scene_bank_pool: Arc::new(RwLock::new(std::array::from_fn(|_| None))), } } } @@ -95,3 +112,23 @@ impl SoundBankPoolInstaller for MemoryBanks { } } } + +impl SceneBankPoolAccess for MemoryBanks { + fn scene_bank_slot(&self, slot: usize) -> Option> { + let pool = self.scene_bank_pool.read().unwrap(); + pool.get(slot).and_then(|s| s.as_ref().map(Arc::clone)) + } + + fn scene_bank_slot_count(&self) -> usize { + 16 + } +} + +impl SceneBankPoolInstaller for MemoryBanks { + fn install_scene_bank(&self, slot: usize, bank: Arc) { + let mut pool = self.scene_bank_pool.write().unwrap(); + if slot < 16 { + pool[slot] = Some(bank); + } + } +} diff --git a/crates/console/prometeu-hal/src/asset.rs b/crates/console/prometeu-hal/src/asset.rs index 24a2d447..36aaf3f9 100644 --- a/crates/console/prometeu-hal/src/asset.rs +++ b/crates/console/prometeu-hal/src/asset.rs @@ -8,6 +8,7 @@ pub type AssetId = i32; pub enum BankType { GLYPH, SOUNDS, + SCENE, // TILEMAPS, // BLOBS, } @@ -45,6 +46,17 @@ pub struct SoundBankMetadata { pub channels: u32, } +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct SceneBankMetadata {} + +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; +pub const SCENE_TILE_RECORD_BYTES_V1: usize = 4; +pub const SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1: usize = 16; + impl AssetEntry { pub fn metadata_as_glyph_bank(&self) -> Result { if self.bank_type != BankType::GLYPH { @@ -67,6 +79,17 @@ impl AssetEntry { serde_json::from_value(self.metadata.clone()) .map_err(|e| format!("Invalid SOUNDS metadata for asset {}: {}", self.asset_id, e)) } + + pub fn metadata_as_scene_bank(&self) -> Result { + if self.bank_type != BankType::SCENE { + return Err(format!( + "Asset {} is not a SCENE bank (type: {:?})", + self.asset_id, self.bank_type + )); + } + serde_json::from_value(self.metadata.clone()) + .map_err(|e| format!("Invalid SCENE metadata for asset {}: {}", self.asset_id, e)) + } } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -133,4 +156,8 @@ impl SlotRef { pub fn audio(index: usize) -> Self { Self { asset_type: BankType::SOUNDS, index } } + + pub fn scene(index: usize) -> Self { + Self { asset_type: BankType::SCENE, index } + } } diff --git a/crates/console/prometeu-hal/src/gfx_bridge.rs b/crates/console/prometeu-hal/src/gfx_bridge.rs index 528081b6..1c677ab4 100644 --- a/crates/console/prometeu-hal/src/gfx_bridge.rs +++ b/crates/console/prometeu-hal/src/gfx_bridge.rs @@ -1,6 +1,7 @@ use crate::color::Color; +use crate::scene_viewport_cache::SceneViewportCache; +use crate::scene_viewport_resolver::ResolverUpdate; use crate::sprite::Sprite; -use crate::tile_layer::{HudTileLayer, ScrollableTileLayer}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BlendMode { @@ -48,15 +49,10 @@ pub trait GfxBridge { fn draw_vertical_line(&mut self, x: i32, y0: i32, y1: i32, color: Color); fn present(&mut self); fn render_all(&mut self); - fn render_layer(&mut self, layer_idx: usize); - fn render_hud(&mut self); + fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate); 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); - fn layer(&self, index: usize) -> &ScrollableTileLayer; - fn layer_mut(&mut self, index: usize) -> &mut ScrollableTileLayer; - fn hud(&self) -> &HudTileLayer; - fn hud_mut(&mut self) -> &mut HudTileLayer; fn sprite(&self, index: usize) -> &Sprite; fn sprite_mut(&mut self, index: usize) -> &mut Sprite; diff --git a/crates/console/prometeu-hal/src/glyph.rs b/crates/console/prometeu-hal/src/glyph.rs index 793e8ece..c65219df 100644 --- a/crates/console/prometeu-hal/src/glyph.rs +++ b/crates/console/prometeu-hal/src/glyph.rs @@ -1,4 +1,6 @@ -#[derive(Clone, Copy, Debug, Default)] +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] pub struct Glyph { pub glyph_id: u16, pub palette_id: u8, diff --git a/crates/console/prometeu-hal/src/glyph_bank.rs b/crates/console/prometeu-hal/src/glyph_bank.rs index 5c8bbce1..aa38e700 100644 --- a/crates/console/prometeu-hal/src/glyph_bank.rs +++ b/crates/console/prometeu-hal/src/glyph_bank.rs @@ -1,10 +1,11 @@ use crate::color::Color; +use serde::{Deserialize, Serialize}; pub const GLYPH_BANK_PALETTE_COUNT_V1: usize = 64; pub const GLYPH_BANK_COLORS_PER_PALETTE: usize = 16; /// Standard sizes for square tiles. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] pub enum TileSize { /// 8x8 pixels. Size8 = 8, diff --git a/crates/console/prometeu-hal/src/lib.rs b/crates/console/prometeu-hal/src/lib.rs index cd789e09..abfc7d7c 100644 --- a/crates/console/prometeu-hal/src/lib.rs +++ b/crates/console/prometeu-hal/src/lib.rs @@ -18,12 +18,16 @@ pub mod native_helpers; pub mod native_interface; pub mod pad_bridge; pub mod sample; +pub mod scene_bank; +pub mod scene_layer; +pub mod scene_viewport_cache; +pub mod scene_viewport_resolver; pub mod sound_bank; pub mod sprite; pub mod syscalls; pub mod telemetry; pub mod tile; -pub mod tile_layer; +pub mod tilemap; pub mod touch_bridge; pub mod vm_fault; pub mod window; diff --git a/crates/console/prometeu-hal/src/scene_bank.rs b/crates/console/prometeu-hal/src/scene_bank.rs new file mode 100644 index 00000000..c5f4b296 --- /dev/null +++ b/crates/console/prometeu-hal/src/scene_bank.rs @@ -0,0 +1,50 @@ +use crate::scene_layer::SceneLayer; + +#[derive(Clone, Debug)] +pub struct SceneBank { + pub layers: [SceneLayer; 4], +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::glyph::Glyph; + use crate::glyph_bank::TileSize; + use crate::scene_layer::MotionFactor; + use crate::tile::Tile; + use crate::tilemap::TileMap; + + fn layer(glyph_bank_id: u8, motion_x: f32, motion_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 }, + tilemap: TileMap { + width: 1, + height: 1, + tiles: vec![Tile { + active: true, + glyph: Glyph { glyph_id, palette_id: glyph_bank_id }, + flip_x: false, + flip_y: false, + }], + }, + } + } + + #[test] + fn scene_bank_owns_exactly_four_layers() { + let scene = SceneBank { + layers: [ + layer(0, 1.0, 1.0, 10), + layer(1, 0.5, 1.0, 11), + layer(2, 1.0, 0.5, 12), + layer(3, 0.25, 0.25, 13), + ], + }; + + assert_eq!(scene.layers.len(), 4); + assert_eq!(scene.layers[3].tilemap.tiles[0].glyph.glyph_id, 13); + } +} diff --git a/crates/console/prometeu-hal/src/scene_layer.rs b/crates/console/prometeu-hal/src/scene_layer.rs new file mode 100644 index 00000000..a7c448f3 --- /dev/null +++ b/crates/console/prometeu-hal/src/scene_layer.rs @@ -0,0 +1,59 @@ +use crate::glyph_bank::TileSize; +use crate::tilemap::TileMap; + +#[derive(Clone, Copy, Debug)] +pub struct MotionFactor { + pub x: f32, + pub y: f32, +} + +#[derive(Clone, Debug)] +pub struct SceneLayer { + pub active: bool, + pub glyph_bank_id: u8, + pub tile_size: TileSize, + pub motion_factor: MotionFactor, + pub tilemap: TileMap, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::glyph::Glyph; + use crate::tile::Tile; + + #[test] + fn scene_layer_preserves_motion_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 }, + tilemap: TileMap { + width: 2, + height: 1, + tiles: vec![ + Tile { + active: true, + glyph: Glyph { glyph_id: 21, palette_id: 3 }, + flip_x: false, + flip_y: false, + }, + Tile { + active: false, + glyph: Glyph { glyph_id: 22, palette_id: 4 }, + flip_x: true, + flip_y: false, + }, + ], + }, + }; + + 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.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 new file mode 100644 index 00000000..c0c3bcdb --- /dev/null +++ b/crates/console/prometeu-hal/src/scene_viewport_cache.rs @@ -0,0 +1,430 @@ +use crate::glyph_bank::TileSize; +use crate::scene_bank::SceneBank; +use crate::scene_layer::SceneLayer; +use crate::tile::Tile; + +const FLAG_FLIP_X: u8 = 0b0000_0001; +const FLAG_FLIP_Y: u8 = 0b0000_0010; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CachedTileEntry { + pub active: bool, + pub glyph_id: u16, + pub palette_id: u8, + pub flags: u8, + pub glyph_bank_id: u8, +} + +impl CachedTileEntry { + pub fn flip_x(self) -> bool { + (self.flags & FLAG_FLIP_X) != 0 + } + + pub fn flip_y(self) -> bool { + (self.flags & FLAG_FLIP_Y) != 0 + } + + fn from_tile(layer: &SceneLayer, tile: Tile) -> Self { + let mut flags = 0_u8; + if tile.flip_x { + flags |= FLAG_FLIP_X; + } + if tile.flip_y { + flags |= FLAG_FLIP_Y; + } + + Self { + active: tile.active, + glyph_id: tile.glyph.glyph_id, + palette_id: tile.glyph.palette_id, + flags, + glyph_bank_id: layer.glyph_bank_id, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ViewportRegion { + pub x: usize, + pub y: usize, + pub width: usize, + pub height: usize, +} + +impl ViewportRegion { + pub const fn new(x: usize, y: usize, width: usize, height: usize) -> Self { + Self { x, y, width, height } + } +} + +#[derive(Clone, Debug)] +pub struct SceneViewportLayerCache { + width: usize, + height: usize, + logical_origin_x: i32, + logical_origin_y: i32, + ring_origin_x: usize, + ring_origin_y: usize, + pub glyph_bank_id: u8, + pub tile_size: TileSize, + entries: Vec, + pub valid: bool, +} + +impl SceneViewportLayerCache { + pub fn new(layer: &SceneLayer, width: usize, height: usize) -> Self { + Self { + width, + height, + logical_origin_x: 0, + logical_origin_y: 0, + ring_origin_x: 0, + ring_origin_y: 0, + glyph_bank_id: layer.glyph_bank_id, + tile_size: layer.tile_size, + entries: vec![CachedTileEntry::default(); width * height], + valid: false, + } + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn height(&self) -> usize { + self.height + } + + pub fn logical_origin(&self) -> (i32, i32) { + (self.logical_origin_x, self.logical_origin_y) + } + + pub fn ring_origin(&self) -> (usize, usize) { + (self.ring_origin_x, self.ring_origin_y) + } + + pub fn entry(&self, cache_x: usize, cache_y: usize) -> CachedTileEntry { + self.entries[self.physical_index(cache_x, cache_y)] + } + + pub fn invalidate_all(&mut self) { + self.entries.fill(CachedTileEntry::default()); + self.valid = false; + } + + pub fn move_window_to(&mut self, origin_x: i32, origin_y: i32) { + let delta_x = origin_x - self.logical_origin_x; + let delta_y = origin_y - self.logical_origin_y; + + self.logical_origin_x = origin_x; + self.logical_origin_y = origin_y; + + self.ring_origin_x = Self::wrapped_origin(self.ring_origin_x, delta_x, self.width); + self.ring_origin_y = Self::wrapped_origin(self.ring_origin_y, delta_y, self.height); + } + + pub fn move_window_by(&mut self, delta_x: i32, delta_y: i32) { + self.move_window_to(self.logical_origin_x + delta_x, self.logical_origin_y + delta_y); + } + + pub fn refresh_line(&mut self, layer: &SceneLayer, cache_y: usize) { + self.refresh_region(layer, ViewportRegion::new(0, cache_y, self.width, 1)); + } + + pub fn refresh_column(&mut self, layer: &SceneLayer, cache_x: usize) { + self.refresh_region(layer, ViewportRegion::new(cache_x, 0, 1, self.height)); + } + + pub fn refresh_region(&mut self, layer: &SceneLayer, region: ViewportRegion) { + self.glyph_bank_id = layer.glyph_bank_id; + self.tile_size = layer.tile_size; + + let max_x = region.x.saturating_add(region.width).min(self.width); + let max_y = region.y.saturating_add(region.height).min(self.height); + + for cache_y in region.y..max_y { + for cache_x in region.x..max_x { + let entry = self.materialize_entry(layer, cache_x, cache_y); + let idx = self.physical_index(cache_x, cache_y); + self.entries[idx] = entry; + } + } + + self.valid = true; + } + + pub fn refresh_all(&mut self, layer: &SceneLayer) { + self.refresh_region(layer, ViewportRegion::new(0, 0, self.width, self.height)); + } + + fn materialize_entry( + &self, + layer: &SceneLayer, + cache_x: usize, + cache_y: usize, + ) -> CachedTileEntry { + let scene_x = self.logical_origin_x + cache_x as i32; + let scene_y = self.logical_origin_y + cache_y as i32; + + if scene_x < 0 || scene_y < 0 { + return CachedTileEntry::default(); + } + + let tile_x = scene_x as usize; + let tile_y = scene_y as usize; + if tile_x >= layer.tilemap.width || tile_y >= layer.tilemap.height { + return CachedTileEntry::default(); + } + + let tile = layer.tilemap.tiles[tile_y * layer.tilemap.width + tile_x]; + CachedTileEntry::from_tile(layer, tile) + } + + fn physical_index(&self, cache_x: usize, cache_y: usize) -> usize { + let physical_x = (self.ring_origin_x + cache_x) % self.width; + let physical_y = (self.ring_origin_y + cache_y) % self.height; + physical_y * self.width + physical_x + } + + fn wrapped_origin(current: usize, delta: i32, span: usize) -> usize { + if span == 0 { + return 0; + } + + let span_i32 = span as i32; + let current_i32 = current as i32; + (current_i32 + delta).rem_euclid(span_i32) as usize + } +} + +#[derive(Clone, Debug)] +pub struct SceneViewportCache { + width: usize, + height: usize, + pub layers: [SceneViewportLayerCache; 4], +} + +impl SceneViewportCache { + pub fn new(scene: &SceneBank, width: usize, height: usize) -> Self { + Self { + width, + height, + layers: std::array::from_fn(|i| { + SceneViewportLayerCache::new(&scene.layers[i], width, height) + }), + } + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn height(&self) -> usize { + self.height + } + + pub fn invalidate_all(&mut self) { + for layer in &mut self.layers { + layer.invalidate_all(); + } + } + + pub fn move_layer_window_to(&mut self, layer_idx: usize, origin_x: i32, origin_y: i32) { + self.layers[layer_idx].move_window_to(origin_x, origin_y); + } + + pub fn move_layer_window_by(&mut self, layer_idx: usize, delta_x: i32, delta_y: i32) { + self.layers[layer_idx].move_window_by(delta_x, delta_y); + } + + pub fn refresh_layer_line(&mut self, scene: &SceneBank, layer_idx: usize, cache_y: usize) { + self.layers[layer_idx].refresh_line(&scene.layers[layer_idx], cache_y); + } + + pub fn refresh_layer_column(&mut self, scene: &SceneBank, layer_idx: usize, cache_x: usize) { + self.layers[layer_idx].refresh_column(&scene.layers[layer_idx], cache_x); + } + + pub fn refresh_layer_region( + &mut self, + scene: &SceneBank, + layer_idx: usize, + region: ViewportRegion, + ) { + self.layers[layer_idx].refresh_region(&scene.layers[layer_idx], region); + } + + pub fn refresh_layer_all(&mut self, scene: &SceneBank, layer_idx: usize) { + self.layers[layer_idx].refresh_all(&scene.layers[layer_idx]); + } + + pub fn materialize_all_layers(&mut self, scene: &SceneBank) { + for layer_idx in 0..self.layers.len() { + self.refresh_layer_all(scene, layer_idx); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::glyph::Glyph; + use crate::glyph_bank::TileSize; + use crate::scene_layer::MotionFactor; + use crate::tile::Tile; + use crate::tilemap::TileMap; + + fn make_tile(glyph_id: u16, palette_id: u8, flip_x: bool, flip_y: bool) -> Tile { + Tile { active: true, glyph: Glyph { glyph_id, palette_id }, flip_x, flip_y } + } + + fn make_layer(glyph_bank_id: u8, 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, + )); + } + } + + SceneLayer { + active: true, + glyph_bank_id, + tile_size: TileSize::Size16, + motion_factor: MotionFactor { x: 1.0, y: 1.0 }, + tilemap: TileMap { width: 4, height: 4, tiles }, + } + } + + fn make_scene() -> SceneBank { + SceneBank { + layers: [ + make_layer(1, 100), + make_layer(2, 200), + make_layer(3, 300), + make_layer(4, 400), + ], + } + } + + #[test] + fn layer_cache_wraps_ring_origin_under_window_movement() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 3, 3); + + cache.move_layer_window_by(0, 1, 2); + assert_eq!(cache.layers[0].logical_origin(), (1, 2)); + assert_eq!(cache.layers[0].ring_origin(), (1, 2)); + + cache.move_layer_window_by(0, 3, 2); + assert_eq!(cache.layers[0].logical_origin(), (4, 4)); + assert_eq!(cache.layers[0].ring_origin(), (1, 1)); + } + + #[test] + fn cache_entry_fields_are_derived_from_scene_tiles() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 2, 2); + + cache.refresh_layer_all(&scene, 0); + let entry = cache.layers[0].entry(1, 1); + + assert!(entry.active); + assert_eq!(entry.glyph_id, 105); + assert_eq!(entry.palette_id, 1); + assert_eq!(entry.glyph_bank_id, 1); + assert!(!entry.flip_x()); + assert!(entry.flip_y()); + } + + #[test] + fn line_refresh_only_updates_the_requested_line() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 3, 3); + + cache.refresh_layer_line(&scene, 0, 1); + + assert_eq!(cache.layers[0].entry(0, 0), CachedTileEntry::default()); + assert_eq!(cache.layers[0].entry(0, 1).glyph_id, 104); + assert_eq!(cache.layers[0].entry(2, 1).glyph_id, 106); + assert_eq!(cache.layers[0].entry(0, 2), CachedTileEntry::default()); + } + + #[test] + fn column_refresh_only_updates_the_requested_column() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 3, 3); + + cache.refresh_layer_column(&scene, 1, 2); + + assert_eq!(cache.layers[1].entry(0, 0), CachedTileEntry::default()); + assert_eq!(cache.layers[1].entry(2, 0).glyph_id, 202); + assert_eq!(cache.layers[1].entry(2, 2).glyph_id, 210); + assert_eq!(cache.layers[1].entry(1, 2), CachedTileEntry::default()); + } + + #[test] + fn region_refresh_only_updates_the_requested_area() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 3, 3); + + cache.refresh_layer_region(&scene, 2, ViewportRegion::new(1, 1, 2, 2)); + + assert_eq!(cache.layers[2].entry(0, 0), CachedTileEntry::default()); + assert_eq!(cache.layers[2].entry(1, 1).glyph_id, 305); + assert_eq!(cache.layers[2].entry(2, 2).glyph_id, 310); + assert_eq!(cache.layers[2].entry(0, 2), CachedTileEntry::default()); + } + + #[test] + fn scene_swap_invalidation_clears_all_layers() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 2, 2); + cache.materialize_all_layers(&scene); + + cache.invalidate_all(); + + for layer in &cache.layers { + assert!(!layer.valid); + for y in 0..cache.height() { + for x in 0..cache.width() { + assert_eq!(layer.entry(x, y), CachedTileEntry::default()); + } + } + } + } + + #[test] + fn corner_style_region_update_does_not_touch_outside_tiles() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 4, 4); + cache.materialize_all_layers(&scene); + + let before = cache.layers[3].entry(1, 1); + cache.layers[3].invalidate_all(); + cache.refresh_layer_region(&scene, 3, ViewportRegion::new(2, 2, 2, 2)); + + assert_eq!(cache.layers[3].entry(0, 0), CachedTileEntry::default()); + assert_eq!(cache.layers[3].entry(1, 1), CachedTileEntry::default()); + assert_ne!(cache.layers[3].entry(2, 2), CachedTileEntry::default()); + assert_eq!(before.glyph_id, 405); + assert_eq!(cache.layers[3].entry(3, 3).glyph_id, 415); + } + + #[test] + fn materialization_populates_all_four_layers() { + let scene = make_scene(); + let mut cache = SceneViewportCache::new(&scene, 2, 2); + + cache.materialize_all_layers(&scene); + + assert_eq!(cache.layers[0].entry(0, 0).glyph_id, 100); + assert_eq!(cache.layers[1].entry(0, 0).glyph_id, 200); + assert_eq!(cache.layers[2].entry(1, 1).glyph_id, 305); + assert_eq!(cache.layers[3].entry(1, 0).glyph_id, 401); + } +} diff --git a/crates/console/prometeu-hal/src/scene_viewport_resolver.rs b/crates/console/prometeu-hal/src/scene_viewport_resolver.rs new file mode 100644 index 00000000..41fad0f2 --- /dev/null +++ b/crates/console/prometeu-hal/src/scene_viewport_resolver.rs @@ -0,0 +1,536 @@ +use crate::glyph_bank::TileSize; +use crate::scene_bank::SceneBank; +use crate::scene_viewport_cache::ViewportRegion; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TileAnchor { + pub x: i32, + pub y: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CacheRefreshRequest { + InvalidateLayer { layer_index: usize }, + RefreshLine { layer_index: usize, cache_y: usize }, + RefreshColumn { layer_index: usize, cache_x: usize }, + RefreshRegion { layer_index: usize, region: ViewportRegion }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LayerCopyRequest { + pub layer_index: usize, + pub tile_size: TileSize, + pub viewport_width_px: i32, + pub viewport_height_px: i32, + pub source_offset_x_px: i32, + pub source_offset_y_px: i32, + pub cache_origin_tile_x: i32, + pub cache_origin_tile_y: i32, + pub camera_x_px: i32, + pub camera_y_px: i32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolverUpdate { + pub master_anchor: TileAnchor, + pub layer_anchors: [TileAnchor; 4], + pub refresh_requests: Vec, + pub copy_requests: [LayerCopyRequest; 4], +} + +#[derive(Clone, Debug)] +pub struct SceneViewportResolver { + viewport_width_px: i32, + viewport_height_px: i32, + cache_width_tiles: usize, + cache_height_tiles: usize, + hysteresis_safe_px: i32, + hysteresis_trigger_px: i32, + initialized: bool, + master_anchor: TileAnchor, + layer_anchors: [TileAnchor; 4], +} + +impl SceneViewportResolver { + pub fn new( + viewport_width_px: i32, + viewport_height_px: i32, + cache_width_tiles: usize, + cache_height_tiles: usize, + hysteresis_safe_px: i32, + hysteresis_trigger_px: i32, + ) -> Self { + Self { + viewport_width_px, + viewport_height_px, + cache_width_tiles, + cache_height_tiles, + hysteresis_safe_px, + hysteresis_trigger_px, + initialized: false, + master_anchor: TileAnchor::default(), + layer_anchors: [TileAnchor::default(); 4], + } + } + + pub fn reset_scene(&mut self) -> Vec { + self.initialized = false; + vec![ + CacheRefreshRequest::InvalidateLayer { layer_index: 0 }, + CacheRefreshRequest::InvalidateLayer { layer_index: 1 }, + CacheRefreshRequest::InvalidateLayer { layer_index: 2 }, + CacheRefreshRequest::InvalidateLayer { layer_index: 3 }, + ] + } + + pub fn update( + &mut self, + scene: &SceneBank, + camera_x_px: i32, + camera_y_px: i32, + ) -> ResolverUpdate { + let mut refresh_requests = Vec::new(); + let camera_center_x = camera_x_px + self.viewport_width_px / 2; + let camera_center_y = camera_y_px + self.viewport_height_px / 2; + + 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_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; + ( + tile_size_px, + layer_camera_x_px, + layer_camera_y_px, + layer_center_x_px, + layer_center_y_px, + ) + }); + + if !self.initialized { + self.master_anchor = self.initial_anchor( + scene.layers[0].tilemap.width, + scene.layers[0].tilemap.height, + scene.layers[0].tile_size as i32, + camera_center_x, + camera_center_y, + ); + + for (layer_index, layer) in scene.layers.iter().enumerate() { + let (_, _, _, layer_center_x_px, layer_center_y_px) = layer_inputs[layer_index]; + self.layer_anchors[layer_index] = self.initial_anchor( + layer.tilemap.width, + layer.tilemap.height, + layer.tile_size as i32, + layer_center_x_px, + layer_center_y_px, + ); + refresh_requests.push(CacheRefreshRequest::RefreshRegion { + layer_index, + region: ViewportRegion::new( + 0, + 0, + self.cache_width_tiles, + self.cache_height_tiles, + ), + }); + } + + self.initialized = true; + } else { + let layer0 = &scene.layers[0]; + self.master_anchor = self.advance_anchor( + self.master_anchor, + camera_center_x, + camera_center_y, + layer0.tile_size as i32, + layer0.tilemap.width, + layer0.tilemap.height, + ); + + for (layer_index, layer) in scene.layers.iter().enumerate() { + let previous = self.layer_anchors[layer_index]; + let (tile_size_px, _, _, layer_center_x_px, layer_center_y_px) = + layer_inputs[layer_index]; + let next = self.advance_anchor( + previous, + layer_center_x_px, + layer_center_y_px, + tile_size_px, + layer.tilemap.width, + layer.tilemap.height, + ); + self.layer_anchors[layer_index] = next; + self.emit_refresh_requests(layer_index, previous, next, &mut refresh_requests); + } + } + + let copy_requests = std::array::from_fn(|layer_index| { + let layer = &scene.layers[layer_index]; + let (tile_size_px, layer_camera_x_px, layer_camera_y_px, _, _) = + layer_inputs[layer_index]; + let anchor = self.layer_anchors[layer_index]; + let cache_origin_tile_x = anchor.x - (self.cache_width_tiles as i32 / 2); + let cache_origin_tile_y = anchor.y - (self.cache_height_tiles as i32 / 2); + let cache_origin_x_px = cache_origin_tile_x * tile_size_px; + let cache_origin_y_px = cache_origin_tile_y * tile_size_px; + + LayerCopyRequest { + layer_index, + tile_size: layer.tile_size, + viewport_width_px: self.viewport_width_px, + viewport_height_px: self.viewport_height_px, + source_offset_x_px: layer_camera_x_px - cache_origin_x_px, + source_offset_y_px: layer_camera_y_px - cache_origin_y_px, + cache_origin_tile_x, + cache_origin_tile_y, + camera_x_px: layer_camera_x_px, + camera_y_px: layer_camera_y_px, + } + }); + + ResolverUpdate { + master_anchor: self.master_anchor, + layer_anchors: self.layer_anchors, + refresh_requests, + copy_requests, + } + } + + fn initial_anchor( + &self, + scene_width_tiles: usize, + scene_height_tiles: usize, + tile_size_px: i32, + camera_center_x_px: i32, + camera_center_y_px: i32, + ) -> TileAnchor { + let proposed = TileAnchor { + x: camera_center_x_px.div_euclid(tile_size_px), + y: camera_center_y_px.div_euclid(tile_size_px), + }; + self.clamp_anchor(proposed, scene_width_tiles, scene_height_tiles) + } + + fn advance_anchor( + &self, + current: TileAnchor, + camera_center_x_px: i32, + camera_center_y_px: i32, + tile_size_px: i32, + scene_width_tiles: usize, + scene_height_tiles: usize, + ) -> TileAnchor { + let mut next = current; + + loop { + let center_x_px = next.x * tile_size_px + tile_size_px / 2; + let drift_x = camera_center_x_px - center_x_px; + if drift_x.abs() <= self.hysteresis_safe_px { + break; + } + if drift_x > self.hysteresis_trigger_px { + next.x += 1; + continue; + } + if drift_x < -self.hysteresis_trigger_px { + next.x -= 1; + continue; + } + break; + } + + loop { + let center_y_px = next.y * tile_size_px + tile_size_px / 2; + let drift_y = camera_center_y_px - center_y_px; + if drift_y.abs() <= self.hysteresis_safe_px { + break; + } + if drift_y > self.hysteresis_trigger_px { + next.y += 1; + continue; + } + if drift_y < -self.hysteresis_trigger_px { + next.y -= 1; + continue; + } + break; + } + + self.clamp_anchor(next, scene_width_tiles, scene_height_tiles) + } + + fn clamp_anchor( + &self, + proposed: TileAnchor, + scene_width_tiles: usize, + scene_height_tiles: usize, + ) -> TileAnchor { + TileAnchor { + x: self.clamp_axis(proposed.x, scene_width_tiles, self.cache_width_tiles), + y: self.clamp_axis(proposed.y, scene_height_tiles, self.cache_height_tiles), + } + } + + fn clamp_axis(&self, proposed: i32, scene_tiles: usize, cache_tiles: usize) -> i32 { + let half = (cache_tiles / 2) as i32; + if scene_tiles <= cache_tiles { + return half; + } + + let min = half; + let max = (scene_tiles - cache_tiles) as i32 + half; + proposed.clamp(min, max) + } + + fn emit_refresh_requests( + &self, + layer_index: usize, + previous: TileAnchor, + next: TileAnchor, + requests: &mut Vec, + ) { + let delta_x = next.x - previous.x; + let delta_y = next.y - previous.y; + + if delta_x == 0 && delta_y == 0 { + return; + } + + if delta_x == 0 { + self.emit_line_requests(layer_index, delta_y, requests); + return; + } + + if delta_y == 0 { + self.emit_column_requests(layer_index, delta_x, requests); + return; + } + + self.emit_corner_region_requests(layer_index, delta_x, delta_y, requests); + } + + fn emit_line_requests( + &self, + layer_index: usize, + delta_y: i32, + requests: &mut Vec, + ) { + let count = delta_y.unsigned_abs() as usize; + if delta_y > 0 { + for offset in 0..count { + requests.push(CacheRefreshRequest::RefreshLine { + layer_index, + cache_y: self.cache_height_tiles - count + offset, + }); + } + } else { + for cache_y in 0..count { + requests.push(CacheRefreshRequest::RefreshLine { layer_index, cache_y }); + } + } + } + + fn emit_column_requests( + &self, + layer_index: usize, + delta_x: i32, + requests: &mut Vec, + ) { + let count = delta_x.unsigned_abs() as usize; + if delta_x > 0 { + for offset in 0..count { + requests.push(CacheRefreshRequest::RefreshColumn { + layer_index, + cache_x: self.cache_width_tiles - count + offset, + }); + } + } else { + for cache_x in 0..count { + requests.push(CacheRefreshRequest::RefreshColumn { layer_index, cache_x }); + } + } + } + + fn emit_corner_region_requests( + &self, + layer_index: usize, + delta_x: i32, + delta_y: i32, + requests: &mut Vec, + ) { + let width = delta_x.unsigned_abs() as usize; + let height = delta_y.unsigned_abs() as usize; + + let primary_x = if delta_x > 0 { self.cache_width_tiles - width } else { 0 }; + requests.push(CacheRefreshRequest::RefreshRegion { + layer_index, + region: ViewportRegion::new(primary_x, 0, width, self.cache_height_tiles), + }); + + let secondary_y = if delta_y > 0 { self.cache_height_tiles - height } else { 0 }; + let secondary_x = if delta_x > 0 { 0 } else { width }; + let secondary_width = self.cache_width_tiles.saturating_sub(width); + + if secondary_width > 0 { + requests.push(CacheRefreshRequest::RefreshRegion { + layer_index, + region: ViewportRegion::new(secondary_x, secondary_y, secondary_width, height), + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::glyph::Glyph; + use crate::glyph_bank::TileSize; + use crate::scene_layer::{MotionFactor, SceneLayer}; + use crate::tile::Tile; + use crate::tilemap::TileMap; + + fn make_layer( + tile_size: TileSize, + motion_x: f32, + motion_y: f32, + width: usize, + height: usize, + ) -> SceneLayer { + let mut tiles = Vec::new(); + for i in 0..(width * height) { + tiles.push(Tile { + active: true, + glyph: Glyph { glyph_id: i as u16, palette_id: 0 }, + flip_x: false, + flip_y: false, + }); + } + + SceneLayer { + active: true, + glyph_bank_id: 1, + tile_size, + motion_factor: MotionFactor { x: motion_x, y: motion_y }, + tilemap: TileMap { width, height, tiles }, + } + } + + fn make_scene() -> SceneBank { + SceneBank { + layers: [ + make_layer(TileSize::Size16, 1.0, 1.0, 64, 64), + make_layer(TileSize::Size16, 0.5, 0.5, 64, 64), + make_layer(TileSize::Size16, 1.0, 0.75, 64, 64), + make_layer(TileSize::Size16, 0.25, 1.0, 64, 64), + ], + } + } + + #[test] + fn first_update_initializes_master_and_layer_anchors() { + let scene = make_scene(); + let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20); + + let update = resolver.update(&scene, 0, 0); + + assert_eq!(update.master_anchor, TileAnchor { x: 12, y: 8 }); + assert_eq!(update.layer_anchors[0], TileAnchor { x: 12, y: 8 }); + assert_eq!(update.layer_anchors[1], TileAnchor { x: 12, y: 8 }); + assert_eq!(update.refresh_requests.len(), 4); + } + + #[test] + fn per_layer_copy_requests_follow_motion_factor() { + let scene = make_scene(); + let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20); + + let update = resolver.update(&scene, 32, 48); + + assert_eq!(update.copy_requests[0].camera_x_px, 32); + assert_eq!(update.copy_requests[0].camera_y_px, 48); + assert_eq!(update.copy_requests[1].camera_x_px, 16); + assert_eq!(update.copy_requests[1].camera_y_px, 24); + assert_eq!(update.copy_requests[3].camera_x_px, 8); + assert_eq!(update.copy_requests[3].camera_y_px, 48); + } + + #[test] + fn hysteresis_prevents_small_back_and_forth_refresh_churn() { + let scene = make_scene(); + let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20); + + let _ = resolver.update(&scene, 0, 0); + let update = resolver.update(&scene, 8, 0); + + assert!(update.refresh_requests.is_empty()); + assert_eq!(update.master_anchor, TileAnchor { x: 12, y: 8 }); + } + + #[test] + fn repeated_high_speed_movement_advances_in_tile_steps() { + let scene = make_scene(); + let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20); + + let _ = resolver.update(&scene, 0, 0); + let update = resolver.update(&scene, 64, 0); + + assert!(update.master_anchor.x > 12); + assert!(update.refresh_requests.iter().any(|request| matches!( + request, + CacheRefreshRequest::RefreshColumn { layer_index: 0, .. } + | CacheRefreshRequest::RefreshRegion { layer_index: 0, .. } + ))); + } + + #[test] + fn anchors_clamp_near_scene_edges() { + let scene = make_scene(); + let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20); + + let update = resolver.update(&scene, 10_000, 10_000); + + assert_eq!(update.master_anchor, TileAnchor { x: 51, y: 56 }); + assert_eq!(update.layer_anchors[0], TileAnchor { x: 51, y: 56 }); + } + + #[test] + fn corner_trigger_converts_to_non_overlapping_region_requests() { + let scene = make_scene(); + let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20); + + let _ = resolver.update(&scene, 0, 0); + let update = resolver.update(&scene, 64, 80); + + let regions: Vec<_> = update + .refresh_requests + .iter() + .filter_map(|request| match request { + CacheRefreshRequest::RefreshRegion { layer_index: 0, region } => Some(*region), + _ => None, + }) + .collect(); + + assert_eq!(regions.len(), 2); + assert_eq!(regions[0].x + regions[0].width, 25); + assert_eq!(regions[0].height, 16); + assert_eq!(regions[1].width, 24); + assert_eq!(regions[1].height, 1); + } + + #[test] + fn reset_scene_requests_full_invalidation_for_all_layers() { + let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20); + + let requests = resolver.reset_scene(); + + assert_eq!(requests.len(), 4); + assert!( + requests + .iter() + .all(|request| matches!(request, CacheRefreshRequest::InvalidateLayer { .. })) + ); + } +} diff --git a/crates/console/prometeu-hal/src/sprite.rs b/crates/console/prometeu-hal/src/sprite.rs index e0da7397..1d5f141f 100644 --- a/crates/console/prometeu-hal/src/sprite.rs +++ b/crates/console/prometeu-hal/src/sprite.rs @@ -1,6 +1,6 @@ use crate::glyph::Glyph; -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug)] pub struct Sprite { pub glyph: Glyph, pub x: i32, diff --git a/crates/console/prometeu-hal/src/tile.rs b/crates/console/prometeu-hal/src/tile.rs index 50012691..862c4a6f 100644 --- a/crates/console/prometeu-hal/src/tile.rs +++ b/crates/console/prometeu-hal/src/tile.rs @@ -1,9 +1,10 @@ use crate::glyph::Glyph; +use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] pub struct Tile { - pub glyph: Glyph, pub active: bool, + pub glyph: Glyph, pub flip_x: bool, pub flip_y: bool, } diff --git a/crates/console/prometeu-hal/src/tile_layer.rs b/crates/console/prometeu-hal/src/tile_layer.rs deleted file mode 100644 index 7ac6afea..00000000 --- a/crates/console/prometeu-hal/src/tile_layer.rs +++ /dev/null @@ -1,94 +0,0 @@ -use crate::glyph_bank::TileSize; -use crate::glyph_bank::TileSize::Size8; -use crate::tile::Tile; - -pub struct TileMap { - pub width: usize, - pub height: usize, - pub tiles: Vec, -} - -impl TileMap { - fn create(width: usize, height: usize) -> Self { - Self { width, height, tiles: vec![Tile::default(); width * height] } - } - - pub fn set_tile(&mut self, x: usize, y: usize, tile: Tile) { - if x < self.width && y < self.height { - self.tiles[y * self.width + x] = tile; - } - } -} - -pub struct TileLayer { - pub bank_id: u8, - pub tile_size: TileSize, - pub map: TileMap, -} - -impl TileLayer { - fn create(width: usize, height: usize, tile_size: TileSize) -> Self { - Self { bank_id: 0, tile_size, map: TileMap::create(width, height) } - } -} - -impl std::ops::Deref for TileLayer { - type Target = TileMap; - fn deref(&self) -> &Self::Target { - &self.map - } -} - -impl std::ops::DerefMut for TileLayer { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.map - } -} - -pub struct ScrollableTileLayer { - pub layer: TileLayer, - pub scroll_x: i32, - pub scroll_y: i32, -} - -impl ScrollableTileLayer { - pub fn new(width: usize, height: usize, tile_size: TileSize) -> Self { - Self { layer: TileLayer::create(width, height, tile_size), scroll_x: 0, scroll_y: 0 } - } -} - -impl std::ops::Deref for ScrollableTileLayer { - type Target = TileLayer; - fn deref(&self) -> &Self::Target { - &self.layer - } -} - -impl std::ops::DerefMut for ScrollableTileLayer { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.layer - } -} - -pub struct HudTileLayer { - pub layer: TileLayer, -} - -impl HudTileLayer { - pub fn new(width: usize, height: usize) -> Self { - Self { layer: TileLayer::create(width, height, Size8) } - } -} - -impl std::ops::Deref for HudTileLayer { - type Target = TileLayer; - fn deref(&self) -> &Self::Target { - &self.layer - } -} - -impl std::ops::DerefMut for HudTileLayer { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.layer - } -} diff --git a/crates/console/prometeu-hal/src/tilemap.rs b/crates/console/prometeu-hal/src/tilemap.rs new file mode 100644 index 00000000..2fa7a9e0 --- /dev/null +++ b/crates/console/prometeu-hal/src/tilemap.rs @@ -0,0 +1,33 @@ +use crate::tile::Tile; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TileMap { + pub width: usize, + pub height: usize, + pub tiles: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::glyph::Glyph; + + #[test] + fn tilemap_tile_write_and_read_remain_canonical() { + let mut map = TileMap { width: 2, height: 2, tiles: vec![Tile::default(); 4] }; + + let index = 3; + map.tiles[index] = Tile { + active: true, + glyph: Glyph { glyph_id: 99, palette_id: 5 }, + flip_x: true, + flip_y: false, + }; + + assert_eq!(map.tiles[index].glyph.glyph_id, 99); + assert_eq!(map.tiles[index].glyph.palette_id, 5); + assert!(map.tiles[index].flip_x); + assert!(map.tiles[index].active); + } +} diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 69accf16..681487ac 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,4 +1,4 @@ -{"type":"meta","next_id":{"DSC":25,"AGD":25,"DEC":13,"PLN":11,"LSN":30,"CLSN":1}} +{"type":"meta","next_id":{"DSC":26,"AGD":26,"DEC":14,"PLN":17,"LSN":30,"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"}]} @@ -17,6 +17,7 @@ {"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":"open","ticket":"scene-bank-and-viewport-cache-refactor","title":"Agenda - Scene Bank and Viewport Cache Refactor","created_at":"2026-04-11","updated_at":"2026-04-13","tags":["gfx","tilemap","runtime","render"],"agendas":[{"id":"AGD-0025","file":"AGD-0025-scene-bank-and-viewport-cache-refactor.md","status":"accepted","created_at":"2026-04-11","updated_at":"2026-04-13"}],"decisions":[{"id":"DEC-0013","file":"DEC-0013-scene-bank-and-viewport-cache-model.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_agenda":"AGD-0025"}],"plans":[{"id":"PLN-0011","file":"PLN-0011-scene-core-types-and-bank-contract.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]},{"id":"PLN-0012","file":"PLN-0012-scene-viewport-cache-structure.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]},{"id":"PLN-0013","file":"PLN-0013-scene-viewport-resolver-and-rematerialization.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]},{"id":"PLN-0014","file":"PLN-0014-renderer-migration-to-scene-viewport-cache.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]},{"id":"PLN-0015","file":"PLN-0015-api-bank-integration-and-tests.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]},{"id":"PLN-0016","file":"PLN-0016-scene-binary-payload-format-and-decoder.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]}],"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"}]} diff --git a/discussion/workflow/agendas/AGD-0025-scene-bank-and-viewport-cache-refactor.md b/discussion/workflow/agendas/AGD-0025-scene-bank-and-viewport-cache-refactor.md new file mode 100644 index 00000000..94c47791 --- /dev/null +++ b/discussion/workflow/agendas/AGD-0025-scene-bank-and-viewport-cache-refactor.md @@ -0,0 +1,408 @@ +--- +id: AGD-0025 +ticket: scene-bank-and-viewport-cache-refactor +title: Agenda - Scene Bank and Viewport Cache Refactor +status: open +created: 2026-04-11 +resolved: +decision: +tags: [gfx, tilemap, runtime, render] +--- + +# Agenda - Scene Bank and Viewport Cache Refactor + +## Contexto + +Hoje o runtime mistura diretamente: + +- o mapa canonico da layer; +- o estado de scroll; +- e o consumo direto pelo renderer. + +Na implementacao atual, os `64x64` das `ScrollableTileLayer` criadas em `Gfx::new()` sao o proprio mapa residente da layer que o renderer consulta diretamente. Nao existe hoje separacao explicita entre: + +- fonte canonica do mundo carregado; +- layer canonica da scene; +- view/cache materializada para render. + +Ao longo desta discussao, o foco deixou de ser ringbuffer como tema principal. A convergencia real passou a ser um refactor do sistema de tilemap para separar melhor: + +- modelo canonico do mundo; +- viewport materializada; +- camera; +- composicao incremental no `back`. + +Tambem ficou claro que: + +- `Tile` nao carrega RGB; ele referencia glyph/palette/flags e continua relativamente leve; +- por isso, manter o tilemap canonico inteiro em memoria e defensavel no baseline atual; +- o ganho mais promissor parece estar em reduzir recomposicao bruta, nao em residentizar parcialmente o mundo logo de inicio. + +## Problema + +Ainda nao esta definido como reorganizar o sistema de tilemap para: + +- manter um modelo canonico simples e previsivel; +- materializar apenas o necessario para render; +- permitir camera desacoplada do tilemap; +- evitar full redraw bruto sempre que a camera ou o mundo se movem; +- preparar o renderer para compor a world a partir de cache de viewport. + +## Pontos Criticos + +1. O `SceneBank` precisa ser a fonte da verdade. + Gameplay, fisica e futuras extensoes nao devem depender do cache de viewport. + +2. O `SceneViewportCache` precisa ser operacional, nao semantico. + Ele existe para render, nao para redefinir o contrato do mundo. + +3. O refactor pode quebrar o shape atual. + Nao ha necessidade de preservar `ScrollableTileLayer` nem compatibilidade com a modelagem existente. + +4. A camera deve ficar desacoplada. + O bank nao deve conhecer detalhes de camera; um componente intermediario resolve camera -> viewport -> cache. + +5. O custo principal a atacar continua sendo composicao. + A materializacao do cache precisa conversar com uma estrategia de redraw menos destrutiva no `back`. + +## Opcoes + +### Opcao A - Manter o modelo atual e otimizar o renderer em volta + +- **Abordagem:** preservar `TileLayer`/`ScrollableTileLayer` como hoje e atacar apenas dirtying, blit e composicao no `back`. +- **Pros:** menor refactor estrutural. +- **Contras:** mantem o acoplamento entre mapa, scroll e renderer; pior base para evolucao arquitetural. + +### Opcao B - Refatorar para `SceneBank` + `SceneViewportCache` + +- **Abordagem:** separar modelo canonico da scene e cache materializado de viewport, com camera desacoplada e algoritmo proprio de rematerializacao. +- **Pros:** arquitetura mais clara; melhor separacao de responsabilidades; base melhor para dirtying e composicao incremental. +- **Contras:** refactor maior; exige redefinir tipos e fluxo do renderer. + +## Sugestao / Recomendacao + +A recomendacao atual e seguir com a **Opcao B**. + +O refactor deve: + +1. manter o mundo canonico inteiro no `SceneBank`; +2. substituir o consumo direto de layer/mapa por um `SceneViewportCache`; +3. desacoplar camera do modelo canonico; +4. preparar o renderer para compor a world a partir de cache materializado; +5. preservar sprites, HUD e fades como sistemas separados da invalidacao do cache de viewport. + +## Direcao Atual Consolidada + +Fica aceito, a menos de revisao explicita posterior, que: + +- o runtime podera carregar ate **16** entradas em `BankType::TILEMAP`; +- cada entrada carregada representa um **`SceneBank`**; +- cada `SceneBank` contem as **4 layers** da scene; +- `background` e `parallax` passam a ser tratados dentro das proprias `SceneLayer`s, nao como tipo separado desta V1; +- cada `SceneLayer` porta seu proprio `TileMap`; +- cada `TileMap` e um conjunto de `Tile`; +- o renderer nao deve consumir o `SceneBank` diretamente; +- o renderer deve consumir um **`SceneViewportCache`** derivado do `SceneBank`; +- `ScrollableTileLayer` **morre** nesta arquitetura; +- nao ha obrigacao de compatibilidade com o shape atual. + +## Modelo Alvo de V1 + +### Hierarquia conceitual + +- `Scene` (`BankType::SCENE`) + - agregado canonico carregado + - contem as `SceneLayer`s da scene + +- `SceneLayer` + - layer canonica da scene + - contem metadados da layer e seu `TileMap` + - pode representar layer normal, background ou parallax pela sua configuracao + +- `TileMap` + - grade de tiles de uma layer + +- `Tile` + - unidade basica de conteudo por celula + +- `SceneViewportCache` + - cache/view materializada para render + - derivada da `Scene` + - contem ringbuffers internos, um por layer + +### Papel de cada tipo + +- `Scene` + - fonte da verdade + - usado por gameplay, fisica e materializacao + +- `SceneViewportCache` + - usado pelo renderer + - sabe scroll fino em pixels + - nao e fonte da verdade do mundo + - e a fonte imediata de copia para o blit + +- `SceneLayer` + - passa a carregar tambem metadados de movimento relativos ao totem mestre + - isso permite compor layers normais e parallax sob o mesmo contrato base + +### O que um tile do cache pode guardar + +V1 deve partir de cache leve. Alem do `Tile` cru, ele pode guardar alguns dados derivados para acelerar raster: + +- `glyph_id` pronto para consulta; +- `palette_id` pronto para uso; +- flags de flip compactadas; +- marca rapida de `active/empty`; +- referencia do glyph bank ja resolvida por layer; +- metadados de dirty local do proprio cache. + +V1 nao deve: + +- guardar pixels RGB resolvidos por tile; +- duplicar desnecessariamente fisica no cache de render; +- virar um mini-framebuffer por tile. + +### Movimento por layer + +Fica aceito que cada `SceneLayer` pode carregar um fator de movimento relativo ao totem mestre. + +Leitura: + +- existe um **totem mestre** resolvido pelo sistema de camera/viewport; +- cada layer resolve seu proprio deslocamento efetivo a partir desse totem mestre; +- layers com fator `1.0` acompanham o mundo principal; +- layers com fator diferente de `1.0` permitem efeito de parallax; +- isso mantem `background` e `parallax` dentro do mesmo modelo estrutural de `SceneLayer`. + +## Camera e Resolver + +Fica aceito que: + +- a camera existe em **pixel space**; +- a `Scene` nao conhece camera; +- um componente externo faz a ligacao entre camera e cache materializado; +- esse componente calcula viewport logica, totem mestre, drift, clamp e rematerializacao. + +Nome conceitual provisório: + +- `SceneViewportResolver` +- ou equivalente com a mesma responsabilidade. + +Direcao aceita para o `SceneViewportResolver`: + +- ele e responsavel pelos totens: + - totem mestre + - totens derivados por layer +- ele recebe posicao de camera de uma entidade externa; +- internamente, a posicao da camera e tratada como insumo para o totem mestre; +- ele clampa o totem mestre; +- ele decide se o cache precisa ou nao ser atualizado; +- se necessario, ele dispara atualizacao do `SceneViewportCache`; +- ele tambem deve saber instrumentalizar requests de copia por layer a partir da posicao da camera e do estado do cache; +- ele **nao executa a copia** no `back`, apenas informa o que deve ser copiado e de onde. + +## Viewport, Halo e Tamanho da Janela + +Dados atuais do runtime: + +- resolucao interna: **320x180** +- world layers: **16x16** +- HUD: **8x8** + +Para world `16x16` em `320x180`, a viewport raster minima atual equivale a: + +- **21x12 tiles** + +Direcao aceita para V1: + +- o `SceneViewportCache` materializa mais do que a viewport raster minima; +- o tamanho alvo de V1 passa a ser **25x16 tiles**; +- isso da folga suficiente para velocidades agressivas de camera sem inflar demais o cache; +- `64x64` deixa de ser tamanho alvo e passa a ser apenas referencia historica do modelo atual. + +Ordem de grandeza de memoria para `25x16`: + +- `400 tiles` +- usando `36 bytes/tile` como estimativa conservadora com margem, o cache fica em ~`14.1 KiB` + +## Algoritmo de Totem, Drift e Histerese + +### Modelo aceito + +- existe um totem mestre em `tile space`: `(i, j)` +- existe uma camera em `pixel space`: `(x, y)` +- com tile `16x16`, o centro do totem mestre em pixels e: + - `cx = 16 * i + 8` + - `cy = 16 * j + 8` +- o drift por eixo e: + - `dx = x - cx` + - `dy = y - cy` + +### Histerese aceita para V1 + +Fica aceito como baseline: + +- `safe = 12 px` +- `trigger = 20 px` + +Interpretacao: + +- dentro de `[-12, +12]`, nada acontece; +- entre `12` e `20`, existe tolerancia sem rematerializacao; +- ao ultrapassar `20`, o totem anda em tiles e o cache rematerializa as faixas necessarias. + +### Regra por eixo + +Horizontal: + +- se `dx > +20`, o totem anda `+1` tile em `x` +- se `dx < -20`, o totem anda `-1` tile em `x` + +Vertical: + +- se `dy > +20`, o totem anda `+1` tile em `y` +- se `dy < -20`, o totem anda `-1` tile em `y` + +Depois de cada movimento: + +- recalcula-se o centro do totem; +- recalcula-se o drift; +- se ainda houver excesso, o processo repete. + +### Motivo da histerese + +Histerese fica cravada na agenda como tecnica explicita para evitar flick/thrash de borda. + +Ela existe para: + +- evitar vai-e-volta quando camera/player oscilam perto do limite; +- manter o `SceneViewportCache` estavel; +- disparar rematerializacao por eventos discretos, nao por ruido de borda. + +### Politica de atualizacao do cache + +Fica aceito que: + +- a atualizacao normal do cache ocorre por **linha/coluna**; +- se houver trigger simultaneo em `x` e `y`, o algoritmo deve permitir atualizacao por **area/regiao** para evitar carregar tiles ja presentes; +- troca de `Scene` invalida o `SceneViewportCache` completamente; +- scroll/camera nao devem invalidar a janela inteira como regra normal. + +### `SceneViewportCache` e ringbuffer + +Leitura atual da agenda: + +- **sim, o `SceneViewportCache` deve preferencialmente usar ringbuffer internamente**; +- **nao** deve virar contrato semantico do mundo nem da `Scene`; +- a principal vantagem aparece justamente no padrao aceito de atualizacao por linha/coluna e por area nos cantos; +- com layers de parallax, esse beneficio aumenta, porque cada layer pode materializar sua propria janela efetiva a partir do totem mestre sem precisar copiar janelas inteiras sempre. + +Direcao aceita: + +- `SceneViewportCache` usa armazenamento em anel internamente como implementacao preferida; +- existe **um ringbuffer por layer**; +- isso fica encapsulado dentro do cache; +- `Scene`, `SceneLayer` e `TileMap` continuam semanticamente simples; +- o renderer deve consumir o cache como view materializada, nao como estrutura ciclica exposta. +- na primeira leva, o blit/composite no `back` ainda pode continuar destrutivo; +- o ganho esperado do ringbuffer fica principalmente em evitar copias internas repetidas do proprio cache antes do blit final. + +### Clamp de borda + +Direcao atual: + +- o totem e um valor de referencia em `x/y`; +- o clamp inicial pode ser pensado como: + - minimos em torno de `w/2` e `h/2` + - maximos em torno de `layer_size - (w/2, h/2)` +- nos cantos, camera e cache nao precisam coincidir de forma simetrica; +- o cache pode ficar clampado ao limite do `SceneBank`. + +## Composicao no Back + +Fica aceito que: + +- `HUD` continua sempre por cima; +- `sprites` podem aparecer entre layers; +- a ordem observavel de composicao continua sendo algo como: + - `layer 0` + - `sprites relevantes` + - `layer 1` + - `sprites relevantes` + - `layer 2` + - `sprites relevantes` + - `layer 3` + - `sprites relevantes` + - `HUD` + +Leitura atual: + +- ainda pode haver redraw completo da ordem de composicao observavel; +- o ganho principal esperado vem de parar de resolver o tilemap bruto toda vez e passar a compor a partir do `SceneViewportCache`; +- `sprites`, `HUD` e `fades` ficam separados da politica de invalidacao do cache de viewport. + +Invariantes aceitos: + +1. `SceneViewportCache` nao e fonte da verdade de dados, mas e a fonte imediata de copia para o blit; +2. `sprites`, `HUD` e `fades` nao entram no cache; +3. a ordem observavel continua: + - `layer 0` + - sprites intermediarios + - `layer 1` + - sprites intermediarios + - `layer 2` + - sprites intermediarios + - `layer 3` + - sprites intermediarios + - `HUD` + +## Open Questions Prioritarias + +### 1. Shape do `SceneBank` + +- **Direcao aceita:** o agregado canonico passa a se chamar **`Scene`**, com `BankType::SCENE`. +- **Direcao revisada:** `background` e `parallax` entram no mesmo contrato de `SceneLayer`, diferenciados por seus metadados e fator de movimento relativo ao totem mestre. +- **Direcao aceita:** a `Scene` deve expor leitura/escrita por tile **e** operacoes por regiao/faixa. + +### 2. Shape do `SceneViewportCache` + +- **Direcao aceita:** o cache e um unico agregado por scene, contendo as 4 layers internamente. +- **Direcao aceita:** entram na V1 campos derivados leves para acelerar raster, incluindo `glyph_id`, `palette_id`, flags de flip, marca de `active/empty` e referencia rapida de bank por layer. +- **Direcao aceita:** o cache usara ringbuffer internamente desde a V1 como implementacao preferida, com um ringbuffer por layer. + +### 3. Resolver camera -> cache + +- **Direcao aceita:** drift, trigger, clamp e posicao do totem moram no resolver. +- **Direcao aceita:** o resolver trabalha com um totem mestre e deriva deslocamentos efetivos por layer quando houver fator de movimento diferente. +- **Direcao aceita:** o cache permanece focado em armazenar a materializacao para render. + +### 4. Composicao incremental + +- **Leitura aceita desta agenda:** neste momento nao ha seguranca para assumir algo mais sofisticado do que composicao destrutiva total da ordem observavel no `back`. +- **Direcao atual:** o ganho esperado continua vindo de deixar de resolver o tilemap bruto toda vez, mesmo que a composicao no `back` continue destrutiva. + +### 5. Camera + +- **Direcao aceita:** camera completa fica fora desta decisao. +- **Direcao aceita:** esta decisao trabalha apenas com uma API que tenta mover o totem; o contrato completo de camera sera discutido separadamente. + +## Criterio para Encerrar + +Esta agenda pode ser encerrada quando houver alinhamento explicito sobre: + +- shape minimo de `Scene`, `SceneLayer`, `TileMap`, `Tile`, `SceneViewportCache` e `SceneViewportResolver`; +- contrato do resolver camera -> viewport cache; +- politica de rematerializacao por faixa/regiao; +- relacao entre `SceneViewportCache` e composicao no `back`; +- invariantes que devem aparecer na decisao posterior para renderer, camera e scene loading. + +## Estado Atual da Agenda + +Leitura consolidada desta agenda neste momento: + +- a direcao arquitetural principal ja esta aceita; +- o tema deixou de ser ringbuffer generico e virou refactor do sistema de tilemap/render para `Scene` + `SceneViewportCache`; +- `background` e `parallax` passam a ser absorvidos pelo proprio contrato de `SceneLayer`, via fator de movimento relativo ao totem mestre; +- `ScrollableTileLayer` deixa de existir na arquitetura alvo; +- a decisao seguinte deve cristalizar tipos, responsabilidades e invariantes, em vez de reabrir o debate macro. diff --git a/discussion/workflow/decisions/DEC-0013-scene-bank-and-viewport-cache-model.md b/discussion/workflow/decisions/DEC-0013-scene-bank-and-viewport-cache-model.md new file mode 100644 index 00000000..57e30201 --- /dev/null +++ b/discussion/workflow/decisions/DEC-0013-scene-bank-and-viewport-cache-model.md @@ -0,0 +1,227 @@ +--- +id: DEC-0013 +ticket: scene-bank-and-viewport-cache-refactor +title: Decision - Scene Bank and Viewport Cache Model +status: accepted +created: 2026-04-13 +accepted: 2026-04-13 +agenda: AGD-0025 +plans: [PLN-0011, PLN-0012, PLN-0013, PLN-0014, PLN-0015] +tags: [gfx, tilemap, runtime, render] +--- + +## Status + +Accepted. + +This decision normatively locks the V1 model for scene-backed tilemap rendering around: + +- `Scene` as canonical loaded state; +- `SceneLayer` as the canonical layer unit; +- `SceneViewportCache` as the materialized render cache; +- `SceneViewportResolver` as the owner of totems, drift, hysteresis, clamp, and cache update decisions. + +## Contexto + +The current runtime couples three concerns too tightly: + +- canonical layer data; +- scroll state; +- direct renderer consumption. + +In the current implementation, the `64x64` maps created for scrollable layers are not an independent viewport cache. They are the actual layer maps consumed directly by the renderer. That shape makes it harder to: + +- keep canonical world state simple; +- materialize only the viewport-relevant subset for render; +- evolve camera separately from the tilemap model; +- reduce repeated tilemap resolution work before the final destructive blit into `back`. + +During agenda `AGD-0025`, the discussion initially explored ringbuffer as a general topic, but converged on a different architectural target: a clean split between canonical scene state and render-oriented viewport materialization. + +## Decisao + +The runtime SHALL adopt the following V1 model: + +1. Canonical loaded tilemap state SHALL be represented by `Scene`. +2. A `Scene` SHALL contain four canonical `SceneLayer`s. +3. Each `SceneLayer` SHALL own its canonical `TileMap`. +4. Each `TileMap` SHALL remain the canonical grid of `Tile`s for that layer. +5. The renderer MUST NOT consume canonical `Scene` data directly for normal world composition. +6. The renderer SHALL consume a `SceneViewportCache` derived from `Scene`. +7. `SceneViewportCache` SHALL be operational render state only and MUST NOT become the semantic source of truth for gameplay or physics. +8. `SceneViewportCache` SHOULD use internal ringbuffer storage as the preferred V1 implementation strategy. +9. Ringbuffer details MUST remain encapsulated inside `SceneViewportCache` and MUST NOT leak into the semantic contract of `Scene`, `SceneLayer`, `TileMap`, or `Tile`. +10. `ScrollableTileLayer` SHALL be removed from the architecture and MUST NOT be preserved as a canonical runtime concept. +11. `background` and `parallax` SHALL remain expressible through `SceneLayer` itself, not through a separate V1 background type. +12. Each `SceneLayer` SHALL carry a relative movement factor against a master totem, allowing normal layers, background-like layers, and parallax-like layers under the same layer contract. + +## Rationale + +This decision prefers architectural clarity over preserving the current type graph. + +The key arguments are: + +- `Tile` remains light enough that keeping canonical tilemaps resident is acceptable in the current baseline. +- The more pressing cost appears to be repeated world resolution and composition work, not merely the existence of tilemap data in memory. +- A dedicated viewport cache gives the renderer a better contract without forcing gameplay and physics to depend on residency mechanics. +- Internal ringbuffering in the cache is useful because the first expected gain is avoiding repeated cache-side copies, even if final composition into `back` remains destructive in V1. +- Keeping parallax inside `SceneLayer` avoids prematurely splitting the scene model into separate layer families while still allowing differentiated movement behavior. + +This model also keeps room for later refinement: + +- richer camera semantics can evolve separately; +- background-specific types can still be introduced later if the layer contract becomes insufficient; +- bank-type wiring can still be finalized during planning/implementation; +- cache implementation can improve while the canonical scene contract remains stable. + +## Invariantes / Contrato + +### 1. Canonical Scene Model + +- `Scene` SHALL be the canonical loaded aggregate. +- `Scene` SHALL contain four canonical `SceneLayer`s. +- `SceneLayer` SHALL be the canonical replacement for the old `TileLayer`. +- `SceneLayer` SHALL minimally include: + - `glyph_bank_id` + - `tile_size` + - `active` + - `totem_factor (x, y)` + - `tilemap` +- `TileMap` SHALL remain a canonical grid of `Tile`. +- `Tile` SHALL remain lightweight and MUST NOT be expanded into resolved RGB pixel payload for canonical storage. + +### 2. Viewport Cache Model + +- `SceneViewportCache` SHALL be a single cache aggregate for one `Scene`. +- It SHALL contain four internal layer caches, one per canonical scene layer. +- The preferred V1 implementation is one internal ringbuffer per layer cache. +- Ringbuffer details MUST stay internal to the cache implementation. +- `SceneViewportCache` MAY store lightweight derived fields to accelerate raster, including: + - resolved `glyph_id` + - resolved `palette_id` + - packed flip flags + - `active/empty` markers + - fast layer-local glyph bank references +- `SceneViewportCache` MUST NOT duplicate full physics state unless a later decision explicitly requires it. +- `SceneViewportCache` SHALL be the immediate source of copy data for world blits, but MUST NOT become the source of truth for world semantics. + +### 3. Resolver Contract + +- `SceneViewportResolver` SHALL own: + - master totem + - per-layer derived totems + - drift calculation + - hysteresis + - clamp logic + - cache update decisions +- An external caller SHALL provide camera position to the resolver. +- The resolver SHALL treat that camera position as input for the master totem flow. +- The resolver SHALL decide whether cache updates are needed. +- The resolver SHALL trigger cache update operations when needed. +- The resolver SHALL know how to instrument per-layer copy requests from the cache to the final compositor. +- The resolver MUST NOT perform the actual framebuffer copy itself. + +### 4. Viewport Size and Cache Update Policy + +- V1 world viewport cache size SHALL be materially larger than the minimum visible world tile window and MUST include explicit halo for cache stability. +- An initial working target in the order of `25x16` tiles is accepted as planning guidance, but exact numeric sizing MAY be finalized in the implementation plan. +- Normal cache update SHALL occur by line and/or column. +- When simultaneous X and Y movement requires corner refresh, the cache MUST support area/region refresh to avoid reloading tiles already present. +- Swapping to a different `Scene` SHALL fully invalidate `SceneViewportCache`. +- Normal camera motion MUST NOT invalidate the whole viewport cache as the default path. + +### 5. Totem, Drift, and Hysteresis + +- The resolver SHALL use a master totem in tile space. +- Camera input SHALL remain in pixel space. +- For `16x16` tiles, the master totem center SHALL be computed as: + - `cx = 16 * i + 8` + - `cy = 16 * j + 8` +- Drift SHALL be computed as: + - `dx = x - cx` + - `dy = y - cy` +- V1 SHALL use hysteresis with: + - an internal safe band where no cache movement occurs; + - an external trigger band that advances the totem and requests cache refresh. +- Initial working values in the order of `safe = 12 px` and `trigger = 20 px` are accepted as planning guidance, but exact numeric tuning MAY be finalized in the implementation plan. +- Inside the safe band, no cache movement SHALL occur. +- Between safe and trigger, the system SHALL tolerate drift without rematerialization. +- Beyond trigger, the resolver SHALL move the totem discretely in tile steps and request cache refresh. +- Hysteresis is mandatory in V1 to prevent edge flick/thrash. + +### 6. Clamp Behavior + +- The resolver SHALL clamp the master totem against scene bounds. +- Initial clamp reasoning SHALL be based on minimums around `(w/2, h/2)` and maximums around `layer_size - (w/2, h/2)`. +- Near bounds, the cache MAY remain asymmetrically aligned relative to camera expectations. +- That asymmetry at scene edges SHALL be considered expected behavior, not an error. + +### 7. Composition Contract + +- `HUD` SHALL remain above world composition. +- `sprites` MAY appear between canonical world layers. +- The observable composition order SHALL remain: + - `layer 0` + - intermediate sprites + - `layer 1` + - intermediate sprites + - `layer 2` + - intermediate sprites + - `layer 3` + - intermediate sprites + - `HUD` +- `sprites`, `HUD`, and `fades` MUST remain outside the invalidation contract of `SceneViewportCache`. +- V1 MAY still use destructive full composition ordering in `back`. +- The expected win in V1 comes from avoiding repeated brute-force canonical tilemap resolution before that final destructive composition. + +## Impactos + +This decision impacts the runtime model directly: + +- `TileLayer` will be replaced by `SceneLayer`. +- `ScrollableTileLayer` will be removed. +- bank integration and naming at the asset-bank enum level SHALL be aligned during planning/implementation. +- The renderer contract will shift from direct map consumption to cache consumption. +- Scene loading, viewport cache maintenance, and composition responsibilities become explicitly separated. + +Expected propagation areas: + +- bank/domain model types; +- tilemap/layer runtime structures; +- renderer world composition flow; +- camera-to-render adapter logic; +- future plan/implementation artifacts derived from this decision. + +## Referencias + +- Agenda: [AGD-0025-scene-bank-and-viewport-cache-refactor.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/discussion/workflow/agendas/AGD-0025-scene-bank-and-viewport-cache-refactor.md) +- Current tile/layer model: + - [tile_layer.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile_layer.rs:1) + - [tile.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile.rs:1) + - [glyph.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/glyph.rs:1) +- Current renderer usage: + - [gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs:291) + - [gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs:594) + - [gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs:673) + +## Propagacao Necessaria + +The following work MUST derive from this decision: + +1. Introduce the new canonical runtime types: + - `Scene` + - `SceneLayer` + - `SceneViewportCache` + - `SceneViewportResolver` +2. Remove or retire `ScrollableTileLayer`. +3. Migrate renderer world composition to consume `SceneViewportCache`. +4. Define the concrete cache update API for: + - line + - column + - area/region +5. Define the concrete blit instrumentation API emitted by the resolver. +6. Write an implementation plan before code changes. + +## Revision Log + +- 2026-04-13: Initial accepted decision from AGD-0025. diff --git a/discussion/workflow/plans/PLN-0011-scene-core-types-and-bank-contract.md b/discussion/workflow/plans/PLN-0011-scene-core-types-and-bank-contract.md new file mode 100644 index 00000000..37db1712 --- /dev/null +++ b/discussion/workflow/plans/PLN-0011-scene-core-types-and-bank-contract.md @@ -0,0 +1,152 @@ +--- +id: PLN-0011 +ticket: scene-bank-and-viewport-cache-refactor +title: Plan - Scene Core Types and Bank Contract +status: accepted +created: 2026-04-13 +completed: +tags: [gfx, tilemap, runtime, render] +--- + +## Objective + +Introduce the canonical type model required by `DEC-0013`: `SceneBank`, `SceneLayer`, `TileMap`, and `Tile`, plus the asset-bank/domain contract changes needed to carry scene-backed tilemap data as the new source of truth. + +## Background + +`DEC-0013` locks the canonical runtime model around `SceneBank` and `SceneLayer`, replacing the old `TileLayer`/`ScrollableTileLayer` coupling. This plan isolates the domain-model and bank-contract refactor before cache or renderer migration begins. + +## Scope + +### Included +- Introduce `SceneBank` and `SceneLayer` in the HAL/runtime model. +- Preserve `TileMap` and `Tile` as canonical grid/unit concepts. +- Define the minimum `SceneLayer` fields required by the decision. +- Update bank/domain enums and payload contracts so scenes can exist as first-class banked content. +- Add compile-time migration shims only when strictly needed to keep the tree buildable during the refactor. + +### Excluded +- `SceneViewportCache` +- `SceneViewportResolver` +- renderer migration to cache consumption +- rematerialization algorithm +- final composition changes +- final binary `SCENE` payload format and decoder implementation + +## Execution Steps + +### Step 1 - Introduce canonical scene types in HAL + +**What:** +Add `SceneBank` and `SceneLayer` to the HAL model and define the minimum canonical fields required by the decision. + +**How:** +- Replace or retire the old `TileLayer`-centric model in [tile_layer.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile_layer.rs). +- Keep `TileMap` as the canonical grid owner and `Tile` as the canonical cell unit. +- Define `SceneLayer` with: + - `glyph_bank_id` + - `tile_size` + - `active` + - `motion_factor` + - `tilemap` +- Define `SceneBank` as the aggregate of exactly four `SceneLayer`s using a fixed array. + +**File(s):** +- [crates/console/prometeu-hal/src/tile_layer.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile_layer.rs) +- [crates/console/prometeu-hal/src/tile.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile.rs) +- [crates/console/prometeu-hal/src/glyph.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/glyph.rs) +- HAL module export files as needed + +### Step 2 - Remove `ScrollableTileLayer` from the canonical model + +**What:** +Delete the canonical role of `ScrollableTileLayer` and any direct ownership path that makes scroll part of the canonical scene representation. + +**How:** +- Remove `ScrollableTileLayer` and `HudTileLayer` from the HAL tile-layer model or reduce them to non-canonical transitional wrappers only if needed to keep compilation moving during the refactor. +- Ensure canonical scene state no longer encodes direct renderer scroll ownership. + +**File(s):** +- [crates/console/prometeu-hal/src/tile_layer.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile_layer.rs) +- [crates/console/prometeu-hal/src/gfx_bridge.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/gfx_bridge.rs) + +### Step 3 - Introduce the bank/domain contract for scene-backed content + +**What:** +Add or revise the asset-bank/domain model so the canonical loaded scene can be represented in the bank contract. + +**How:** +- Update `BankType` and related asset/bank metadata in HAL to support `Scene`-backed content at the domain level. +- Adjust slot/reference types where necessary so scene content can be addressed consistently with existing banks. +- Keep the exact asset-bank enum wiring minimal and domain-first; do not conflate it with renderer cache details. + +**File(s):** +- [crates/console/prometeu-hal/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/asset.rs) +- [crates/console/prometeu-hal/src/syscalls/domains/bank.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/syscalls/domains/bank.rs) +- [crates/console/prometeu-hal/src/syscalls/domains/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/syscalls/domains/asset.rs) +- Any bank-facing shared types used by runtime and drivers + +### Step 4 - Thread the new scene types through driver-facing runtime structures + +**What:** +Prepare the driver layer to reference canonical `SceneBank` types instead of old layer/map ownership. + +**How:** +- Identify the driver-side structs currently owning the old layer model. +- Replace those type references with the new canonical scene types without yet switching renderer logic to the viewport cache. +- Keep this step focused on compile-safe type replacement, not behavior changes. + +**File(s):** +- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs) +- [crates/console/prometeu-drivers/src/hardware.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/hardware.rs) + +### Step 5 - Add baseline tests for the canonical scene model + +**What:** +Protect the new canonical scene types with focused tests before cache and renderer work begins. + +**How:** +- Add unit tests for: + - scene-bank construction + - layer ownership of tilemaps + - tile write/read behavior + - preservation of `motion_factor` +- Keep tests domain-focused and independent of viewport caching. + +**File(s):** +- HAL tests colocated with scene/tile modules +- Driver tests only if required to preserve build integrity + +## Test Requirements + +### Unit Tests +- `SceneBank` owns exactly four canonical `SceneLayer`s. +- `SceneLayer` exposes the required minimal fields. +- `TileMap` indexing and mutation still behave canonically. +- `motion_factor` survives construction and mutation paths. + +### Integration Tests +- Asset/bank domain types accept the new scene-backed contract without breaking existing glyph/sound usage. +- `SCENE` binary decode remains intentionally open and out of scope for this plan. + +### Manual Verification +- Build the console crates and verify no canonical runtime path still treats `ScrollableTileLayer` as the source of truth. + +## Acceptance Criteria + +- [ ] `SceneBank` and `SceneLayer` exist as canonical runtime types. +- [ ] `ScrollableTileLayer` is no longer the canonical scene model. +- [ ] Bank/domain types can represent scene-backed content. +- [ ] Driver-facing runtime structures compile against the new canonical scene model. +- [ ] Baseline tests protect the new type graph. + +## Dependencies + +- Source decision: `DEC-0013` +- No dependency on later plans; this is the foundation for the rest of the family. + +## Risks + +- Breaking too many public or cross-crate type references at once can stall compilation. +- Bank enum changes can ripple into syscalls and telemetry if not scoped carefully. +- Transitional wrappers can accidentally survive longer than intended if not explicitly retired in later plans. diff --git a/discussion/workflow/plans/PLN-0012-scene-viewport-cache-structure.md b/discussion/workflow/plans/PLN-0012-scene-viewport-cache-structure.md new file mode 100644 index 00000000..3a02b899 --- /dev/null +++ b/discussion/workflow/plans/PLN-0012-scene-viewport-cache-structure.md @@ -0,0 +1,147 @@ +--- +id: PLN-0012 +ticket: scene-bank-and-viewport-cache-refactor +title: Plan - Scene Viewport Cache Structure +status: accepted +created: 2026-04-13 +completed: +tags: [gfx, tilemap, runtime, render] +--- + +## Objective + +Implement the `SceneViewportCache` as the operational render cache for one `SceneBank`, including one internal ringbuffer per layer and lightweight derived cache entries for raster acceleration. + +## Background + +`DEC-0013` locks `SceneViewportCache` as the renderer-facing view of scene data and prefers internal ringbuffer storage per layer. `PLN-0011` already established the canonical source as `SceneBank` with exactly four `SceneLayer`s in a fixed array and moved per-layer movement metadata to `motion_factor`. This plan isolates the cache data structure and update API before resolver and renderer integration. + +## Scope + +### Included +- Define `SceneViewportCache`. +- Define the per-layer internal ringbuffer structure. +- Define the cached tile entry format. +- Define cache update APIs for line, column, and area/region. +- Define full invalidation on scene swap. + +### Excluded +- camera logic +- drift/hysteresis logic +- final renderer migration +- execution of blits into `back` + +## Execution Steps + +### Step 1 - Define cache entry and layer-cache structures + +**What:** +Create the internal data model for one viewport cache layer and for cached tile entries. + +**How:** +- Introduce a cache tile entry type that stores: + - resolved `glyph_id` + - resolved `palette_id` + - packed flip flags + - `active/empty` + - fast layer-local glyph bank reference/index +- Introduce one ringbuffer-backed layer-cache structure per scene layer. +- Keep ringbuffer internals encapsulated and out of public semantic APIs. + +**File(s):** +- New HAL or driver-side viewport-cache module(s), likely under: + - `crates/console/prometeu-hal/src/` + - and/or `crates/console/prometeu-drivers/src/` + +### Step 2 - Define `SceneViewportCache` as a four-layer aggregate + +**What:** +Create the top-level cache aggregate for one `SceneBank`. + +**How:** +- Represent one cache aggregate containing four internal layer caches aligned to `SceneBank.layers`. +- Thread through cache dimensions and tile-size assumptions for V1. +- Make the cache explicitly scene-derived and non-canonical. + +**File(s):** +- New cache module(s) +- Any related exports in HAL/driver public surfaces + +### Step 3 - Implement cache mutation APIs + +**What:** +Define the update surface that later plans will use for rematerialization. + +**How:** +- Add explicit APIs for: + - refresh line + - refresh column + - refresh area/region + - invalidate whole cache on scene swap +- Ensure corner refresh can use region updates without duplicate work on already-present tiles. + +**File(s):** +- New cache module(s) +- Any helper modules shared with resolver integration + +### Step 4 - Implement scene-to-cache materialization helpers + +**What:** +Add helpers that copy canonical scene data into cache entries. + +**How:** +- Build helpers that read canonical `SceneBank` / `SceneLayer` / `TileMap` data and populate cache entries. +- Keep the helpers unaware of camera policy; they should only perform requested materialization work. +- Ensure per-layer movement factors remain metadata of the scene layer, not cache-only state. + +**File(s):** +- New cache/materialization helper module(s) + +### Step 5 - Add focused tests for cache structure and update rules + +**What:** +Protect the cache shape and update operations before wiring the resolver. + +**How:** +- Add tests for: + - ringbuffer wrap behavior + - line refresh + - column refresh + - region refresh + - scene-swap invalidation + - no duplicate reload for corner-style region updates + +**File(s):** +- Tests colocated with cache modules + +## Test Requirements + +### Unit Tests +- Each cache layer maintains ringbuffer invariants under wrap. +- Cache entry fields match the expected derived values from canonical scene tiles. +- Line/column/region refresh APIs only rewrite the requested area. + +### Integration Tests +- Materialization from `SceneBank` into `SceneViewportCache` succeeds across all four layers. + +### Manual Verification +- Inspect debug output or temporary probes to confirm cache updates do not expose ringbuffer details outside the cache boundary. + +## Acceptance Criteria + +- [ ] `SceneViewportCache` exists as a four-layer aggregate. +- [ ] Each layer cache uses internal ringbuffer storage. +- [ ] Cache entries store the lightweight derived raster fields defined by the decision. +- [ ] Explicit APIs exist for line, column, area/region, and full invalidation. +- [ ] Tests cover wrap and non-duplicative corner updates. + +## Dependencies + +- Depends on `PLN-0011` for canonical `SceneBank` / `SceneLayer` / `TileMap` types and the fixed four-layer shape. +- Source decision: `DEC-0013` + +## Risks + +- Over-designing cache entry shape can accidentally turn the cache into a second heavy scene representation. +- Ringbuffer implementation bugs can stay hidden until resolver integration if tests are too weak. +- Region refresh semantics can become ambiguous if API boundaries are not explicit early. diff --git a/discussion/workflow/plans/PLN-0013-scene-viewport-resolver-and-rematerialization.md b/discussion/workflow/plans/PLN-0013-scene-viewport-resolver-and-rematerialization.md new file mode 100644 index 00000000..2ce22921 --- /dev/null +++ b/discussion/workflow/plans/PLN-0013-scene-viewport-resolver-and-rematerialization.md @@ -0,0 +1,161 @@ +--- +id: PLN-0013 +ticket: scene-bank-and-viewport-cache-refactor +title: Plan - Scene Viewport Resolver and Rematerialization +status: accepted +created: 2026-04-13 +completed: +tags: [gfx, tilemap, runtime, render] +--- + +## Objective + +Implement `SceneViewportResolver` as the owner of master anchor, per-layer anchors, drift, hysteresis, clamp, cache update decisions, and per-layer copy instrumentation metadata. + +## Background + +`DEC-0013` makes the resolver the owner of movement policy between camera input and viewport cache updates. `PLN-0011` already fixed the canonical source as `SceneBank` and renamed per-layer movement metadata to `motion_factor`. This plan isolates the decision logic before final renderer migration. + +## Scope + +### Included +- Define `SceneViewportResolver`. +- Implement master anchor and per-layer derived anchors. +- Implement drift and hysteresis. +- Implement clamp behavior. +- Implement cache-update triggering and copy instrumentation metadata. + +### Excluded +- actual framebuffer copy execution +- sprite/HUD/fade composition logic +- broad renderer migration + +## Execution Steps + +### Step 1 - Define resolver state and inputs + +**What:** +Create the resolver state model and its public input surface. + +**How:** +- Define resolver state to own: + - master anchor + - per-layer derived anchors + - viewport dimensions + - hysteresis thresholds + - clamp-relevant scene bounds +- Define the external entry point that accepts camera position input. + +**File(s):** +- New resolver module(s), likely under HAL or drivers depending on final ownership + +### Step 2 - Implement master-to-layer totem derivation + +**What:** +Make layer motion derive from the master anchor using each layer’s `motion_factor`. + +**How:** +- Compute per-layer effective motion from the master anchor. +- Preserve support for normal layers (`1.0`) and parallax/background-like layers (`!= 1.0`). +- Keep the derivation explicit and testable per axis. + +**File(s):** +- Resolver module(s) +- Any math/helper module(s) needed for factor handling + +### Step 3 - Implement drift, hysteresis, and clamp + +**What:** +Translate camera motion into discrete anchor advancement safely. + +**How:** +- Implement drift calculation in pixel space against anchor centers in tile space. +- Implement hysteresis with: + - internal safe band + - external trigger band +- Implement clamp against scene bounds. +- Ensure edge behavior is explicitly asymmetric when clamped. + +**File(s):** +- Resolver module(s) + +### Step 4 - Connect resolver decisions to cache update requests + +**What:** +Turn resolver state changes into concrete cache update requests. + +**How:** +- Emit requests for: + - line refresh + - column refresh + - area/region refresh for corner updates +- Ensure no duplicate work is scheduled for already-covered cache content. +- Keep the resolver in charge of “what to refresh,” not “how cache storage performs it.” + +**File(s):** +- Resolver module(s) +- Shared request/command structs between resolver and cache + +### Step 5 - Add copy instrumentation outputs for later renderer use + +**What:** +Make the resolver capable of describing which cache slice/region should be copied for each layer. + +**How:** +- Define a per-layer copy request/instrumentation type. +- Include enough information for a later compositor to copy from cache into `back` without re-deciding viewport math. +- Do not execute the copy here. + +**File(s):** +- Resolver module(s) +- Shared copy-request types + +### Step 6 - Add focused resolver tests + +**What:** +Protect resolver correctness before renderer integration. + +**How:** +- Add tests for: + - master anchor updates + - per-layer motion-factor derivation + - hysteresis stability + - repeated high-speed movement + - clamp at scene edges + - corner-trigger conversion into region refresh requests + +**File(s):** +- Tests colocated with resolver modules + +## Test Requirements + +### Unit Tests +- Hysteresis prevents edge flick/thrash. +- Anchor movement occurs discretely in tile steps. +- Per-layer anchors follow the master according to `motion_factor`. +- Clamp behavior is correct near scene edges. + +### Integration Tests +- Resolver outputs valid update requests consumable by `SceneViewportCache`. + +### Manual Verification +- Instrument logs or debug traces to confirm no unnecessary refresh churn during back-and-forth movement near edges. + +## Acceptance Criteria + +- [ ] `SceneViewportResolver` exists with the full state required by `DEC-0013`. +- [ ] Master and per-layer anchors are derived correctly. +- [ ] Hysteresis and clamp are implemented and tested. +- [ ] Cache refresh requests are emitted by line, column, and region as required. +- [ ] Copy instrumentation metadata is available for later renderer use. + +## Dependencies + +- Depends on `PLN-0011` and `PLN-0012`. +- Source decision: `DEC-0013` + +## Risks + +- Resolver logic can become hard to reason about if movement, clamp, and copy instrumentation are not clearly separated. +- Parallax factor derivation can introduce subtle off-by-one or drift mismatch issues per layer. +- Region-update scheduling can duplicate work if X/Y corner movement is not normalized carefully. diff --git a/discussion/workflow/plans/PLN-0014-renderer-migration-to-scene-viewport-cache.md b/discussion/workflow/plans/PLN-0014-renderer-migration-to-scene-viewport-cache.md new file mode 100644 index 00000000..4de33d2c --- /dev/null +++ b/discussion/workflow/plans/PLN-0014-renderer-migration-to-scene-viewport-cache.md @@ -0,0 +1,142 @@ +--- +id: PLN-0014 +ticket: scene-bank-and-viewport-cache-refactor +title: Plan - Renderer Migration to Scene Viewport Cache +status: accepted +created: 2026-04-13 +completed: +tags: [gfx, tilemap, runtime, render] +--- + +## Objective + +Migrate world rendering from direct canonical `SceneBank` consumption to `SceneViewportCache` consumption while preserving the accepted observable composition order. + +## Background + +`DEC-0013` makes `SceneViewportCache` the immediate source of copy data for world blits and preserves the visible order of world layers, interleaved sprites, and HUD. `PLN-0011` already removed the old canonical `TileLayer` / `ScrollableTileLayer` model and cleaned the bridge surface accordingly. This plan focuses on the renderer migration itself. + +## Scope + +### Included +- Remove direct world-layer map reads from the renderer hot path. +- Make world-layer composition consume cache-backed copy requests. +- Preserve sprite interleaving and HUD ordering. +- Keep V1 destructive `back` composition acceptable. + +### Excluded +- scene domain model refactor +- cache structural implementation +- resolver movement logic +- non-world rendering systems beyond required integration points + +## Execution Steps + +### Step 1 - Identify and isolate direct canonical map consumption in the renderer + +**What:** +Locate current renderer paths that read canonical scene/layer maps directly. + +**How:** +- Replace any remaining direct `SceneBank` / `SceneLayer` / `TileMap` reads in [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs) with abstraction points prepared to accept cache-sourced copy requests. +- Keep the migration incremental enough to preserve buildability. + +**File(s):** +- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs) + +### Step 2 - Introduce world-layer copy paths driven by cache requests + +**What:** +Teach the renderer to copy world content from `SceneViewportCache`. + +**How:** +- Add renderer-facing entry points that consume per-layer copy instrumentation emitted by the resolver. +- Ensure the renderer treats the cache as the immediate world source for blit operations. +- Do not let the renderer re-own totem/drift/clamp policy. + +**File(s):** +- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs) +- Supporting cache/resolver integration modules + +### Step 3 - Preserve accepted composition order + +**What:** +Keep the visible ordering of layers, sprites, and HUD intact during migration. + +**How:** +- Preserve composition order: + - world layer 0 + - sprites + - world layer 1 + - sprites + - world layer 2 + - sprites + - world layer 3 + - sprites + - HUD +- Keep `HUD`, sprites, and fades outside the viewport-cache invalidation model. + +**File(s):** +- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs) + +### Step 4 - Preserve destructive `back` composition for V1 + +**What:** +Accept destructive composition while still benefiting from cache-backed world inputs. + +**How:** +- Keep the first migrated renderer implementation destructive in `back`. +- Ensure the code path clearly separates: + - canonical scene data + - viewport cache data + - final composition buffer +- Avoid mixing “cache update” with “final buffer copy” concerns in the renderer. + +**File(s):** +- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs) + +### Step 5 - Add renderer regression coverage + +**What:** +Protect the migrated composition order and world rendering behavior. + +**How:** +- Add tests or golden-style checks for: + - world layers rendered from cache, not canonical maps + - visible ordering of sprites between layers + - HUD remaining on top +- Add probes or assertions preventing accidental fallback to direct canonical map reads. + +**File(s):** +- Renderer tests in [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs) and adjacent test modules + +## Test Requirements + +### Unit Tests +- Cache-driven world copy code produces the same visible layer ordering as the prior renderer. + +### Integration Tests +- Full `render_all()` path uses `SceneViewportCache` for world layers. +- Sprites/HUD/fades remain correctly ordered relative to world layers. + +### Manual Verification +- Render representative scenes with multiple active layers and sprites to confirm no visible ordering regressions. + +## Acceptance Criteria + +- [ ] Renderer world composition no longer reads canonical `SceneBank` data directly in the hot path. +- [ ] World layers are copied from `SceneViewportCache`. +- [ ] Sprite interleaving and HUD ordering remain correct. +- [ ] V1 destructive composition still works end-to-end. +- [ ] Regression coverage protects the migrated path. + +## Dependencies + +- Depends on `PLN-0011`, `PLN-0012`, and `PLN-0013`. +- Source decision: `DEC-0013` + +## Risks + +- Renderer migration can accidentally duplicate world-copy work if cache and compositor responsibilities blur. +- Sprite ordering regressions are easy to introduce during world-layer refactoring. +- Temporary fallback paths can linger unless tests explicitly block them. diff --git a/discussion/workflow/plans/PLN-0015-api-bank-integration-and-tests.md b/discussion/workflow/plans/PLN-0015-api-bank-integration-and-tests.md new file mode 100644 index 00000000..6c79d2db --- /dev/null +++ b/discussion/workflow/plans/PLN-0015-api-bank-integration-and-tests.md @@ -0,0 +1,124 @@ +--- +id: PLN-0015 +ticket: scene-bank-and-viewport-cache-refactor +title: Plan - API, Bank Integration, and Tests +status: accepted +created: 2026-04-13 +completed: +tags: [gfx, tilemap, runtime, render] +--- + +## Objective + +Finish the scene/viewport-cache migration by aligning exposed APIs, final integration surfaces, and test coverage across HAL, drivers, and system entry points. + +## Background + +After core types, cache, resolver, and renderer migration exist, the remaining work is to align the surrounding surfaces so the new architecture is usable as the runtime’s operational model. `PLN-0011` already removed the old canonical tile-layer APIs and introduced `BankType::SCENE` plus scene-aware bank slots. The binary `SCENE` payload contract and decoder are now isolated in `PLN-0016`. + +## Scope + +### Included +- Update bridge APIs and exposed runtime surfaces. +- Remove stale tile-layer-era interfaces. +- Add cross-layer regression coverage. + +### Excluded +- additional architectural redesign +- feature work beyond the accepted decision + +## Execution Steps + +### Step 1 - Update HAL and bridge surfaces + +**What:** +Remove or replace public APIs still shaped around the old scrollable tile-layer model. + +**How:** +- Verify `GfxBridge` and adjacent bridge traits stay free of obsolete canonical layer ownership assumptions after cache/resolver integration. +- Introduce scene/cache/resolver-oriented access only where required by runtime consumers. + +**File(s):** +- [crates/console/prometeu-hal/src/gfx_bridge.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/gfx_bridge.rs) +- Other bridge traits affected by scene model changes + +### Step 2 - Align driver/hardware construction paths + +**What:** +Ensure hardware and driver initialization paths can construct and own the new scene/cache/resolver model. + +**How:** +- Update hardware bootstrap and driver ownership paths to instantiate the canonical scene and viewport-cache stack. +- Remove any remaining ownership assumptions tied to the pre-`SceneBank` model. + +**File(s):** +- [crates/console/prometeu-drivers/src/hardware.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/hardware.rs) +- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs) + +### Step 3 - Remove stale type and API remnants + +**What:** +Clean out old interfaces that would leave the codebase in a dual-model state. + +**How:** +- Remove any remaining obsolete references to: + - `TileLayer` + - `ScrollableTileLayer` + - direct old-layer scroll ownership APIs + - transitional commented fallback paths left during migration +- Keep only the canonical scene model and the viewport-cache/render pipeline required by the decision. + +**File(s):** +- HAL and driver modules touched by earlier plans + +### Step 4 - Add full-stack regression coverage + +**What:** +Add the minimum test family needed to execute the migration safely one plan at a time. + +**How:** +- Add tests for: + - canonical scene ownership + - viewport-cache update behavior + - resolver drift/hysteresis + - renderer composition order + - scene swap invalidation + - absence of stale old-layer APIs +- Add at least one integration path that exercises: + - scene load + - cache population + - resolver update + - renderer world composition + +**File(s):** +- Test modules across HAL, drivers, and system crates as needed + +## Test Requirements + +### Unit Tests +- Public/runtime-facing APIs no longer depend on `ScrollableTileLayer`. +- Cache, resolver, and renderer modules remain individually covered. + +### Integration Tests +- End-to-end scene load -> cache update -> renderer composition path succeeds. +- Scene swap invalidates cache and repopulates correctly. + +### Manual Verification +- Build the runtime and exercise representative world scenes to confirm no stale assumptions remain in construction or render flow. + +## Acceptance Criteria + +- [ ] Bridge and runtime-facing APIs align with the new scene/cache model. +- [ ] Hardware/driver construction paths instantiate the new architecture correctly. +- [ ] Stale old-layer APIs are removed. +- [ ] Full-stack regression coverage exists for the migration. + +## Dependencies + +- Depends on `PLN-0011`, `PLN-0012`, `PLN-0013`, `PLN-0014`, and `PLN-0016`. +- Source decision: `DEC-0013` + +## Risks + +- API cleanup is where hidden dependencies on the old model are most likely to surface. +- If stale APIs are not removed aggressively, the codebase can get stuck in a fragile dual-model transition. diff --git a/discussion/workflow/plans/PLN-0016-scene-binary-payload-format-and-decoder.md b/discussion/workflow/plans/PLN-0016-scene-binary-payload-format-and-decoder.md new file mode 100644 index 00000000..f3e4c37f --- /dev/null +++ b/discussion/workflow/plans/PLN-0016-scene-binary-payload-format-and-decoder.md @@ -0,0 +1,164 @@ +--- +id: PLN-0016 +ticket: scene-bank-and-viewport-cache-refactor +title: Plan - Scene Binary Payload Format and Decoder +status: accepted +created: 2026-04-13 +completed: +tags: [asset, runtime, scene, codec, binary-format] +--- + +## Objective + +Define the canonical binary payload contract for `SCENE` assets and implement the runtime decoder needed to load `SceneBank` content without JSON-based scene payloads. + +## Background + +`DEC-0013` established `SceneBank` as the canonical loaded scene aggregate. `PLN-0011` introduced `BankType::SCENE` and the scene-aware bank slot model, but intentionally left `SCENE` payload decoding open. A temporary `todo!()` now blocks scene payload materialization in the asset manager until the binary contract is closed. This plan isolates that format and decoder work before the final integration pass. + +## Scope + +### Included +- Define the binary on-disk/on-wire payload format for `SCENE`. +- Define the minimum metadata contract needed in `AssetEntry` for `SCENE`. +- Implement the runtime decoder from payload bytes into `SceneBank`. +- Add format and decode tests for valid and invalid scene payloads. +- Re-enable scene asset load/preload coverage once the decoder exists. + +### Excluded +- `SceneViewportCache` +- `SceneViewportResolver` +- renderer migration +- cartridge authoring tooling beyond what is strictly needed to encode test fixtures +- reopening canonical scene/runtime type design + +## Execution Steps + +### Step 1 - Define the binary `SCENE` payload contract + +**What:** +Close the binary layout for serialized `SceneBank` assets. + +**How:** +- Define a versioned binary format with: + - file/payload prelude or version marker + - fixed four-layer scene layout + - per-layer metadata block + - per-layer tilemap dimensions + - serialized tile records +- Keep the format aligned with the canonical runtime shape from `PLN-0011`: + - `SceneBank` + - `[SceneLayer; 4]` + - `motion_factor` + - `TileMap` + - `Tile` +- Document field sizes, endianness, ordering, and validation rules. + +**File(s):** +- [crates/console/prometeu-hal/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/asset.rs) +- [crates/console/prometeu-hal/src/cartridge_loader.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/cartridge_loader.rs) +- Additional shared asset-format module(s) if needed + +### Step 2 - Define `SCENE` metadata requirements in `AssetEntry` + +**What:** +Decide what belongs in `AssetEntry.metadata` versus the binary payload itself. + +**How:** +- Keep metadata minimal and stable. +- Add only fields that materially help validation or loader routing. +- Avoid duplicating large structural scene information in both metadata and payload. +- Add typed metadata helpers in HAL if required for scene assets. + +**File(s):** +- [crates/console/prometeu-hal/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/asset.rs) + +### Step 3 - Implement the runtime `SCENE` decoder + +**What:** +Replace the current `todo!()` loader path with a real binary decoder. + +**How:** +- Implement `perform_load_scene_bank(...)` using the accepted binary contract. +- Decode from slice/reader into canonical runtime objects: + - `SceneBank` + - `SceneLayer` + - `TileMap` + - `Tile` +- Add explicit validation for: + - invalid version + - short payload + - invalid tile-size values + - layer count mismatch + - tile count mismatch + - numeric overflow / malformed dimensions +- Keep decode logic self-contained and free of viewport/cache behavior. + +**File(s):** +- [crates/console/prometeu-drivers/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/asset.rs) +- Supporting HAL/shared modules if decoder helpers are factored out + +### Step 4 - Re-enable scene asset loading paths and tests + +**What:** +Turn scene asset loading back on in the asset manager and restore coverage. + +**How:** +- Re-enable the `SCENE` load/preload path currently blocked by the `todo!()`. +- Add tests for: + - scene payload decode success + - scene asset load + - scene preload on initialization + - malformed scene payload rejection +- Ensure glyph/sound behavior remains unchanged. + +**File(s):** +- [crates/console/prometeu-drivers/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/asset.rs) + +### Step 5 - Confirm cartridge-loader and asset-table compatibility + +**What:** +Ensure the broader cartridge/asset pipeline accepts the finalized `SCENE` contract cleanly. + +**How:** +- Verify `AssetEntry` and cartridge-loading paths accept the new scene metadata contract. +- Add targeted tests for asset-table parsing or preload validation if needed. +- Keep this step scoped to compatibility with the finalized binary scene payload, not new tooling features. + +**File(s):** +- [crates/console/prometeu-hal/src/cartridge_loader.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/cartridge_loader.rs) +- [crates/console/prometeu-hal/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/asset.rs) + +## Test Requirements + +### Unit Tests +- Valid binary scene payloads decode into the expected `SceneBank`. +- Invalid payloads fail with explicit decoder errors. +- Per-layer metadata and tile contents survive round-trip fixture decode. + +### Integration Tests +- `SCENE` assets can be loaded and preloaded through `AssetManager`. +- Scene bank slot installation works without breaking glyph/sound behavior. + +### Manual Verification +- Inspect one representative binary scene fixture and confirm the decoded `SceneBank` matches the expected four-layer canonical shape. + +## Acceptance Criteria + +- [ ] The binary `SCENE` payload format is explicitly defined and versioned. +- [ ] `AssetEntry` requirements for `SCENE` are documented and implemented. +- [ ] `perform_load_scene_bank(...)` is implemented without JSON payload parsing. +- [ ] Scene load/preload tests pass against the finalized binary decoder. +- [ ] Glyph and sound asset paths remain unaffected. + +## Dependencies + +- Depends on `PLN-0011`. +- Should complete before the final integration/cleanup pass in `PLN-0015`. +- Source decision: `DEC-0013` + +## Risks + +- Overloading `AssetEntry.metadata` can duplicate scene structure and make the contract brittle. +- A weak binary format definition can create hidden compatibility problems for future scene growth. +- Decoder validation gaps can surface later as corrupted scene state instead of explicit asset-load failures.