Merge pull request 'dev/runtime-owned-variable-glyph-bank-palette-protocol' (#37) from dev/runtime-owned-variable-glyph-bank-palette-protocol into master
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good

Reviewed-on: #37
This commit is contained in:
bquarkz 2026-07-14 15:25:05 +00:00
commit aa3b31deb5
14 changed files with 542 additions and 86 deletions

View File

@ -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_COLORS_PER_PALETTE: usize = 16;
const GLYPH_BANK_PALETTE_BYTES_V1: usize = const GLYPH_BANK_PALETTE_BYTES_PER_PALETTE: usize =
GLYPH_BANK_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * size_of::<u32>(); GLYPH_BANK_COLORS_PER_PALETTE * size_of::<u32>();
/// Resident metadata for a decoded/materialized asset inside a BankPolicy. /// Resident metadata for a decoded/materialized asset inside a BankPolicy.
#[derive(Debug)] #[derive(Debug)]
@ -599,7 +599,7 @@ impl AssetBridge for AssetManager {
impl AssetManager { impl AssetManager {
fn decode_glyph_bank_layout( fn decode_glyph_bank_layout(
entry: &AssetEntry, entry: &AssetEntry,
) -> Result<(TileSize, usize, usize, usize), String> { ) -> Result<(TileSize, usize, usize, usize, usize, usize), String> {
let meta = entry.metadata_as_glyph_bank()?; let meta = entry.metadata_as_glyph_bank()?;
let tile_size = match meta.tile_size { let tile_size = match meta.tile_size {
@ -609,7 +609,8 @@ impl AssetManager {
_ => return Err(format!("Invalid tile_size: {}", meta.tile_size)), _ => 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)); 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 logical_pixels = width.checked_mul(height).ok_or("GlyphBank dimensions overflow")?;
let serialized_pixel_bytes = logical_pixels.div_ceil(2); 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 let serialized_size = serialized_pixel_bytes
.checked_add(GLYPH_BANK_PALETTE_BYTES_V1) .checked_add(palette_bytes)
.ok_or("GlyphBank serialized size overflow")?; .ok_or("GlyphBank serialized size overflow")?;
let decoded_size = logical_pixels let decoded_size =
.checked_add(GLYPH_BANK_PALETTE_BYTES_V1) logical_pixels.checked_add(palette_bytes).ok_or("GlyphBank decoded size overflow")?;
.ok_or("GlyphBank decoded size overflow")?;
if entry.size != serialized_size as u64 { if entry.size != serialized_size as u64 {
return Err(format!( 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<u8> { fn unpack_glyph_bank_pixels(packed_pixels: &[u8], logical_pixels: usize) -> Vec<u8> {
@ -1200,19 +1203,18 @@ impl AssetManager {
entry: &AssetEntry, entry: &AssetEntry,
buffer: &[u8], buffer: &[u8],
) -> Result<GlyphBank, String> { ) -> Result<GlyphBank, String> {
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) =
if buffer.len() < packed_pixel_bytes + GLYPH_BANK_PALETTE_BYTES_V1 { Self::decode_glyph_bank_layout(entry)?;
if buffer.len() < packed_pixel_bytes + palette_bytes {
return Err("Buffer too small for GLYPHBANK".to_string()); return Err("Buffer too small for GLYPHBANK".to_string());
} }
let logical_pixels = width * height; let logical_pixels = width * height;
let packed_pixels = &buffer[0..packed_pixel_bytes]; let packed_pixels = &buffer[0..packed_pixel_bytes];
let pixel_indices = Self::unpack_glyph_bank_pixels(packed_pixels, logical_pixels); let pixel_indices = Self::unpack_glyph_bank_pixels(packed_pixels, logical_pixels);
let palette_data = let palette_data = &buffer[packed_pixel_bytes..packed_pixel_bytes + palette_bytes];
&buffer[packed_pixel_bytes..packed_pixel_bytes + GLYPH_BANK_PALETTE_BYTES_V1];
let mut palettes = let mut palettes = vec![[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; palette_count];
[[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; GLYPH_BANK_PALETTE_COUNT_V1];
for (p, pal) in palettes.iter_mut().enumerate() { for (p, pal) in palettes.iter_mut().enumerate() {
for (c, slot) in pal.iter_mut().enumerate() { for (c, slot) in pal.iter_mut().enumerate() {
let offset = (p * 16 + c) * 4; let offset = (p * 16 + c) * 4;
@ -1232,7 +1234,8 @@ impl AssetManager {
entry: &AssetEntry, entry: &AssetEntry,
reader: &mut impl Read, reader: &mut impl Read,
) -> Result<GlyphBank, String> { ) -> Result<GlyphBank, String> {
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 logical_pixels = width * height;
let mut packed_pixels = vec![0_u8; packed_pixel_bytes]; let mut packed_pixels = vec![0_u8; packed_pixel_bytes];
reader reader
@ -1241,13 +1244,12 @@ impl AssetManager {
let pixel_indices = Self::unpack_glyph_bank_pixels(&packed_pixels, logical_pixels); 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 reader
.read_exact(&mut palette_data) .read_exact(&mut palette_data)
.map_err(|_| "Buffer too small for GLYPHBANK".to_string())?; .map_err(|_| "Buffer too small for GLYPHBANK".to_string())?;
let mut palettes = let mut palettes = vec![[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; palette_count];
[[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; GLYPH_BANK_PALETTE_COUNT_V1];
for (p, pal) in palettes.iter_mut().enumerate() { for (p, pal) in palettes.iter_mut().enumerate() {
for (c, slot) in pal.iter_mut().enumerate() { for (c, slot) in pal.iter_mut().enumerate() {
let offset = (p * 16 + c) * 4; let offset = (p * 16 + c) * 4;
@ -1883,35 +1885,53 @@ mod tests {
use prometeu_hal::tile::Tile; use prometeu_hal::tile::Tile;
use prometeu_hal::tilemap::TileMap; use prometeu_hal::tilemap::TileMap;
fn expected_glyph_payload_size(width: usize, height: usize) -> usize { fn expected_glyph_payload_size(width: usize, height: usize, palette_count: usize) -> usize {
(width * height).div_ceil(2) + GLYPH_BANK_PALETTE_BYTES_V1 (width * height).div_ceil(2) + (palette_count * GLYPH_BANK_PALETTE_BYTES_PER_PALETTE)
} }
fn expected_glyph_decoded_size(width: usize, height: usize) -> usize { fn expected_glyph_decoded_size(width: usize, height: usize, palette_count: usize) -> usize {
width * height + GLYPH_BANK_PALETTE_BYTES_V1 width * height + (palette_count * GLYPH_BANK_PALETTE_BYTES_PER_PALETTE)
} }
fn test_glyph_asset_data() -> Vec<u8> { fn test_glyph_asset_data() -> Vec<u8> {
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<u8> {
let mut data = vec![0x11u8; 128]; 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 data
} }
fn test_glyph_asset_entry(asset_name: &str, width: usize, height: usize) -> AssetEntry { 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 { AssetEntry {
asset_id: 0, asset_id: 0,
asset_name: asset_name.to_string(), asset_name: asset_name.to_string(),
bank_type: BankType::GLYPH, bank_type: BankType::GLYPH,
offset: 0, offset: 0,
size: expected_glyph_payload_size(width, height) as u64, size: expected_glyph_payload_size(width, height, palette_count) as u64,
decoded_size: expected_glyph_decoded_size(width, height) as u64, decoded_size: expected_glyph_decoded_size(width, height, palette_count) as u64,
codec: AssetCodec::None, codec: AssetCodec::None,
metadata: serde_json::json!({ metadata: serde_json::json!({
"tile_size": 16, "tile_size": 16,
"width": width, "width": width,
"height": height, "height": height,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1, "palette_count": palette_count,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 "palette_authored": palette_count
}), }),
} }
} }
@ -2034,22 +2054,67 @@ mod tests {
#[test] #[test]
fn test_decode_glyph_bank_unpacks_packed_pixels_and_reads_palette_colors() { 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]; 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]); data[2..6].copy_from_slice(&[0x12, 0x34, 0x56, 0x78]);
let bank = let bank =
AssetManager::decode_glyph_bank_from_buffer(&entry, &data).expect("glyph decode"); AssetManager::decode_glyph_bank_from_buffer(&entry, &data).expect("glyph decode");
assert_eq!(bank.pixel_indices, vec![1, 0, 2, 3]); 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)); 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] #[test]
fn test_decode_glyph_bank_rejects_short_packed_buffer() { fn test_decode_glyph_bank_rejects_short_packed_buffer() {
let entry = test_glyph_asset_entry("glyphs", 16, 16); 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) { let err = match AssetManager::decode_glyph_bank_from_buffer(&entry, &data) {
Ok(_) => panic!("glyph decode should reject short buffer"), Ok(_) => panic!("glyph decode should reject short buffer"),
@ -2060,9 +2125,9 @@ mod tests {
} }
#[test] #[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); 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 = let err =
match AssetManager::decode_glyph_bank_from_buffer(&entry, &test_glyph_asset_data()) { match AssetManager::decode_glyph_bank_from_buffer(&entry, &test_glyph_asset_data()) {
@ -2070,7 +2135,67 @@ mod tests {
Err(err) => err, 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] #[test]

View File

@ -557,8 +557,9 @@ mod tests {
fn make_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank { fn make_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank {
let size = tile_size as usize; let size = tile_size as usize;
let mut bank = GlyphBank::new(tile_size, size, size); let mut bank =
bank.palettes[palette_id as usize][1] = color; 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 { for pixel in &mut bank.pixel_indices {
*pixel = 1; *pixel = 1;
} }

View File

@ -812,7 +812,14 @@ impl Gfx {
let fetch_x = if tile.entry.flip_x() { size - 1 - local_x } else { local_x }; 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 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 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 { if color.alpha() == 0 {
continue; continue;
} }
@ -862,7 +869,14 @@ impl Gfx {
let fetch_y = if sprite.flip_y { size - 1 - local_y } else { local_y }; 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 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 { if color.alpha() == 0 {
continue; continue;
} }
@ -1010,9 +1024,14 @@ mod tests {
fn make_glyph_bank(tile_size: TileSize, palette_colors: &[(u8, u8, Color)]) -> GlyphBank { fn make_glyph_bank(tile_size: TileSize, palette_colors: &[(u8, u8, Color)]) -> GlyphBank {
let size = tile_size as usize; 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 { 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 bank
} }
@ -1152,6 +1171,26 @@ mod tests {
assert_eq!(back[0], Color::RED.raw()); 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] #[test]
fn test_cached_tile_draws_opaque_color_index_zero() { fn test_cached_tile_draws_opaque_color_index_zero() {
let bank = make_filled_glyph_bank(TileSize::Size8, 0, &[(0, 0, Color::GREEN)]); 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()); 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] #[test]
fn test_draw_rect() { fn test_draw_rect() {
let banks = Arc::new(MemoryBanks::new()); let banks = Arc::new(MemoryBanks::new());

View File

@ -118,7 +118,11 @@ impl Game2DFrameComposer for Hardware {
} }
fn emit_sprite(&mut self, sprite: Sprite) -> prometeu_hal::ComposerOpStatus { 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; return prometeu_hal::ComposerOpStatus::BankInvalid;
} }
@ -261,7 +265,7 @@ mod tests {
fn make_glyph_bank() -> GlyphBank { fn make_glyph_bank() -> GlyphBank {
let mut bank = GlyphBank::new(TileSize::Size8, 8, 8); 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 { for pixel in &mut bank.pixel_indices {
*pixel = 1; *pixel = 1;
} }
@ -433,4 +437,28 @@ mod tests {
assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw()); 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);
}
} }

View File

@ -196,7 +196,7 @@ mod tests {
fn make_glyph_bank() -> GlyphBank { fn make_glyph_bank() -> GlyphBank {
let mut bank = GlyphBank::new(TileSize::Size8, 8, 8); let mut bank = GlyphBank::new(TileSize::Size8, 8, 8);
bank.palettes[0][1] = Color::WHITE; bank.palette_mut(0).unwrap()[1] = Color::WHITE;
bank bank
} }

View File

@ -203,7 +203,7 @@ mod tests {
use super::*; use super::*;
use crate::asset::{AssetCodec, AssetEntry, BankType, PreloadEntry}; use crate::asset::{AssetCodec, AssetEntry, BankType, PreloadEntry};
use crate::cartridge::{ASSETS_PA_MAGIC, ASSETS_PA_SCHEMA_VERSION, AssetsPackPrelude}; 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 serde_json::json;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@ -369,14 +369,14 @@ mod tests {
bank_type: BankType::GLYPH, bank_type: BankType::GLYPH,
offset, offset,
size, 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, codec: AssetCodec::None,
metadata: json!({ metadata: json!({
"tile_size": 16, "tile_size": 16,
"width": 16, "width": 16,
"height": 16, "height": 16,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1, "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1
}), }),
} }
} }
@ -452,14 +452,14 @@ mod tests {
bank_type: BankType::GLYPH, bank_type: BankType::GLYPH,
offset: 4, offset: 4,
size: 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, codec: AssetCodec::None,
metadata: json!({ metadata: json!({
"tile_size": 16, "tile_size": 16,
"width": 16, "width": 16,
"height": 16, "height": 16,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1, "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1
}), }),
}, },
]; ];
@ -519,8 +519,8 @@ mod tests {
"tile_size": 16, "tile_size": 16,
"width": 16, "width": 16,
"height": 16, "height": 16,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1, "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1
} }
}], }],
"preload": [] "preload": []
@ -561,8 +561,8 @@ mod tests {
"tile_size": 16, "tile_size": 16,
"width": 16, "width": 16,
"height": 16, "height": 16,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1, "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1
} }
}], }],
"preload": [] "preload": []

View File

@ -1,7 +1,7 @@
use crate::color::Color; use crate::color::Color;
use serde::{Deserialize, Serialize}; 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; pub const GLYPH_BANK_COLORS_PER_PALETTE: usize = 16;
/// Standard sizes for square tiles. /// Standard sizes for square tiles.
@ -35,22 +35,59 @@ pub struct GlyphBank {
/// Palette indices are ordinary indices; transparency is resolved through /// Palette indices are ordinary indices; transparency is resolved through
/// the RGBA alpha channel of the palette entry. /// the RGBA alpha channel of the palette entry.
pub pixel_indices: Vec<u8>, pub pixel_indices: Vec<u8>,
/// Runtime-facing v1 palette table: 64 palettes of 16 RGBA8888 colors each. /// Runtime-facing v1 palette table.
pub palettes: [[Color; GLYPH_BANK_COLORS_PER_PALETTE]; GLYPH_BANK_PALETTE_COUNT_V1], ///
/// 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 { 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 { 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 { Self {
tile_size, tile_size,
width, width,
height, height,
pixel_indices: vec![0; 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. /// Resolves a global tile ID and local pixel coordinates to a palette index.
/// tile_id: the tile index in the bank /// tile_id: the tile index in the bank
/// local_x, local_y: the pixel position inside the tile (0 to tile_size-1) /// 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. /// 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<Color> {
self.palettes self.palettes
.get(palette_id as usize) .get(palette_id as usize)
.and_then(|palette| palette.get(pixel_index as usize)) .and_then(|palette| palette.get(pixel_index as usize))
.copied() .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);
} }
} }

View File

@ -15,7 +15,7 @@ use prometeu_hal::asset::{
use prometeu_hal::cartridge::{AssetsPayloadSource, Cartridge}; use prometeu_hal::cartridge::{AssetsPayloadSource, Cartridge};
use prometeu_hal::color::Color; use prometeu_hal::color::Color;
use prometeu_hal::glyph::Glyph; 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_bank::SceneBank;
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer}; use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::syscalls::caps; 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 { 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::<u32>()) (width * height).div_ceil(2)
+ (GLYPH_BANK_MAX_PALETTE_COUNT_V1 * 16 * std::mem::size_of::<u32>())
} }
fn test_glyph_decoded_size(width: usize, height: usize) -> usize { fn test_glyph_decoded_size(width: usize, height: usize) -> usize {
width * height + (GLYPH_BANK_PALETTE_COUNT_V1 * 16 * std::mem::size_of::<u32>()) width * height + (GLYPH_BANK_MAX_PALETTE_COUNT_V1 * 16 * std::mem::size_of::<u32>())
} }
fn test_glyph_asset_entry(asset_name: &str, data_len: usize) -> AssetEntry { 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, "tile_size": 16,
"width": 16, "width": 16,
"height": 16, "height": 16,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1, "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1
}), }),
} }
} }
fn test_glyph_asset_data() -> Vec<u8> { fn test_glyph_asset_data() -> Vec<u8> {
let mut data = let mut data =
vec![0x11u8; test_glyph_payload_size(16, 16) - (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_PALETTE_COUNT_V1 * 16 * 4]); data.extend_from_slice(&[0u8; GLYPH_BANK_MAX_PALETTE_COUNT_V1 * 16 * 4]);
data data
} }
fn runtime_test_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank { fn runtime_test_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank {
let size = tile_size as usize; let size = tile_size as usize;
let mut bank = GlyphBank::new(tile_size, size, size); let mut bank = GlyphBank::with_palette_count(tile_size, size, size, palette_id as usize + 1);
bank.palettes[palette_id as usize][1] = color; bank.palette_mut(palette_id).unwrap()[1] = color;
for pixel in &mut bank.pixel_indices { for pixel in &mut bank.pixel_indices {
*pixel = 1; *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)]); 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<u32, String> = 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] #[test]
fn tick_composer_emit_sprite_invalid_layer_returns_status_not_crash() { fn tick_composer_emit_sprite_invalid_layer_returns_status_not_crash() {
let mut runtime = VirtualMachineRuntime::new(None); let mut runtime = VirtualMachineRuntime::new(None);

View File

@ -14,7 +14,7 @@ use prometeu_hal::cartridge::{
use prometeu_hal::color::Color; use prometeu_hal::color::Color;
use prometeu_hal::glyph::Glyph; use prometeu_hal::glyph::Glyph;
use prometeu_hal::glyph_bank::{ 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_bank::SceneBank;
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer}; use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
@ -275,15 +275,15 @@ fn build_glyph_asset() -> (AssetEntry, Vec<u8>) {
bank_type: BankType::GLYPH, bank_type: BankType::GLYPH,
offset: 0, offset: 0,
size: payload.len() as u64, 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, as u64,
codec: AssetCodec::None, codec: AssetCodec::None,
metadata: serde_json::json!({ metadata: serde_json::json!({
"tile_size": 8, "tile_size": 8,
"width": 8, "width": 8,
"height": 8, "height": 8,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1, "palette_count": GLYPH_BANK_MAX_PALETTE_COUNT_V1,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1 "palette_authored": GLYPH_BANK_MAX_PALETTE_COUNT_V1
}), }),
}; };
@ -292,8 +292,8 @@ fn build_glyph_asset() -> (AssetEntry, Vec<u8>) {
fn build_palette_bytes() -> Vec<u8> { fn build_palette_bytes() -> Vec<u8> {
let mut bytes = let mut bytes =
Vec::with_capacity(GLYPH_BANK_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * 4); Vec::with_capacity(GLYPH_BANK_MAX_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * 4);
for palette_id in 0..GLYPH_BANK_PALETTE_COUNT_V1 { for palette_id in 0..GLYPH_BANK_MAX_PALETTE_COUNT_V1 {
for color_index in 0..GLYPH_BANK_COLORS_PER_PALETTE { for color_index in 0..GLYPH_BANK_COLORS_PER_PALETTE {
let color = if color_index == 1 { stress_color(palette_id) } else { Color::BLACK }; let color = if color_index == 1 { stress_color(palette_id) } else { Color::BLACK };
bytes.extend_from_slice(&color.raw().to_be_bytes()); bytes.extend_from_slice(&color.raw().to_be_bytes());

0
discussion/.index.lock Normal file
View File

View File

@ -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-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-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."} {"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-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-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-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"}]}

View File

@ -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.

View File

@ -625,7 +625,8 @@ Each tilemap cell contains:
Runtime-facing validity rule for v1: 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 #### Sprite
@ -640,7 +641,8 @@ Each sprite draw contains:
Runtime-facing validity rule for v1: 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: The pipeline works like this:
1. Read indexed pixel from tile (value 0..15) 1. Read indexed pixel from tile (value 0..15)
2. Resolve: 2. Validate and resolve:
- real_color = palette[palette_id][index] - real_color = palette[palette_id][index]
3. Apply: 3. Apply:
- flip - flip
@ -667,6 +669,9 @@ else:
draw_or_blend(color) 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 ### 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; - 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 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 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. - runtime MUST NOT continue canonical scene composition after such a dependency failure.
### 20.2 `composer.emit_sprite` ### 20.2 `composer.emit_sprite`
@ -774,7 +781,7 @@ Rules:
ABI: ABI:
1. `glyph_id: int` — glyph index within the bank 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 3. `x: int` — x coordinate
4. `y: int` — y coordinate 4. `y: int` — y coordinate
5. `layer: int` — composition layer reference 5. `layer: int` — composition layer reference
@ -797,4 +804,6 @@ Operational notes:
- the canonical public sprite contract is frame-emission based; - the canonical public sprite contract is frame-emission based;
- no caller-provided sprite index exists in the v1 canonical ABI; - no caller-provided sprite index exists in the v1 canonical ABI;
- no `active` flag 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. - overflow remains non-fatal and must not escalate to trap in v1.

View File

@ -115,7 +115,7 @@ For `BankType::GLYPH`, the v1 runtime-facing contract is:
- `codec = NONE` - `codec = NONE`
- serialized pixels use packed `u4` palette indices - serialized pixels use packed `u4` palette indices
- serialized palettes use `RGBA8888` with canonical RGBA channel order - 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 - 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. 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` - `tile_size`: tile edge in pixels; valid values are `8`, `16`, or `32`
- `width`: total sheet width in pixels - `width`: total sheet width in pixels
- `height`: total sheet height 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: Optional informative subtrees:
@ -145,7 +145,7 @@ Optional informative subtrees:
Validation rules for `GLYPH` v1: 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 - `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. - 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` - serialized pixel plane: packed `4bpp`
- decoded pixel plane: expanded `u8` indices, one entry per pixel - 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: 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 Palette indices are ordinary indices. Transparency is represented by the alpha
channel of the resolved RGBA8888 palette entry, not by reserving index `0`. 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 ### 4.2 `SCENE` asset contract in v1
For `BankType::SCENE`, the v1 runtime-facing contract is: For `BankType::SCENE`, the v1 runtime-facing contract is: