diff --git a/crates/console/prometeu-drivers/src/asset.rs b/crates/console/prometeu-drivers/src/asset.rs index e846d8ec..aa033729 100644 --- a/crates/console/prometeu-drivers/src/asset.rs +++ b/crates/console/prometeu-drivers/src/asset.rs @@ -191,10 +191,10 @@ impl GlyphAssetSlotIndex { } } -const GLYPH_BANK_PALETTE_COUNT_V1: usize = 64; +const GLYPH_BANK_MAX_PALETTE_COUNT_V1: usize = 64; const GLYPH_BANK_COLORS_PER_PALETTE: usize = 16; -const GLYPH_BANK_PALETTE_BYTES_V1: usize = - GLYPH_BANK_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * size_of::(); +const GLYPH_BANK_PALETTE_BYTES_PER_PALETTE: usize = + GLYPH_BANK_COLORS_PER_PALETTE * size_of::(); /// Resident metadata for a decoded/materialized asset inside a BankPolicy. #[derive(Debug)] @@ -599,7 +599,7 @@ impl AssetBridge for AssetManager { impl AssetManager { fn decode_glyph_bank_layout( entry: &AssetEntry, - ) -> Result<(TileSize, usize, usize, usize), String> { + ) -> Result<(TileSize, usize, usize, usize, usize, usize), String> { let meta = entry.metadata_as_glyph_bank()?; let tile_size = match meta.tile_size { @@ -609,7 +609,8 @@ impl AssetManager { _ => return Err(format!("Invalid tile_size: {}", meta.tile_size)), }; - if meta.palette_count as usize != GLYPH_BANK_PALETTE_COUNT_V1 { + let palette_count = meta.palette_count as usize; + if !(1..=GLYPH_BANK_MAX_PALETTE_COUNT_V1).contains(&palette_count) { return Err(format!("Invalid palette_count: {}", meta.palette_count)); } @@ -618,12 +619,14 @@ impl AssetManager { let logical_pixels = width.checked_mul(height).ok_or("GlyphBank dimensions overflow")?; let serialized_pixel_bytes = logical_pixels.div_ceil(2); + let palette_bytes = palette_count + .checked_mul(GLYPH_BANK_PALETTE_BYTES_PER_PALETTE) + .ok_or("GlyphBank palette size overflow")?; let serialized_size = serialized_pixel_bytes - .checked_add(GLYPH_BANK_PALETTE_BYTES_V1) + .checked_add(palette_bytes) .ok_or("GlyphBank serialized size overflow")?; - let decoded_size = logical_pixels - .checked_add(GLYPH_BANK_PALETTE_BYTES_V1) - .ok_or("GlyphBank decoded size overflow")?; + let decoded_size = + logical_pixels.checked_add(palette_bytes).ok_or("GlyphBank decoded size overflow")?; if entry.size != serialized_size as u64 { return Err(format!( @@ -639,7 +642,7 @@ impl AssetManager { )); } - Ok((tile_size, width, height, serialized_pixel_bytes)) + Ok((tile_size, width, height, serialized_pixel_bytes, palette_count, palette_bytes)) } fn unpack_glyph_bank_pixels(packed_pixels: &[u8], logical_pixels: usize) -> Vec { @@ -1200,19 +1203,18 @@ impl AssetManager { entry: &AssetEntry, buffer: &[u8], ) -> Result { - let (tile_size, width, height, packed_pixel_bytes) = Self::decode_glyph_bank_layout(entry)?; - if buffer.len() < packed_pixel_bytes + GLYPH_BANK_PALETTE_BYTES_V1 { + let (tile_size, width, height, packed_pixel_bytes, palette_count, palette_bytes) = + Self::decode_glyph_bank_layout(entry)?; + if buffer.len() < packed_pixel_bytes + palette_bytes { return Err("Buffer too small for GLYPHBANK".to_string()); } let logical_pixels = width * height; let packed_pixels = &buffer[0..packed_pixel_bytes]; let pixel_indices = Self::unpack_glyph_bank_pixels(packed_pixels, logical_pixels); - let palette_data = - &buffer[packed_pixel_bytes..packed_pixel_bytes + GLYPH_BANK_PALETTE_BYTES_V1]; + let palette_data = &buffer[packed_pixel_bytes..packed_pixel_bytes + palette_bytes]; - let mut palettes = - [[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; GLYPH_BANK_PALETTE_COUNT_V1]; + let mut palettes = vec![[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; palette_count]; for (p, pal) in palettes.iter_mut().enumerate() { for (c, slot) in pal.iter_mut().enumerate() { let offset = (p * 16 + c) * 4; @@ -1232,7 +1234,8 @@ impl AssetManager { entry: &AssetEntry, reader: &mut impl Read, ) -> Result { - let (tile_size, width, height, packed_pixel_bytes) = Self::decode_glyph_bank_layout(entry)?; + let (tile_size, width, height, packed_pixel_bytes, palette_count, palette_bytes) = + Self::decode_glyph_bank_layout(entry)?; let logical_pixels = width * height; let mut packed_pixels = vec![0_u8; packed_pixel_bytes]; reader @@ -1241,13 +1244,12 @@ impl AssetManager { let pixel_indices = Self::unpack_glyph_bank_pixels(&packed_pixels, logical_pixels); - let mut palette_data = [0_u8; GLYPH_BANK_PALETTE_BYTES_V1]; + let mut palette_data = vec![0_u8; palette_bytes]; reader .read_exact(&mut palette_data) .map_err(|_| "Buffer too small for GLYPHBANK".to_string())?; - let mut palettes = - [[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; GLYPH_BANK_PALETTE_COUNT_V1]; + let mut palettes = vec![[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; palette_count]; for (p, pal) in palettes.iter_mut().enumerate() { for (c, slot) in pal.iter_mut().enumerate() { let offset = (p * 16 + c) * 4; @@ -1883,35 +1885,53 @@ mod tests { 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 + fn expected_glyph_payload_size(width: usize, height: usize, palette_count: usize) -> usize { + (width * height).div_ceil(2) + (palette_count * GLYPH_BANK_PALETTE_BYTES_PER_PALETTE) } - fn expected_glyph_decoded_size(width: usize, height: usize) -> usize { - width * height + GLYPH_BANK_PALETTE_BYTES_V1 + fn expected_glyph_decoded_size(width: usize, height: usize, palette_count: usize) -> usize { + width * height + (palette_count * GLYPH_BANK_PALETTE_BYTES_PER_PALETTE) } fn test_glyph_asset_data() -> Vec { + test_glyph_asset_data_with_palette_count(GLYPH_BANK_MAX_PALETTE_COUNT_V1) + } + + fn test_glyph_asset_data_with_palette_count(palette_count: usize) -> Vec { let mut data = vec![0x11u8; 128]; - data.extend_from_slice(&[0u8; GLYPH_BANK_PALETTE_BYTES_V1]); + data.extend_from_slice(&vec![0u8; palette_count * GLYPH_BANK_PALETTE_BYTES_PER_PALETTE]); data } fn test_glyph_asset_entry(asset_name: &str, width: usize, height: usize) -> AssetEntry { + test_glyph_asset_entry_with_palette_count( + asset_name, + width, + height, + GLYPH_BANK_MAX_PALETTE_COUNT_V1, + ) + } + + fn test_glyph_asset_entry_with_palette_count( + asset_name: &str, + width: usize, + height: usize, + palette_count: usize, + ) -> AssetEntry { AssetEntry { asset_id: 0, asset_name: asset_name.to_string(), bank_type: BankType::GLYPH, offset: 0, - size: expected_glyph_payload_size(width, height) as u64, - decoded_size: expected_glyph_decoded_size(width, height) as u64, + size: expected_glyph_payload_size(width, height, palette_count) as u64, + decoded_size: expected_glyph_decoded_size(width, height, palette_count) as u64, codec: AssetCodec::None, metadata: serde_json::json!({ "tile_size": 16, "width": width, "height": height, - "palette_count": GLYPH_BANK_PALETTE_COUNT_V1, - "palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 + "palette_count": palette_count, + "palette_authored": palette_count }), } } @@ -2034,22 +2054,67 @@ mod tests { #[test] fn test_decode_glyph_bank_unpacks_packed_pixels_and_reads_palette_colors() { - let entry = test_glyph_asset_entry("glyphs", 2, 2); + let entry = test_glyph_asset_entry_with_palette_count("glyphs", 2, 2, 1); let mut data = vec![0x10, 0x23]; - data.extend_from_slice(&[0u8; GLYPH_BANK_PALETTE_BYTES_V1]); + data.extend_from_slice(&[0u8; GLYPH_BANK_PALETTE_BYTES_PER_PALETTE]); data[2..6].copy_from_slice(&[0x12, 0x34, 0x56, 0x78]); let bank = AssetManager::decode_glyph_bank_from_buffer(&entry, &data).expect("glyph decode"); assert_eq!(bank.pixel_indices, vec![1, 0, 2, 3]); + assert_eq!(bank.palette_count(), 1); assert_eq!(bank.palettes[0][0], Color::from_raw(0x12345678)); } + #[test] + fn test_decode_glyph_bank_accepts_intermediate_palette_count() { + let entry = test_glyph_asset_entry_with_palette_count("glyphs", 16, 16, 7); + let data = test_glyph_asset_data_with_palette_count(7); + + let bank = + AssetManager::decode_glyph_bank_from_buffer(&entry, &data).expect("glyph decode"); + + assert_eq!(bank.palette_count(), 7); + } + + #[test] + fn test_decode_glyph_bank_accepts_max_palette_count() { + let entry = test_glyph_asset_entry_with_palette_count( + "glyphs", + 16, + 16, + GLYPH_BANK_MAX_PALETTE_COUNT_V1, + ); + let data = test_glyph_asset_data_with_palette_count(GLYPH_BANK_MAX_PALETTE_COUNT_V1); + + let bank = + AssetManager::decode_glyph_bank_from_buffer(&entry, &data).expect("glyph decode"); + + assert_eq!(bank.palette_count(), GLYPH_BANK_MAX_PALETTE_COUNT_V1); + } + + #[test] + fn test_decode_glyph_bank_reader_matches_buffer_for_variable_palette_count() { + let entry = test_glyph_asset_entry_with_palette_count("glyphs", 16, 16, 3); + let data = test_glyph_asset_data_with_palette_count(3); + + let from_buffer = + AssetManager::decode_glyph_bank_from_buffer(&entry, &data).expect("buffer decode"); + let mut reader = std::io::Cursor::new(data); + let from_reader = AssetManager::decode_glyph_bank_from_reader(&entry, &mut reader) + .expect("reader decode"); + + assert_eq!(from_buffer.palette_count(), from_reader.palette_count()); + assert_eq!(from_buffer.pixel_indices, from_reader.pixel_indices); + assert_eq!(from_buffer.palettes, from_reader.palettes); + } + #[test] fn test_decode_glyph_bank_rejects_short_packed_buffer() { let entry = test_glyph_asset_entry("glyphs", 16, 16); - let data = vec![0u8; expected_glyph_payload_size(16, 16) - 1]; + let data = + vec![0u8; expected_glyph_payload_size(16, 16, GLYPH_BANK_MAX_PALETTE_COUNT_V1) - 1]; let err = match AssetManager::decode_glyph_bank_from_buffer(&entry, &data) { Ok(_) => panic!("glyph decode should reject short buffer"), @@ -2060,9 +2125,9 @@ mod tests { } #[test] - fn test_decode_glyph_bank_requires_palette_count_64() { + fn test_decode_glyph_bank_rejects_palette_count_zero() { let mut entry = test_glyph_asset_entry("glyphs", 16, 16); - entry.metadata["palette_count"] = serde_json::json!(32); + entry.metadata["palette_count"] = serde_json::json!(0); let err = match AssetManager::decode_glyph_bank_from_buffer(&entry, &test_glyph_asset_data()) { @@ -2070,7 +2135,67 @@ mod tests { Err(err) => err, }; - assert_eq!(err, "Invalid palette_count: 32"); + assert_eq!(err, "Invalid palette_count: 0"); + } + + #[test] + fn test_decode_glyph_bank_rejects_palette_count_above_v1_max() { + let mut entry = test_glyph_asset_entry("glyphs", 16, 16); + entry.metadata["palette_count"] = serde_json::json!(65); + + let err = + match AssetManager::decode_glyph_bank_from_buffer(&entry, &test_glyph_asset_data()) { + Ok(_) => panic!("glyph decode should reject invalid palette_count"), + Err(err) => err, + }; + + assert_eq!(err, "Invalid palette_count: 65"); + } + + #[test] + fn test_decode_glyph_bank_rejects_mismatched_variable_decoded_size() { + let mut entry = test_glyph_asset_entry_with_palette_count("glyphs", 16, 16, 3); + entry.decoded_size += 1; + + let err = match AssetManager::decode_glyph_bank_from_buffer( + &entry, + &test_glyph_asset_data_with_palette_count(3), + ) { + Ok(_) => panic!("glyph decode should reject invalid decoded_size"), + Err(err) => err, + }; + + assert_eq!( + err, + format!( + "Invalid GLYPHBANK decoded_size: expected {}, got {}", + expected_glyph_decoded_size(16, 16, 3), + expected_glyph_decoded_size(16, 16, 3) + 1 + ) + ); + } + + #[test] + fn test_decode_glyph_bank_rejects_mismatched_variable_serialized_size() { + let mut entry = test_glyph_asset_entry_with_palette_count("glyphs", 16, 16, 3); + entry.size += 1; + + let err = match AssetManager::decode_glyph_bank_from_buffer( + &entry, + &test_glyph_asset_data_with_palette_count(3), + ) { + Ok(_) => panic!("glyph decode should reject invalid serialized size"), + Err(err) => err, + }; + + assert_eq!( + err, + format!( + "Invalid GLYPHBANK serialized size: expected {}, got {}", + expected_glyph_payload_size(16, 16, 3), + expected_glyph_payload_size(16, 16, 3) + 1 + ) + ); } #[test] diff --git a/crates/console/prometeu-drivers/src/frame_composer.rs b/crates/console/prometeu-drivers/src/frame_composer.rs index 80e3bd5a..11abbc6c 100644 --- a/crates/console/prometeu-drivers/src/frame_composer.rs +++ b/crates/console/prometeu-drivers/src/frame_composer.rs @@ -557,8 +557,9 @@ mod tests { fn make_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank { let size = tile_size as usize; - let mut bank = GlyphBank::new(tile_size, size, size); - bank.palettes[palette_id as usize][1] = color; + let mut bank = + GlyphBank::with_palette_count(tile_size, size, size, palette_id as usize + 1); + bank.palette_mut(palette_id).unwrap()[1] = color; for pixel in &mut bank.pixel_indices { *pixel = 1; } diff --git a/crates/console/prometeu-drivers/src/gfx.rs b/crates/console/prometeu-drivers/src/gfx.rs index 6b86cee6..e984a3d1 100644 --- a/crates/console/prometeu-drivers/src/gfx.rs +++ b/crates/console/prometeu-drivers/src/gfx.rs @@ -812,7 +812,14 @@ impl Gfx { let fetch_x = if tile.entry.flip_x() { size - 1 - local_x } else { local_x }; let fetch_y = if tile.entry.flip_y() { size - 1 - local_y } else { local_y }; let px_index = tile.bank.get_pixel_index(tile.entry.glyph_id, fetch_x, fetch_y); - let color = tile.bank.resolve_color(tile.entry.palette_id, px_index); + let color = tile.bank.resolve_color(tile.entry.palette_id, px_index).unwrap_or_else( + || { + panic!( + "SCENE composition fatal: palette_id {} is not resident for glyph asset {}", + tile.entry.palette_id, tile.entry.glyph_asset_id + ) + }, + ); if color.alpha() == 0 { continue; } @@ -862,7 +869,14 @@ impl Gfx { let fetch_y = if sprite.flip_y { size - 1 - local_y } else { local_y }; let px_index = bank.get_pixel_index(sprite.glyph.glyph_id, fetch_x, fetch_y); - let color = bank.resolve_color(sprite.glyph.palette_id, px_index); + let color = bank.resolve_color(sprite.glyph.palette_id, px_index).unwrap_or_else( + || { + panic!( + "SPRITE composition fatal: palette_id {} is not resident for glyph bank {}", + sprite.glyph.palette_id, sprite.bank_id + ) + }, + ); if color.alpha() == 0 { continue; } @@ -1010,9 +1024,14 @@ mod tests { fn make_glyph_bank(tile_size: TileSize, palette_colors: &[(u8, u8, Color)]) -> GlyphBank { let size = tile_size as usize; - let mut bank = GlyphBank::new(tile_size, size, size); + let palette_count = palette_colors + .iter() + .map(|(palette_id, _, _)| *palette_id as usize + 1) + .max() + .unwrap_or(1); + let mut bank = GlyphBank::with_palette_count(tile_size, size, size, palette_count); for (palette_id, color_index, color) in palette_colors { - bank.palettes[*palette_id as usize][*color_index as usize] = *color; + bank.palette_mut(*palette_id).unwrap()[*color_index as usize] = *color; } bank } @@ -1152,6 +1171,26 @@ mod tests { assert_eq!(back[0], Color::RED.raw()); } + #[test] + #[should_panic(expected = "SPRITE composition fatal: palette_id 1 is not resident")] + fn sprite_draw_panics_for_invalid_palette_reference() { + let bank = make_filled_glyph_bank(TileSize::Size8, 0, &[(0, 0, Color::GREEN)]); + let mut back = vec![Color::BLACK.raw(); 8 * 8]; + let sprite = Sprite { + glyph: Glyph { glyph_id: 0, palette_id: 1 }, + x: 0, + y: 0, + layer: 0, + flip_x: false, + flip_y: false, + bank_id: 0, + active: true, + priority: 0, + }; + + Gfx::draw_sprite_pixel_by_pixel(&mut back, 8, 8, &sprite, &bank); + } + #[test] fn test_cached_tile_draws_opaque_color_index_zero() { let bank = make_filled_glyph_bank(TileSize::Size8, 0, &[(0, 0, Color::GREEN)]); @@ -1194,6 +1233,26 @@ mod tests { assert_eq!(target.back[0], Color::RED.raw()); } + #[test] + #[should_panic(expected = "SCENE composition fatal: palette_id 1 is not resident")] + fn cached_tile_draw_panics_for_invalid_palette_reference() { + let bank = make_filled_glyph_bank(TileSize::Size8, 0, &[(0, 0, Color::GREEN)]); + let mut back = vec![Color::BLACK.raw(); 8 * 8]; + let mut target = RenderTarget { back: &mut back, screen_w: 8, screen_h: 8 }; + let entry = CachedTileEntry { + active: true, + glyph_id: 0, + palette_id: 1, + flags: 0, + glyph_asset_id: 0, + }; + + Gfx::draw_cached_tile_pixels( + &mut target, + CachedTileDraw { x: 0, y: 0, entry, bank: &bank, tile_size: TileSize::Size8 }, + ); + } + #[test] fn test_draw_rect() { let banks = Arc::new(MemoryBanks::new()); diff --git a/crates/console/prometeu-drivers/src/hardware.rs b/crates/console/prometeu-drivers/src/hardware.rs index cd4d3cf9..bb375591 100644 --- a/crates/console/prometeu-drivers/src/hardware.rs +++ b/crates/console/prometeu-drivers/src/hardware.rs @@ -118,7 +118,11 @@ impl Game2DFrameComposer for Hardware { } fn emit_sprite(&mut self, sprite: Sprite) -> prometeu_hal::ComposerOpStatus { - if self.gfx.glyph_banks.glyph_bank_slot(sprite.bank_id as usize).is_none() { + let Some(bank) = self.gfx.glyph_banks.glyph_bank_slot(sprite.bank_id as usize) else { + return prometeu_hal::ComposerOpStatus::BankInvalid; + }; + + if !bank.contains_palette(sprite.glyph.palette_id) { return prometeu_hal::ComposerOpStatus::BankInvalid; } @@ -261,7 +265,7 @@ mod tests { fn make_glyph_bank() -> GlyphBank { let mut bank = GlyphBank::new(TileSize::Size8, 8, 8); - bank.palettes[0][1] = Color::RED; + bank.palette_mut(0).unwrap()[1] = Color::RED; for pixel in &mut bank.pixel_indices { *pixel = 1; } @@ -433,4 +437,28 @@ mod tests { assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw()); } + + #[test] + fn emit_sprite_rejects_palette_id_outside_loaded_glyph_bank() { + let banks = Arc::new(MemoryBanks::new()); + banks.install_glyph_bank(0, Arc::new(make_glyph_bank())); + let mut hardware = Hardware::new_with_memory_banks(banks); + + let status = Game2DFrameComposer::emit_sprite( + &mut hardware, + Sprite { + glyph: Glyph { glyph_id: 0, palette_id: 1 }, + x: 0, + y: 0, + layer: 0, + bank_id: 0, + active: false, + flip_x: false, + flip_y: false, + priority: 0, + }, + ); + + assert_eq!(status, prometeu_hal::ComposerOpStatus::BankInvalid); + } } diff --git a/crates/console/prometeu-drivers/src/memory_banks.rs b/crates/console/prometeu-drivers/src/memory_banks.rs index 1eef36e6..aa694f71 100644 --- a/crates/console/prometeu-drivers/src/memory_banks.rs +++ b/crates/console/prometeu-drivers/src/memory_banks.rs @@ -196,7 +196,7 @@ mod tests { fn make_glyph_bank() -> GlyphBank { let mut bank = GlyphBank::new(TileSize::Size8, 8, 8); - bank.palettes[0][1] = Color::WHITE; + bank.palette_mut(0).unwrap()[1] = Color::WHITE; bank } diff --git a/crates/console/prometeu-hal/src/cartridge_loader.rs b/crates/console/prometeu-hal/src/cartridge_loader.rs index 2fe4f836..302da027 100644 --- a/crates/console/prometeu-hal/src/cartridge_loader.rs +++ b/crates/console/prometeu-hal/src/cartridge_loader.rs @@ -203,7 +203,7 @@ mod tests { use super::*; use crate::asset::{AssetCodec, AssetEntry, BankType, PreloadEntry}; use crate::cartridge::{ASSETS_PA_MAGIC, ASSETS_PA_SCHEMA_VERSION, AssetsPackPrelude}; - use crate::glyph_bank::GLYPH_BANK_PALETTE_COUNT_V1; + use crate::glyph_bank::GLYPH_BANK_MAX_PALETTE_COUNT_V1; use serde_json::json; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -369,14 +369,14 @@ mod tests { bank_type: BankType::GLYPH, offset, size, - decoded_size: 16 * 16 + (GLYPH_BANK_PALETTE_COUNT_V1 as u64 * 16 * 4), + decoded_size: 16 * 16 + (GLYPH_BANK_MAX_PALETTE_COUNT_V1 as u64 * 16 * 4), codec: AssetCodec::None, metadata: json!({ "tile_size": 16, "width": 16, "height": 16, - "palette_count": GLYPH_BANK_PALETTE_COUNT_V1, - "palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 + "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1, + "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1 }), } } @@ -452,14 +452,14 @@ mod tests { bank_type: BankType::GLYPH, offset: 4, size: 4, - decoded_size: 16 * 16 + (GLYPH_BANK_PALETTE_COUNT_V1 as u64 * 16 * 4), + decoded_size: 16 * 16 + (GLYPH_BANK_MAX_PALETTE_COUNT_V1 as u64 * 16 * 4), codec: AssetCodec::None, metadata: json!({ "tile_size": 16, "width": 16, "height": 16, - "palette_count": GLYPH_BANK_PALETTE_COUNT_V1, - "palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 + "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1, + "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1 }), }, ]; @@ -519,8 +519,8 @@ mod tests { "tile_size": 16, "width": 16, "height": 16, - "palette_count": GLYPH_BANK_PALETTE_COUNT_V1, - "palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 + "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1, + "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1 } }], "preload": [] @@ -561,8 +561,8 @@ mod tests { "tile_size": 16, "width": 16, "height": 16, - "palette_count": GLYPH_BANK_PALETTE_COUNT_V1, - "palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 + "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1, + "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1 } }], "preload": [] diff --git a/crates/console/prometeu-hal/src/glyph_bank.rs b/crates/console/prometeu-hal/src/glyph_bank.rs index fe92e131..2c72d8ed 100644 --- a/crates/console/prometeu-hal/src/glyph_bank.rs +++ b/crates/console/prometeu-hal/src/glyph_bank.rs @@ -1,7 +1,7 @@ use crate::color::Color; use serde::{Deserialize, Serialize}; -pub const GLYPH_BANK_PALETTE_COUNT_V1: usize = 64; +pub const GLYPH_BANK_MAX_PALETTE_COUNT_V1: usize = 64; pub const GLYPH_BANK_COLORS_PER_PALETTE: usize = 16; /// Standard sizes for square tiles. @@ -35,22 +35,59 @@ pub struct GlyphBank { /// Palette indices are ordinary indices; transparency is resolved through /// the RGBA alpha channel of the palette entry. pub pixel_indices: Vec, - /// Runtime-facing v1 palette table: 64 palettes of 16 RGBA8888 colors each. - pub palettes: [[Color; GLYPH_BANK_COLORS_PER_PALETTE]; GLYPH_BANK_PALETTE_COUNT_V1], + /// Runtime-facing v1 palette table. + /// + /// Palette identity is the direct index in this vector. A resident glyph + /// bank carries exactly the palettes materialized from its payload. + pub palettes: Vec<[Color; GLYPH_BANK_COLORS_PER_PALETTE]>, } impl GlyphBank { - /// Creates an empty glyph bank with the specified dimensions. + /// Creates an empty glyph bank with one palette. pub fn new(tile_size: TileSize, width: usize, height: usize) -> Self { + Self::with_palette_count(tile_size, width, height, 1) + } + + /// Creates an empty glyph bank with the specified resident palette count. + pub fn with_palette_count( + tile_size: TileSize, + width: usize, + height: usize, + palette_count: usize, + ) -> Self { + assert!( + (1..=GLYPH_BANK_MAX_PALETTE_COUNT_V1).contains(&palette_count), + "glyph bank palette_count must be in 1..={}", + GLYPH_BANK_MAX_PALETTE_COUNT_V1 + ); + Self { tile_size, width, height, pixel_indices: vec![0; width * height], - palettes: [[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; GLYPH_BANK_PALETTE_COUNT_V1], + palettes: vec![[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; palette_count], } } + /// Returns the number of resident palettes. + pub fn palette_count(&self) -> usize { + self.palettes.len() + } + + /// Returns true when the palette id is valid for this resident bank. + pub fn contains_palette(&self, palette_id: u8) -> bool { + (palette_id as usize) < self.palette_count() + } + + /// Returns a mutable palette slot when the palette exists. + pub fn palette_mut( + &mut self, + palette_id: u8, + ) -> Option<&mut [Color; GLYPH_BANK_COLORS_PER_PALETTE]> { + self.palettes.get_mut(palette_id as usize) + } + /// Resolves a global tile ID and local pixel coordinates to a palette index. /// tile_id: the tile index in the bank /// local_x, local_y: the pixel position inside the tile (0 to tile_size-1) @@ -71,11 +108,48 @@ impl GlyphBank { } /// Maps a 4-bit index to a real RGBA8888 Color using the specified palette. - pub fn resolve_color(&self, palette_id: u8, pixel_index: u8) -> Color { + pub fn resolve_color(&self, palette_id: u8, pixel_index: u8) -> Option { self.palettes .get(palette_id as usize) .and_then(|palette| palette.get(pixel_index as usize)) .copied() - .unwrap_or(Color::TRANSPARENT) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_glyph_bank_uses_single_resident_palette() { + let bank = GlyphBank::new(TileSize::Size8, 8, 8); + + assert_eq!(bank.palette_count(), 1); + assert!(bank.contains_palette(0)); + assert!(!bank.contains_palette(1)); + } + + #[test] + fn glyph_bank_can_materialize_v1_max_palette_count() { + let bank = + GlyphBank::with_palette_count(TileSize::Size8, 8, 8, GLYPH_BANK_MAX_PALETTE_COUNT_V1); + + assert_eq!(bank.palette_count(), GLYPH_BANK_MAX_PALETTE_COUNT_V1); + assert!(bank.contains_palette((GLYPH_BANK_MAX_PALETTE_COUNT_V1 - 1) as u8)); + } + + #[test] + fn invalid_palette_lookup_is_distinct_from_transparent_color() { + let mut bank = GlyphBank::new(TileSize::Size8, 8, 8); + bank.palette_mut(0).unwrap()[1] = Color::TRANSPARENT; + + assert_eq!(bank.resolve_color(0, 1), Some(Color::TRANSPARENT)); + assert_eq!(bank.resolve_color(1, 1), None); + } + + #[test] + #[should_panic(expected = "glyph bank palette_count must be in 1..=64")] + fn glyph_bank_rejects_zero_resident_palettes() { + let _ = GlyphBank::with_palette_count(TileSize::Size8, 8, 8, 0); } } diff --git a/crates/console/prometeu-system/src/services/vm_runtime/tests.rs b/crates/console/prometeu-system/src/services/vm_runtime/tests.rs index a10b47c1..e23abe67 100644 --- a/crates/console/prometeu-system/src/services/vm_runtime/tests.rs +++ b/crates/console/prometeu-system/src/services/vm_runtime/tests.rs @@ -15,7 +15,7 @@ use prometeu_hal::asset::{ use prometeu_hal::cartridge::{AssetsPayloadSource, Cartridge}; use prometeu_hal::color::Color; use prometeu_hal::glyph::Glyph; -use prometeu_hal::glyph_bank::{GLYPH_BANK_PALETTE_COUNT_V1, GlyphBank, TileSize}; +use prometeu_hal::glyph_bank::{GLYPH_BANK_MAX_PALETTE_COUNT_V1, GlyphBank, TileSize}; use prometeu_hal::scene_bank::SceneBank; use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer}; use prometeu_hal::syscalls::caps; @@ -113,11 +113,12 @@ fn serialized_single_function_module_with_consts( } fn test_glyph_payload_size(width: usize, height: usize) -> usize { - (width * height).div_ceil(2) + (GLYPH_BANK_PALETTE_COUNT_V1 * 16 * std::mem::size_of::()) + (width * height).div_ceil(2) + + (GLYPH_BANK_MAX_PALETTE_COUNT_V1 * 16 * std::mem::size_of::()) } fn test_glyph_decoded_size(width: usize, height: usize) -> usize { - width * height + (GLYPH_BANK_PALETTE_COUNT_V1 * 16 * std::mem::size_of::()) + width * height + (GLYPH_BANK_MAX_PALETTE_COUNT_V1 * 16 * std::mem::size_of::()) } fn test_glyph_asset_entry(asset_name: &str, data_len: usize) -> AssetEntry { @@ -133,23 +134,23 @@ fn test_glyph_asset_entry(asset_name: &str, data_len: usize) -> AssetEntry { "tile_size": 16, "width": 16, "height": 16, - "palette_count": GLYPH_BANK_PALETTE_COUNT_V1, - "palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 + "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1, + "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1 }), } } fn test_glyph_asset_data() -> Vec { let mut data = - vec![0x11u8; test_glyph_payload_size(16, 16) - (GLYPH_BANK_PALETTE_COUNT_V1 * 16 * 4)]; - data.extend_from_slice(&[0u8; GLYPH_BANK_PALETTE_COUNT_V1 * 16 * 4]); + vec![0x11u8; test_glyph_payload_size(16, 16) - (GLYPH_BANK_MAX_PALETTE_COUNT_V1 * 16 * 4)]; + data.extend_from_slice(&[0u8; GLYPH_BANK_MAX_PALETTE_COUNT_V1 * 16 * 4]); data } fn runtime_test_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank { let size = tile_size as usize; - let mut bank = GlyphBank::new(tile_size, size, size); - bank.palettes[palette_id as usize][1] = color; + let mut bank = GlyphBank::with_palette_count(tile_size, size, size, palette_id as usize + 1); + bank.palette_mut(palette_id).unwrap()[1] = color; for pixel in &mut bank.pixel_indices { *pixel = 1; } @@ -1275,6 +1276,53 @@ fn tick_composer_emit_sprite_operational_error_returns_status_not_crash() { assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(ComposerOpStatus::BankInvalid as i64)]); } +#[test] +fn tick_composer_emit_sprite_invalid_palette_returns_bank_invalid() { + let mut runtime = VirtualMachineRuntime::new(None); + let mut log_service = LogService::new(4096); + let mut fs = VirtualFS::new(); + let mut fs_state = FsState::Unmounted; + let mut memcard = MemcardService::new(); + let mut open_files: HashMap = HashMap::new(); + let mut next_handle = 1; + let mut vm = VirtualMachine::default(); + let banks = Arc::new(MemoryBanks::new()); + banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 0, Color::BLUE))); + let mut platform = TestPlatform::new_with_memory_banks(banks); + let signals = InputSignals::default(); + let code = assemble( + "PUSH_I32 0\nPUSH_I32 1\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT", + ) + .expect("assemble"); + let program = serialized_single_function_module( + code, + vec![SyscallDecl { + module: "composer".into(), + name: "emit_sprite".into(), + version: 1, + arg_slots: 9, + ret_slots: 1, + }], + ); + let cartridge = cartridge_with_program(program, caps::GFX); + + runtime.initialize_vm(&mut log_service, &mut vm, &cartridge).expect("runtime must initialize"); + let report = runtime.tick( + &mut log_service, + &mut fs, + &mut fs_state, + &mut memcard, + &mut open_files, + &mut next_handle, + &mut vm, + &signals, + &mut platform, + ); + assert!(report.is_none(), "invalid palette must not crash VM execution"); + assert!(vm.is_halted()); + assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(ComposerOpStatus::BankInvalid as i64)]); +} + #[test] fn tick_composer_emit_sprite_invalid_layer_returns_status_not_crash() { let mut runtime = VirtualMachineRuntime::new(None); diff --git a/crates/tools/pbxgen-stress/src/lib.rs b/crates/tools/pbxgen-stress/src/lib.rs index 11b8bee1..9395b431 100644 --- a/crates/tools/pbxgen-stress/src/lib.rs +++ b/crates/tools/pbxgen-stress/src/lib.rs @@ -14,7 +14,7 @@ use prometeu_hal::cartridge::{ use prometeu_hal::color::Color; use prometeu_hal::glyph::Glyph; use prometeu_hal::glyph_bank::{ - TileSize, GLYPH_BANK_COLORS_PER_PALETTE, GLYPH_BANK_PALETTE_COUNT_V1, + TileSize, GLYPH_BANK_COLORS_PER_PALETTE, GLYPH_BANK_MAX_PALETTE_COUNT_V1, }; use prometeu_hal::scene_bank::SceneBank; use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer}; @@ -275,15 +275,15 @@ fn build_glyph_asset() -> (AssetEntry, Vec) { bank_type: BankType::GLYPH, offset: 0, size: payload.len() as u64, - decoded_size: (8 * 8 + GLYPH_BANK_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * 4) + decoded_size: (8 * 8 + GLYPH_BANK_MAX_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * 4) as u64, codec: AssetCodec::None, metadata: serde_json::json!({ "tile_size": 8, "width": 8, "height": 8, - "palette_count": GLYPH_BANK_PALETTE_COUNT_V1, - "palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 + "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1, + "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1 }), }; @@ -292,8 +292,8 @@ fn build_glyph_asset() -> (AssetEntry, Vec) { fn build_palette_bytes() -> Vec { let mut bytes = - Vec::with_capacity(GLYPH_BANK_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * 4); - for palette_id in 0..GLYPH_BANK_PALETTE_COUNT_V1 { + Vec::with_capacity(GLYPH_BANK_MAX_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * 4); + for palette_id in 0..GLYPH_BANK_MAX_PALETTE_COUNT_V1 { for color_index in 0..GLYPH_BANK_COLORS_PER_PALETTE { let color = if color_index == 1 { stress_color(palette_id) } else { Color::BLACK }; bytes.extend_from_slice(&color.raw().to_be_bytes()); diff --git a/discussion/.index.lock b/discussion/.index.lock new file mode 100644 index 00000000..e69de29b diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 9da61ed1..5ada6974 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,4 +1,4 @@ -{"type":"meta","next_id":{"DSC":46,"AGD":49,"DEC":41,"PLN":167,"LSN":55,"CLSN":1}} +{"type":"meta","next_id":{"DSC":47,"AGD":50,"DEC":42,"PLN":173,"LSN":56,"CLSN":1}} {"type":"discussion","id":"DSC-0044","status":"done","ticket":"hub-suspended-game-kill-affordance","title":"Hub Suspended Game Kill Affordance","created_at":"2026-07-05","updated_at":"2026-07-05","tags":["hub","lifecycle","game","ui"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0054","file":"discussion/lessons/DSC-0044-hub-suspended-game-kill-affordance/LSN-0054-manual-hub-kill-must-share-game-termination-cleanup.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05"}]} {"type":"discussion","id":"DSC-0043","status":"done","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-05","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0053","file":"discussion/lessons/DSC-0043-system-os-cartridge-switch-orchestrator/LSN-0053-game-switching-is-lifecycle-replacement-not-loader-work.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05"}]} {"type":"discussion","id":"DSC-0039","status":"abandoned","ticket":"render-pipeline-family-and-future-3d","title":"Render Pipeline Family and Future 3D","created_at":"2026-06-04","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","architecture","pipeline"],"agendas":[{"id":"AGD-0039","file":"AGD-0039-render-pipeline-family-and-future-3d.md","status":"abandoned","created_at":"2026-06-04","updated_at":"2026-06-04","_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."} @@ -44,3 +44,4 @@ {"type":"discussion","id":"DSC-0033","status":"done","ticket":"system-os-service-ownership-and-module-layout","title":"Agenda - SystemOS Service Ownership and Module Layout","created_at":"2026-05-14","updated_at":"2026-05-15","tags":["runtime","os","services","module-layout","vm","window-manager","logging"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0042","file":"discussion/lessons/DSC-0033-system-os-service-ownership-and-module-layout/LSN-0042-systemos-service-ownership-boundary.md","status":"done","created_at":"2026-05-15","updated_at":"2026-05-15"}]} {"type":"discussion","id":"DSC-0036","status":"done","ticket":"prometeu-hub-ui-direction","title":"Agenda - Prometeu Hub UI Direction","created_at":"2026-05-15","updated_at":"2026-05-22","tags":["hub","ui","shell","system-apps","lifecycle","design-system"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0045","file":"discussion/lessons/DSC-0036-prometeu-hub-ui-direction/LSN-0045-hub-ui-slices-should-prove-os-boundaries.md","status":"done","created_at":"2026-05-22","updated_at":"2026-05-22"}]} {"type":"discussion","id":"DSC-0037","status":"done","ticket":"rgba8888-framebuffer-and-pixel-format-direction","title":"Agenda - RGBA8888 Framebuffer and Pixel Format Direction","created_at":"2026-05-22","updated_at":"2026-05-23","tags":["gfx","framebuffer","rgb565","rgba8888","renderer","assets","host","backend"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0046","file":"discussion/lessons/DSC-0037-rgba8888-framebuffer-and-pixel-format-direction/LSN-0046-pixel-format-contracts-must-move-as-one-surface.md","status":"done","created_at":"2026-05-23","updated_at":"2026-05-23"}]} +{"type":"discussion","id":"DSC-0046","status":"done","ticket":"runtime-owned-variable-glyph-bank-palette-protocol","title":"Runtime-Owned Variable Glyph Bank Palette Protocol","created_at":"2026-07-14","updated_at":"2026-07-14","tags":["runtime","gfx","assets","glyph-bank","palette-serialization","protocol"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0055","file":"discussion/lessons/DSC-0046-runtime-owned-variable-glyph-bank-palette-protocol/LSN-0055-runtime-owned-asset-protocols-must-align-payload-residency-and-lookup.md","status":"done","created_at":"2026-07-14","updated_at":"2026-07-14"}]} diff --git a/discussion/lessons/DSC-0046-runtime-owned-variable-glyph-bank-palette-protocol/LSN-0055-runtime-owned-asset-protocols-must-align-payload-residency-and-lookup.md b/discussion/lessons/DSC-0046-runtime-owned-variable-glyph-bank-palette-protocol/LSN-0055-runtime-owned-asset-protocols-must-align-payload-residency-and-lookup.md new file mode 100644 index 00000000..57708be9 --- /dev/null +++ b/discussion/lessons/DSC-0046-runtime-owned-variable-glyph-bank-palette-protocol/LSN-0055-runtime-owned-asset-protocols-must-align-payload-residency-and-lookup.md @@ -0,0 +1,97 @@ +--- +id: LSN-0055 +ticket: runtime-owned-variable-glyph-bank-palette-protocol +title: Runtime-Owned Asset Protocols Must Align Payload, Residency, and Lookup +created: 2026-07-14 +tags: [runtime, assets, glyph-bank, palette-serialization, protocol] +decision: DEC-0041 +--- + +## Context + +The runtime changed the `GLYPH/indexed_v1` glyph-bank palette contract from a +fixed 64-palette payload and resident table to variable palette serialization +and variable resident palette storage. + +The initiating pressure came from packer/studio: fixed RGBA8888 palette padding +made small glyph banks pay for `64 * 16 * 4` palette bytes even when they used +far fewer palettes. The important architectural point was that the runtime, not +the packer, had to decide the protocol. The packer can only emit payloads that +conform to the runtime contract once the runtime spec is published. + +## Key Decisions + +### Variable Glyph Bank Palette Protocol + +**What:** +`palette_count` became the number of serialized palettes and the number of +resident palettes in the loaded `GlyphBank`. The valid range is `1..=64`. +`palette_id` remains a direct palette identity and is valid only when +`palette_id < palette_count` for the referenced resident bank. + +**Why:** +Keeping serialized count variable while retaining a fixed 64-slot resident +table would preserve two meanings for palette capacity. The runtime would save +payload bytes but still carry a split contract between payload shape, resident +shape, and lookup validity. Making all three agree gives the runtime one +source of truth. + +**Trade-offs:** +Palette validity is now bank-dependent. Scene and sprite composition must check +the referenced loaded bank instead of relying on a global `0..63` rule. This +adds validation work, but it prevents invalid references from silently becoming +transparent or default colors. + +## Patterns and Algorithms + +Let the runtime own runtime-facing asset protocols. Tooling can discover pain, +but the runtime spec must define effective metadata, payload shape, resident +shape, and failure semantics. + +Make count fields material. A count such as `palette_count` should not describe +only what the producer authored or only what the payload happens to include. If +runtime lookup depends on it, the count must also define resident state and +validation boundaries. + +Keep identity direct unless a real remapping problem exists. Sparse-to-dense +palette remapping was rejected because it would add a second identity layer +across scenes, sprites, packer output, and runtime lookup. V1 keeps palette id +`N` as palette slot `N` in the resident bank. + +Separate transparent color from invalid lookup. RGBA alpha is valid color data. +An invalid palette reference must be observable as invalid; it must not be +collapsed into `Color::TRANSPARENT`. + +Use residue scans after protocol migrations. Search for fixed byte formulas, +old constants, fixture helpers, and spec phrases. Legitimate hits should be +renamed as maximum-bound concepts, not left as accidental active contracts. + +## Pitfalls + +Partially variable specs are worse than fixed specs. The asset spec already had +variable size formulas in some places but still required `palette_count = 64` +elsewhere. That contradiction made it unclear which rule was canonical. + +Resident data structures can preserve obsolete protocol assumptions after the +payload changes. A fixed array in `GlyphBank` would have kept the old model +alive even if decode accepted variable payloads. + +Fallback rendering hides contract errors. Returning transparent for an invalid +palette id makes bad assets and bad scene references look like intentional +alpha, which is exactly the wrong failure mode for a runtime protocol. + +Fixture generators are part of the contract surface. Stress cartridges and +test payload builders must express whether they are using a variable count or +the v1 maximum; otherwise they reintroduce fixed-padding assumptions. + +## Takeaways + +- Runtime-facing asset protocols should align payload size, resident memory, + and lookup validity. +- `palette_count` is effective runtime metadata, not producer commentary. +- Direct identity is simpler than remapping until a concrete remapping need + exists. +- Invalid palette references should fail explicitly; transparent remains a + valid RGBA color, not an error substitute. +- Protocol migrations need spec edits, code changes, fixture updates, and + residue scans in the same workflow. diff --git a/docs/specs/runtime/04-gfx-peripheral.md b/docs/specs/runtime/04-gfx-peripheral.md index d6801caa..a64ada99 100644 --- a/docs/specs/runtime/04-gfx-peripheral.md +++ b/docs/specs/runtime/04-gfx-peripheral.md @@ -625,7 +625,8 @@ Each tilemap cell contains: Runtime-facing validity rule for v1: -- `palette_id` values are valid only in the range `0..63` +- `palette_id` values are valid only when `palette_id < palette_count` for the + resolved resident glyph bank #### Sprite @@ -640,7 +641,8 @@ Each sprite draw contains: Runtime-facing validity rule for v1: -- `palette_id` values are valid only in the range `0..63` +- `palette_id` values are valid only when `palette_id < palette_count` for the + referenced resident glyph bank --- @@ -649,7 +651,7 @@ Runtime-facing validity rule for v1: The pipeline works like this: 1. Read indexed pixel from tile (value 0..15) -2. Resolve: +2. Validate and resolve: - real_color = palette[palette_id][index] 3. Apply: - flip @@ -667,6 +669,9 @@ else: draw_or_blend(color) ``` +The `palette_id` lookup above is valid only after the runtime has established +that the referenced resident glyph bank contains that palette. + --- ### 19.7. Organization of Tile Banks @@ -766,6 +771,8 @@ Rules: - missing glyph dependencies referenced by a resident scene are not a passive `status:int` case; - if scene activation discovers that a layer dependency cannot be resolved to a committed glyph asset, the machine MUST fail fatally and emit a clear log; - if scene composition later discovers that a layer dependency can no longer be resolved, the machine MUST fail fatally and emit a clear log; +- if scene activation or composition discovers that a tile references `palette_id >= palette_count` for its resolved glyph bank, the machine MUST fail fatally and emit a clear log; +- invalid scene palette references are scene dependency failures and MUST NOT be rendered through transparent, black, or default color substitution; - runtime MUST NOT continue canonical scene composition after such a dependency failure. ### 20.2 `composer.emit_sprite` @@ -774,7 +781,7 @@ Rules: ABI: 1. `glyph_id: int` — glyph index within the bank -2. `palette_id: int` — palette index +2. `palette_id: int` — palette index within the referenced bank; valid only when `palette_id < palette_count` for that resident glyph bank 3. `x: int` — x coordinate 4. `y: int` — y coordinate 5. `layer: int` — composition layer reference @@ -797,4 +804,6 @@ Operational notes: - the canonical public sprite contract is frame-emission based; - no caller-provided sprite index exists in the v1 canonical ABI; - no `active` flag exists in the v1 canonical ABI; +- `palette_id >= palette_count` for the referenced glyph bank MUST return `BANK_INVALID`; +- invalid sprite palette references MUST NOT be rendered through transparent, black, or default color substitution; - overflow remains non-fatal and must not escalate to trap in v1. diff --git a/docs/specs/runtime/15-asset-management.md b/docs/specs/runtime/15-asset-management.md index 699c499d..43e6982d 100644 --- a/docs/specs/runtime/15-asset-management.md +++ b/docs/specs/runtime/15-asset-management.md @@ -115,7 +115,7 @@ For `BankType::GLYPH`, the v1 runtime-facing contract is: - `codec = NONE` - serialized pixels use packed `u4` palette indices - serialized palettes use `RGBA8888` with canonical RGBA channel order -- `palette_count = 64` +- `palette_count` is the number of serialized and resident palettes and must be in `1..=64` - runtime materialization may expand pixel indices to one `u8` per pixel For `GLYPH`, `NONE` means there is no additional generic codec layer beyond the bank contract itself. @@ -136,7 +136,7 @@ Required effective metadata fields for `GLYPH` at the root level: - `tile_size`: tile edge in pixels; valid values are `8`, `16`, or `32` - `width`: total sheet width in pixels - `height`: total sheet height in pixels -- `palette_count`: number of serialized palettes for the bank +- `palette_count`: number of serialized palettes and resident runtime palettes for the bank Optional informative subtrees: @@ -145,7 +145,7 @@ Optional informative subtrees: Validation rules for `GLYPH` v1: -- `palette_count` must be `64` +- `palette_count` must be in the inclusive range `1..=64` - `width * height` defines the number of logical indexed pixels in the decoded sheet - extra metadata may exist, but the runtime contract must not depend on it to reconstruct the in-memory bank unless that data is defined at the root as an effective field. @@ -158,7 +158,16 @@ The tile-bank payload therefore separates serialized storage form from runtime m - serialized pixel plane: packed `4bpp` - decoded pixel plane: expanded `u8` indices, one entry per pixel -- palette table: `64 * 16` colors in RGBA8888 channel order +- palette table: `palette_count * 16` colors in RGBA8888 channel order + +For `GLYPH` v1: + +```text +serialized_pixel_bytes = ceil(width * height / 2) +palette_bytes = palette_count * 16 * 4 +size = serialized_pixel_bytes + palette_bytes +decoded_size = (width * height) + palette_bytes +``` For `GLYPH` v1: @@ -172,6 +181,11 @@ before they are loaded by the runtime. Palette indices are ordinary indices. Transparency is represented by the alpha channel of the resolved RGBA8888 palette entry, not by reserving index `0`. +`palette_id` is valid only when it is lower than the referenced resident glyph +bank's `palette_count`. Canonical runtime composition MUST NOT substitute +transparent, black, or any other default color for `palette_id >= +palette_count`. + ### 4.2 `SCENE` asset contract in v1 For `BankType::SCENE`, the v1 runtime-facing contract is: