dev/scene-bank-glyph-runtime-binding-leak #21

Merged
bquarkz merged 8 commits from dev/scene-bank-glyph-runtime-binding-leak into master 2026-04-24 15:17:02 +00:00
23 changed files with 650 additions and 234 deletions

View File

@ -32,6 +32,35 @@ type StagingMap<T> = HashMap<HandleId, StagedValue<T>>;
type AssetTable = HashMap<AssetId, AssetEntry>; type AssetTable = HashMap<AssetId, AssetEntry>;
type HandleTable = HashMap<HandleId, LoadHandleInfo>; type HandleTable = HashMap<HandleId, LoadHandleInfo>;
#[derive(Clone, Default)]
pub struct GlyphAssetSlotIndex {
slots_by_asset_id: Arc<RwLock<HashMap<AssetId, usize>>>,
}
impl GlyphAssetSlotIndex {
pub fn new() -> Self {
Self { slots_by_asset_id: Arc::new(RwLock::new(HashMap::new())) }
}
pub fn glyph_slot_for_asset(&self, asset_id: AssetId) -> Option<usize> {
self.slots_by_asset_id.read().unwrap().get(&asset_id).copied()
}
pub fn rebuild_from_slots(&self, slots: &[Option<AssetId>; 16]) {
let mut map = self.slots_by_asset_id.write().unwrap();
map.clear();
for (slot, asset_id) in slots.iter().enumerate() {
if let Some(asset_id) = asset_id {
map.insert(*asset_id, slot);
}
}
}
fn clear(&self) {
self.slots_by_asset_id.write().unwrap().clear();
}
}
const GLYPH_BANK_PALETTE_COUNT_V1: usize = 64; const GLYPH_BANK_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_V1: usize =
@ -163,6 +192,7 @@ pub struct AssetManager {
/// Track what is installed in each hardware slot (for stats/info). /// Track what is installed in each hardware slot (for stats/info).
gfx_slots: Arc<RwLock<[Option<AssetId>; 16]>>, gfx_slots: Arc<RwLock<[Option<AssetId>; 16]>>,
glyph_slot_index: GlyphAssetSlotIndex,
sound_slots: Arc<RwLock<[Option<AssetId>; 16]>>, sound_slots: Arc<RwLock<[Option<AssetId>; 16]>>,
scene_slots: Arc<RwLock<[Option<AssetId>; 16]>>, scene_slots: Arc<RwLock<[Option<AssetId>; 16]>>,
@ -309,6 +339,7 @@ impl AssetManager {
sound_installer, sound_installer,
scene_installer, scene_installer,
gfx_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))), gfx_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
glyph_slot_index: GlyphAssetSlotIndex::new(),
sound_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))), sound_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
scene_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))), scene_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
gfx_policy: BankPolicy::new(), gfx_policy: BankPolicy::new(),
@ -361,6 +392,7 @@ impl AssetManager {
let mut slots = self.gfx_slots.write().unwrap(); let mut slots = self.gfx_slots.write().unwrap();
if slot_index < slots.len() { if slot_index < slots.len() {
slots[slot_index] = Some(entry.asset_id); slots[slot_index] = Some(entry.asset_id);
self.glyph_slot_index.rebuild_from_slots(&slots);
} }
} }
} }
@ -746,7 +778,7 @@ impl AssetManager {
let mut decoded_size = 0_usize; let mut decoded_size = 0_usize;
let layers = std::array::from_fn(|_| SceneLayer { let layers = std::array::from_fn(|_| SceneLayer {
active: false, active: false,
glyph_bank_id: 0, glyph_asset_id: 0,
tile_size: TileSize::Size8, tile_size: TileSize::Size8,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap { width: 0, height: 0, tiles: Vec::new() }, tilemap: TileMap { width: 0, height: 0, tiles: Vec::new() },
@ -762,8 +794,13 @@ impl AssetManager {
} }
let flags = buffer[offset]; let flags = buffer[offset];
let glyph_bank_id = buffer[offset + 1]; let glyph_asset_id = i32::from_le_bytes([
let tile_size_raw = buffer[offset + 2]; buffer[offset + 1],
buffer[offset + 2],
buffer[offset + 3],
buffer[offset + 4],
]);
let tile_size_raw = buffer[offset + 5];
let tile_size = match tile_size_raw { let tile_size = match tile_size_raw {
8 => TileSize::Size8, 8 => TileSize::Size8,
16 => TileSize::Size16, 16 => TileSize::Size16,
@ -771,39 +808,39 @@ impl AssetManager {
other => return Err(format!("Invalid SCENE tile size: {}", other)), other => return Err(format!("Invalid SCENE tile size: {}", other)),
}; };
let parallax_factor_x = f32::from_le_bytes([ let parallax_factor_x = f32::from_le_bytes([
buffer[offset + 4],
buffer[offset + 5],
buffer[offset + 6],
buffer[offset + 7],
]);
let parallax_factor_y = f32::from_le_bytes([
buffer[offset + 8], buffer[offset + 8],
buffer[offset + 9], buffer[offset + 9],
buffer[offset + 10], buffer[offset + 10],
buffer[offset + 11], buffer[offset + 11],
]); ]);
let parallax_factor_y = f32::from_le_bytes([
buffer[offset + 12],
buffer[offset + 13],
buffer[offset + 14],
buffer[offset + 15],
]);
if !parallax_factor_x.is_finite() || !parallax_factor_y.is_finite() { if !parallax_factor_x.is_finite() || !parallax_factor_y.is_finite() {
return Err("Invalid SCENE parallax_factor".to_string()); return Err("Invalid SCENE parallax_factor".to_string());
} }
let width = u32::from_le_bytes([ let width = u32::from_le_bytes([
buffer[offset + 12],
buffer[offset + 13],
buffer[offset + 14],
buffer[offset + 15],
]) as usize;
let height = u32::from_le_bytes([
buffer[offset + 16], buffer[offset + 16],
buffer[offset + 17], buffer[offset + 17],
buffer[offset + 18], buffer[offset + 18],
buffer[offset + 19], buffer[offset + 19],
]) as usize; ]) as usize;
let tile_count = u32::from_le_bytes([ let height = u32::from_le_bytes([
buffer[offset + 20], buffer[offset + 20],
buffer[offset + 21], buffer[offset + 21],
buffer[offset + 22], buffer[offset + 22],
buffer[offset + 23], buffer[offset + 23],
]) as usize; ]) as usize;
let tile_count = u32::from_le_bytes([
buffer[offset + 24],
buffer[offset + 25],
buffer[offset + 26],
buffer[offset + 27],
]) as usize;
let expected_tile_count = let expected_tile_count =
width.checked_mul(height).ok_or("SCENE tile count overflow")?; width.checked_mul(height).ok_or("SCENE tile count overflow")?;
@ -845,7 +882,7 @@ impl AssetManager {
*layer = SceneLayer { *layer = SceneLayer {
active: (flags & 0b0000_0001) != 0, active: (flags & 0b0000_0001) != 0,
glyph_bank_id, glyph_asset_id,
tile_size, tile_size,
parallax_factor: ParallaxFactor { x: parallax_factor_x, y: parallax_factor_y }, parallax_factor: ParallaxFactor { x: parallax_factor_x, y: parallax_factor_y },
tilemap: TileMap { width, height, tiles }, tilemap: TileMap { width, height, tiles },
@ -960,6 +997,7 @@ impl AssetManager {
let mut slots = self.gfx_slots.write().unwrap(); let mut slots = self.gfx_slots.write().unwrap();
if h.slot.index < slots.len() { if h.slot.index < slots.len() {
slots[h.slot.index] = Some(h._asset_id); slots[h.slot.index] = Some(h._asset_id);
self.glyph_slot_index.rebuild_from_slots(&slots);
} }
h.status = LoadStatus::COMMITTED; h.status = LoadStatus::COMMITTED;
} }
@ -1089,9 +1127,14 @@ impl AssetManager {
self.handles.write().unwrap().clear(); self.handles.write().unwrap().clear();
self.pending_commits.lock().unwrap().clear(); self.pending_commits.lock().unwrap().clear();
self.gfx_slots.write().unwrap().fill(None); self.gfx_slots.write().unwrap().fill(None);
self.glyph_slot_index.clear();
self.sound_slots.write().unwrap().fill(None); self.sound_slots.write().unwrap().fill(None);
self.scene_slots.write().unwrap().fill(None); self.scene_slots.write().unwrap().fill(None);
} }
pub fn glyph_asset_slot_index(&self) -> GlyphAssetSlotIndex {
self.glyph_slot_index.clone()
}
} }
#[cfg(test)] #[cfg(test)]
@ -1143,62 +1186,53 @@ mod tests {
} }
fn test_scene() -> SceneBank { fn test_scene() -> SceneBank {
let make_layer = let make_layer = |glyph_asset_id: AssetId,
|glyph_bank_id: u8, parallax_x: f32, parallax_y: f32, tile_size: TileSize| SceneLayer { active: bool,
active: glyph_bank_id != 3, parallax_x: f32,
glyph_bank_id, parallax_y: f32,
tile_size, tile_size: TileSize| SceneLayer {
parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y }, active,
tilemap: TileMap { glyph_asset_id,
width: 2, tile_size,
height: 2, parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
tiles: vec![ tilemap: TileMap {
Tile { width: 2,
active: true, height: 2,
glyph: Glyph { tiles: vec![
glyph_id: 10 + glyph_bank_id as u16, Tile {
palette_id: glyph_bank_id, active: true,
}, glyph: Glyph { glyph_id: 10, palette_id: 1 },
flip_x: false, flip_x: false,
flip_y: false, flip_y: false,
}, },
Tile { Tile {
active: true, active: true,
glyph: Glyph { glyph: Glyph { glyph_id: 20, palette_id: 2 },
glyph_id: 20 + glyph_bank_id as u16, flip_x: true,
palette_id: glyph_bank_id + 1, flip_y: false,
}, },
flip_x: true, Tile {
flip_y: false, active: true,
}, glyph: Glyph { glyph_id: 30, palette_id: 3 },
Tile { flip_x: false,
active: true, flip_y: true,
glyph: Glyph { },
glyph_id: 30 + glyph_bank_id as u16, Tile {
palette_id: glyph_bank_id + 2, active,
}, glyph: Glyph { glyph_id: 40, palette_id: 4 },
flip_x: false, flip_x: true,
flip_y: true, flip_y: true,
}, },
Tile { ],
active: glyph_bank_id != 2, },
glyph: Glyph { };
glyph_id: 40 + glyph_bank_id as u16,
palette_id: glyph_bank_id + 3,
},
flip_x: true,
flip_y: true,
},
],
},
};
SceneBank { SceneBank {
layers: [ layers: [
make_layer(0, 1.0, 1.0, TileSize::Size16), make_layer(100, true, 1.0, 1.0, TileSize::Size16),
make_layer(1, 0.5, 0.75, TileSize::Size8), make_layer(101, true, 0.5, 0.75, TileSize::Size8),
make_layer(2, 1.0, 0.5, TileSize::Size32), make_layer(102, true, 1.0, 0.5, TileSize::Size32),
make_layer(3, 0.25, 0.25, TileSize::Size16), make_layer(103, false, 0.25, 0.25, TileSize::Size16),
], ],
} }
} }
@ -1224,9 +1258,9 @@ mod tests {
for layer in &scene.layers { for layer in &scene.layers {
let layer_flags = if layer.active { 0b0000_0001 } else { 0 }; let layer_flags = if layer.active { 0b0000_0001 } else { 0 };
data.push(layer_flags); data.push(layer_flags);
data.push(layer.glyph_bank_id); data.extend_from_slice(&layer.glyph_asset_id.to_le_bytes());
data.push(layer.tile_size as u8); data.push(layer.tile_size as u8);
data.push(0); data.extend_from_slice(&0_u16.to_le_bytes());
data.extend_from_slice(&layer.parallax_factor.x.to_le_bytes()); data.extend_from_slice(&layer.parallax_factor.x.to_le_bytes());
data.extend_from_slice(&layer.parallax_factor.y.to_le_bytes()); data.extend_from_slice(&layer.parallax_factor.y.to_le_bytes());
data.extend_from_slice(&(layer.tilemap.width as u32).to_le_bytes()); data.extend_from_slice(&(layer.tilemap.width as u32).to_le_bytes());
@ -1358,12 +1392,12 @@ mod tests {
let decoded = AssetManager::decode_scene_bank_from_buffer(&entry, &data).expect("scene"); let decoded = AssetManager::decode_scene_bank_from_buffer(&entry, &data).expect("scene");
assert_eq!(decoded.layers[1].glyph_bank_id, 1); assert_eq!(decoded.layers[1].glyph_asset_id, 101);
assert_eq!(decoded.layers[1].parallax_factor.x, 0.5); assert_eq!(decoded.layers[1].parallax_factor.x, 0.5);
assert_eq!(decoded.layers[2].tile_size, TileSize::Size32); assert_eq!(decoded.layers[2].tile_size, TileSize::Size32);
assert_eq!(decoded.layers[0].tilemap.tiles[1].flip_x, true); assert!(decoded.layers[0].tilemap.tiles[1].flip_x);
assert_eq!(decoded.layers[2].tilemap.tiles[2].flip_y, true); assert!(decoded.layers[2].tilemap.tiles[2].flip_y);
assert_eq!(decoded.layers[3].active, false); assert!(!decoded.layers[3].active);
} }
#[test] #[test]
@ -1382,7 +1416,7 @@ mod tests {
fn test_decode_scene_bank_rejects_invalid_tile_size() { fn test_decode_scene_bank_rejects_invalid_tile_size() {
let scene = test_scene(); let scene = test_scene();
let mut data = encode_scene_payload(&scene); let mut data = encode_scene_payload(&scene);
data[14] = 12; data[17] = 12;
let entry = test_scene_asset_entry("scene", &data, &scene); let entry = test_scene_asset_entry("scene", &data, &scene);
let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err(); let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err();
@ -1406,7 +1440,7 @@ mod tests {
fn test_decode_scene_bank_rejects_tile_count_mismatch() { fn test_decode_scene_bank_rejects_tile_count_mismatch() {
let scene = test_scene(); let scene = test_scene();
let mut data = encode_scene_payload(&scene); let mut data = encode_scene_payload(&scene);
data[32..36].copy_from_slice(&5_u32.to_le_bytes()); data[36..40].copy_from_slice(&5_u32.to_le_bytes());
let entry = test_scene_asset_entry("scene", &data, &scene); let entry = test_scene_asset_entry("scene", &data, &scene);
let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err(); let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err();

View File

@ -1,3 +1,4 @@
use crate::asset::GlyphAssetSlotIndex;
use crate::memory_banks::SceneBankPoolAccess; use crate::memory_banks::SceneBankPoolAccess;
use prometeu_hal::GfxBridge; use prometeu_hal::GfxBridge;
use prometeu_hal::glyph::Glyph; use prometeu_hal::glyph::Glyph;
@ -116,6 +117,7 @@ impl SpriteController {
pub struct FrameComposer { pub struct FrameComposer {
scene_bank_pool: Arc<dyn SceneBankPoolAccess>, scene_bank_pool: Arc<dyn SceneBankPoolAccess>,
glyph_slot_index: GlyphAssetSlotIndex,
viewport_width_px: usize, viewport_width_px: usize,
viewport_height_px: usize, viewport_height_px: usize,
active_scene_id: Option<usize>, active_scene_id: Option<usize>,
@ -133,9 +135,11 @@ impl FrameComposer {
viewport_width_px: usize, viewport_width_px: usize,
viewport_height_px: usize, viewport_height_px: usize,
scene_bank_pool: Arc<dyn SceneBankPoolAccess>, scene_bank_pool: Arc<dyn SceneBankPoolAccess>,
glyph_slot_index: GlyphAssetSlotIndex,
) -> Self { ) -> Self {
Self { Self {
scene_bank_pool, scene_bank_pool,
glyph_slot_index,
viewport_width_px, viewport_width_px,
viewport_height_px, viewport_height_px,
active_scene_id: None, active_scene_id: None,
@ -187,6 +191,8 @@ impl FrameComposer {
return false; return false;
}; };
let _ = self.resolve_glyph_slots(&scene, scene_bank_id, "bind_scene");
let (cache, resolver) = let (cache, resolver) =
Self::build_scene_runtime(self.viewport_width_px, self.viewport_height_px, &scene); Self::build_scene_runtime(self.viewport_width_px, self.viewport_height_px, &scene);
self.active_scene_id = Some(scene_bank_id); self.active_scene_id = Some(scene_bank_id);
@ -242,15 +248,20 @@ impl FrameComposer {
let ordered_sprites = self.ordered_sprites(); let ordered_sprites = self.ordered_sprites();
gfx.load_frame_sprites(&ordered_sprites); gfx.load_frame_sprites(&ordered_sprites);
if let (Some(scene), Some(cache), Some(resolver)) = if let Some(scene) = self.active_scene.as_deref() {
(self.active_scene.as_deref(), self.cache.as_mut(), self.resolver.as_mut()) let scene_bank_id = self.active_scene_id.expect("active scene id must exist");
{ let resolved_glyph_slots =
self.resolve_glyph_slots(scene, scene_bank_id, "render_frame");
let (Some(cache), Some(resolver)) = (self.cache.as_mut(), self.resolver.as_mut())
else {
panic!("SCENE runtime invariant broken: active scene without cache/resolver");
};
let update = resolver.update(scene, self.camera_x_px, self.camera_y_px); let update = resolver.update(scene, self.camera_x_px, self.camera_y_px);
Self::apply_refresh_requests(cache, scene, &update.refresh_requests); Self::apply_refresh_requests(cache, scene, &update.refresh_requests);
// `FrameComposer` owns only canonical game-frame composition. // `FrameComposer` owns only canonical game-frame composition.
// Deferred `gfx.*` primitives are drained later by a separate // Deferred `gfx.*` primitives are drained later by a separate
// overlay/debug stage outside this service boundary. // overlay/debug stage outside this service boundary.
gfx.render_scene_from_cache(cache, &update); gfx.render_scene_from_cache(cache, &update, &resolved_glyph_slots);
return; return;
} }
@ -284,6 +295,23 @@ impl FrameComposer {
) )
} }
fn resolve_glyph_slots(
&self,
scene: &SceneBank,
scene_bank_id: usize,
phase: &str,
) -> [usize; 4] {
std::array::from_fn(|layer_index| {
let layer = &scene.layers[layer_index];
self.glyph_slot_index.glyph_slot_for_asset(layer.glyph_asset_id).unwrap_or_else(|| {
panic!(
"SCENE dependency fatal during {}: scene_bank_id={}, layer={}, glyph_asset_id={}",
phase, scene_bank_id, layer_index, layer.glyph_asset_id
)
})
})
}
fn apply_refresh_requests( fn apply_refresh_requests(
cache: &mut SceneViewportCache, cache: &mut SceneViewportCache,
scene: &SceneBank, scene: &SceneBank,
@ -315,24 +343,26 @@ mod tests {
use crate::memory_banks::{ use crate::memory_banks::{
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller, GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller,
}; };
use prometeu_hal::asset::AssetId;
use prometeu_hal::color::Color; use prometeu_hal::color::Color;
use prometeu_hal::glyph_bank::{GlyphBank, TileSize}; use prometeu_hal::glyph_bank::{GlyphBank, TileSize};
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer}; use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::tile::Tile; use prometeu_hal::tile::Tile;
use prometeu_hal::tilemap::TileMap; use prometeu_hal::tilemap::TileMap;
use std::panic::{AssertUnwindSafe, catch_unwind};
fn make_scene() -> SceneBank { fn make_scene() -> SceneBank {
make_scene_with_palette(1, 1, TileSize::Size8) make_scene_with_palette(1, 1, TileSize::Size8)
} }
fn make_scene_with_palette( fn make_scene_with_palette(
glyph_bank_id: u8, glyph_asset_id: AssetId,
palette_id: u8, palette_id: u8,
tile_size: TileSize, tile_size: TileSize,
) -> SceneBank { ) -> SceneBank {
let layer = SceneLayer { let layer = SceneLayer {
active: true, active: true,
glyph_bank_id, glyph_asset_id,
tile_size, tile_size,
parallax_factor: ParallaxFactor { x: 1.0, y: 0.5 }, parallax_factor: ParallaxFactor { x: 1.0, y: 0.5 },
tilemap: TileMap { tilemap: TileMap {
@ -353,6 +383,16 @@ mod tests {
SceneBank { layers: std::array::from_fn(|_| layer.clone()) } SceneBank { layers: std::array::from_fn(|_| layer.clone()) }
} }
fn make_glyph_slot_index(bindings: &[(AssetId, usize)]) -> GlyphAssetSlotIndex {
let index = GlyphAssetSlotIndex::new();
let mut slots = [None; 16];
for (asset_id, slot) in bindings {
slots[*slot] = Some(*asset_id);
}
index.rebuild_from_slots(&slots);
index
}
fn make_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank { 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 = GlyphBank::new(tile_size, size, size);
@ -365,7 +405,8 @@ mod tests {
#[test] #[test]
fn frame_composer_starts_unbound_with_empty_owned_state() { fn frame_composer_starts_unbound_with_empty_owned_state() {
let frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); let frame_composer =
FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[]));
assert_eq!(frame_composer.viewport_size(), (320, 180)); assert_eq!(frame_composer.viewport_size(), (320, 180));
assert_eq!(frame_composer.active_scene_id(), None); assert_eq!(frame_composer.active_scene_id(), None);
@ -384,7 +425,7 @@ mod tests {
let banks = Arc::new(MemoryBanks::new()); let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(3, Arc::new(make_scene())); banks.install_scene_bank(3, Arc::new(make_scene()));
let frame_composer = FrameComposer::new(320, 180, banks); let frame_composer = FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)]));
let scene = let scene =
frame_composer.scene_bank_slot(3).expect("scene bank slot 3 should be resident"); frame_composer.scene_bank_slot(3).expect("scene bank slot 3 should be resident");
@ -400,7 +441,8 @@ mod tests {
let expected_scene = let expected_scene =
banks.scene_bank_slot(3).expect("scene bank slot 3 should be resident"); banks.scene_bank_slot(3).expect("scene bank slot 3 should be resident");
let mut frame_composer = FrameComposer::new(320, 180, banks); let mut frame_composer =
FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)]));
assert!(frame_composer.bind_scene(3)); assert!(frame_composer.bind_scene(3));
@ -419,7 +461,8 @@ mod tests {
let banks = Arc::new(MemoryBanks::new()); let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(1, Arc::new(make_scene())); banks.install_scene_bank(1, Arc::new(make_scene()));
let mut frame_composer = FrameComposer::new(320, 180, banks); let mut frame_composer =
FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)]));
assert!(frame_composer.bind_scene(1)); assert!(frame_composer.bind_scene(1));
frame_composer.unbind_scene(); frame_composer.unbind_scene();
@ -433,7 +476,8 @@ mod tests {
#[test] #[test]
fn set_camera_stores_top_left_pixel_coordinates() { fn set_camera_stores_top_left_pixel_coordinates() {
let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); let mut frame_composer =
FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[]));
frame_composer.set_camera(-12, 48); frame_composer.set_camera(-12, 48);
@ -445,7 +489,8 @@ mod tests {
let banks = Arc::new(MemoryBanks::new()); let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(0, Arc::new(make_scene())); banks.install_scene_bank(0, Arc::new(make_scene()));
let mut frame_composer = FrameComposer::new(320, 180, banks); let mut frame_composer =
FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)]));
assert!(frame_composer.bind_scene(0)); assert!(frame_composer.bind_scene(0));
let cache = frame_composer.cache().expect("cache should exist for bound scene"); let cache = frame_composer.cache().expect("cache should exist for bound scene");
@ -454,7 +499,8 @@ mod tests {
#[test] #[test]
fn missing_scene_binding_falls_back_to_no_scene_state() { fn missing_scene_binding_falls_back_to_no_scene_state() {
let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); let mut frame_composer =
FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[]));
assert!(!frame_composer.bind_scene(7)); assert!(!frame_composer.bind_scene(7));
@ -463,6 +509,27 @@ mod tests {
assert!(frame_composer.resolver().is_none()); assert!(frame_composer.resolver().is_none());
} }
#[test]
fn bind_scene_panics_when_scene_dependency_is_not_resident() {
let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(0, Arc::new(make_scene()));
let mut frame_composer = FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[]));
let panic = catch_unwind(AssertUnwindSafe(|| frame_composer.bind_scene(0)))
.expect_err("bind_scene should panic when dependency is missing");
let message = if let Some(message) = panic.downcast_ref::<String>() {
message.clone()
} else if let Some(message) = panic.downcast_ref::<&str>() {
(*message).to_string()
} else {
String::new()
};
assert!(message.contains("SCENE dependency fatal during bind_scene"));
assert!(message.contains("glyph_asset_id=1"));
}
#[test] #[test]
fn sprite_controller_begin_frame_resets_sprite_count_and_buckets() { fn sprite_controller_begin_frame_resets_sprite_count_and_buckets() {
let mut controller = SpriteController::new(); let mut controller = SpriteController::new();
@ -582,7 +649,8 @@ mod tests {
#[test] #[test]
fn frame_composer_emits_ordered_sprites_for_rendering() { fn frame_composer_emits_ordered_sprites_for_rendering() {
let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new())); let mut frame_composer =
FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[]));
frame_composer.begin_frame(); frame_composer.begin_frame();
assert!(frame_composer.emit_sprite(Sprite { assert!(frame_composer.emit_sprite(Sprite {
@ -620,8 +688,12 @@ mod tests {
let banks = Arc::new(MemoryBanks::new()); let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(1, Arc::new(make_glyph_bank(TileSize::Size8, 3, Color::WHITE))); banks.install_glyph_bank(1, Arc::new(make_glyph_bank(TileSize::Size8, 3, Color::WHITE)));
let mut frame_composer = let mut frame_composer = FrameComposer::new(
FrameComposer::new(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>); 16,
16,
Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>,
make_glyph_slot_index(&[]),
);
frame_composer.begin_frame(); frame_composer.begin_frame();
assert!(frame_composer.emit_sprite(Sprite { assert!(frame_composer.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 0, palette_id: 3 }, glyph: Glyph { glyph_id: 0, palette_id: 3 },
@ -651,8 +723,12 @@ mod tests {
banks.install_glyph_bank(0, Arc::new(make_glyph_bank(TileSize::Size8, 2, Color::BLUE))); banks.install_glyph_bank(0, Arc::new(make_glyph_bank(TileSize::Size8, 2, Color::BLUE)));
banks.install_scene_bank(0, Arc::new(make_scene_with_palette(0, 2, TileSize::Size8))); banks.install_scene_bank(0, Arc::new(make_scene_with_palette(0, 2, TileSize::Size8)));
let mut frame_composer = let mut frame_composer = FrameComposer::new(
FrameComposer::new(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>); 16,
16,
Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>,
make_glyph_slot_index(&[(0, 0)]),
);
assert!(frame_composer.bind_scene(0)); assert!(frame_composer.bind_scene(0));
let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>); let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>);
@ -682,8 +758,12 @@ mod tests {
banks.install_scene_bank(0, Arc::new(make_scene_with_palette(0, 1, TileSize::Size8))); banks.install_scene_bank(0, Arc::new(make_scene_with_palette(0, 1, TileSize::Size8)));
banks.install_scene_bank(1, Arc::new(make_scene_with_palette(1, 2, TileSize::Size8))); banks.install_scene_bank(1, Arc::new(make_scene_with_palette(1, 2, TileSize::Size8)));
let mut frame_composer = let mut frame_composer = FrameComposer::new(
FrameComposer::new(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>); 16,
16,
Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>,
make_glyph_slot_index(&[(0, 0), (1, 1)]),
);
let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>); let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>);
gfx.scene_fade_level = 31; gfx.scene_fade_level = 31;
gfx.hud_fade_level = 31; gfx.hud_fade_level = 31;
@ -716,4 +796,59 @@ mod tests {
gfx.present(); gfx.present();
assert_eq!(gfx.front_buffer()[0], Color::BLUE.raw()); assert_eq!(gfx.front_buffer()[0], Color::BLUE.raw());
} }
#[test]
fn render_frame_panics_when_dependency_disappears_after_bind() {
let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(0, Arc::new(make_glyph_bank(TileSize::Size8, 1, Color::RED)));
banks.install_scene_bank(0, Arc::new(make_scene_with_palette(1, 1, TileSize::Size8)));
let glyph_slot_index = make_glyph_slot_index(&[(1, 0)]);
let mut frame_composer = FrameComposer::new(
16,
16,
Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>,
glyph_slot_index.clone(),
);
assert!(frame_composer.bind_scene(0));
glyph_slot_index.rebuild_from_slots(&[None; 16]);
let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>);
let panic = catch_unwind(AssertUnwindSafe(|| frame_composer.render_frame(&mut gfx)))
.expect_err("render_frame should panic when dependency disappears");
let message = if let Some(message) = panic.downcast_ref::<String>() {
message.clone()
} else if let Some(message) = panic.downcast_ref::<&str>() {
(*message).to_string()
} else {
String::new()
};
assert!(message.contains("SCENE dependency fatal during render_frame"));
assert!(message.contains("glyph_asset_id=1"));
}
#[test]
fn render_frame_resolves_glyph_dependency_by_asset_id_not_by_slot_number() {
let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(7, Arc::new(make_glyph_bank(TileSize::Size8, 2, Color::BLUE)));
banks.install_scene_bank(0, Arc::new(make_scene_with_palette(42, 2, TileSize::Size8)));
let mut frame_composer = FrameComposer::new(
16,
16,
Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>,
make_glyph_slot_index(&[(42, 7)]),
);
assert!(frame_composer.bind_scene(0));
let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>);
gfx.scene_fade_level = 31;
gfx.hud_fade_level = 31;
frame_composer.render_frame(&mut gfx);
gfx.present();
assert_eq!(gfx.front_buffer()[0], Color::BLUE.raw());
}
} }

View File

@ -222,8 +222,13 @@ impl GfxBridge for Gfx {
fn render_no_scene_frame(&mut self) { fn render_no_scene_frame(&mut self) {
self.render_no_scene_frame() self.render_no_scene_frame()
} }
fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) { fn render_scene_from_cache(
self.render_scene_from_cache(cache, update) &mut self,
cache: &SceneViewportCache,
update: &ResolverUpdate,
resolved_glyph_slots: &[usize; 4],
) {
self.render_scene_from_cache(cache, update, resolved_glyph_slots)
} }
fn load_frame_sprites(&mut self, sprites: &[Sprite]) { fn load_frame_sprites(&mut self, sprites: &[Sprite]) {
self.load_frame_sprites(sprites) self.load_frame_sprites(sprites)
@ -646,11 +651,18 @@ impl Gfx {
/// This is the cache-backed world path accepted by DEC-0013. The canonical scene /// This is the cache-backed world path accepted by DEC-0013. The canonical scene
/// is not consulted here; the renderer only consumes prepared cache materialization /// is not consulted here; the renderer only consumes prepared cache materialization
/// plus sprite state and fade controls. /// plus sprite state and fade controls.
pub fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) { pub fn render_scene_from_cache(
&mut self,
cache: &SceneViewportCache,
update: &ResolverUpdate,
resolved_glyph_slots: &[usize; 4],
) {
self.back.fill(Color::BLACK.raw()); self.back.fill(Color::BLACK.raw());
self.populate_layer_buckets(); self.populate_layer_buckets();
for layer_index in 0..cache.layers.len() { for (layer_index, glyph_slot) in
resolved_glyph_slots.iter().enumerate().take(cache.layers.len())
{
Self::draw_bucket_on_buffer( Self::draw_bucket_on_buffer(
&mut self.back, &mut self.back,
self.w, self.w,
@ -665,6 +677,7 @@ impl Gfx {
self.h, self.h,
cache, cache,
&update.copy_requests[layer_index], &update.copy_requests[layer_index],
*glyph_slot,
&*self.glyph_banks, &*self.glyph_banks,
); );
} }
@ -695,6 +708,7 @@ impl Gfx {
screen_h: usize, screen_h: usize,
cache: &SceneViewportCache, cache: &SceneViewportCache,
request: &LayerCopyRequest, request: &LayerCopyRequest,
glyph_slot: usize,
glyph_banks: &dyn GlyphBankPoolAccess, glyph_banks: &dyn GlyphBankPoolAccess,
) { ) {
let mut target = RenderTarget { back, screen_w, screen_h }; let mut target = RenderTarget { back, screen_w, screen_h };
@ -703,8 +717,11 @@ impl Gfx {
return; return;
} }
let Some(bank) = glyph_banks.glyph_bank_slot(layer_cache.glyph_bank_id as usize) else { let Some(bank) = glyph_banks.glyph_bank_slot(glyph_slot) else {
return; panic!(
"SCENE composition fatal: resolved glyph slot {} for layer {} is not resident",
glyph_slot, request.layer_index
);
}; };
let tile_size_px = request.tile_size as i32; let tile_size_px = request.tile_size as i32;
@ -891,11 +908,63 @@ impl Gfx {
} }
} }
/// Blends in RGB565 per channel with saturation.
/// `dst` and `src` are RGB565 pixels (u16).
fn blend_rgb565(dst: u16, src: u16, mode: BlendMode) -> u16 {
match mode {
BlendMode::None => src,
BlendMode::Half => {
let (dr, dg, db) = Color::unpack_to_native(dst);
let (sr, sg, sb) = Color::unpack_to_native(src);
let r = ((dr as u16 + sr as u16) >> 1) as u8;
let g = ((dg as u16 + sg as u16) >> 1) as u8;
let b = ((db as u16 + sb as u16) >> 1) as u8;
Color::pack_from_native(r, g, b)
}
BlendMode::HalfPlus => {
let (dr, dg, db) = Color::unpack_to_native(dst);
let (sr, sg, sb) = Color::unpack_to_native(src);
let r = (dr as u16 + ((sr as u16) >> 1)).min(31) as u8;
let g = (dg as u16 + ((sg as u16) >> 1)).min(63) as u8;
let b = (db as u16 + ((sb as u16) >> 1)).min(31) as u8;
Color::pack_from_native(r, g, b)
}
BlendMode::HalfMinus => {
let (dr, dg, db) = Color::unpack_to_native(dst);
let (sr, sg, sb) = Color::unpack_to_native(src);
let r = (dr as i16 - ((sr as i16) >> 1)).max(0) as u8;
let g = (dg as i16 - ((sg as i16) >> 1)).max(0) as u8;
let b = (db as i16 - ((sb as i16) >> 1)).max(0) as u8;
Color::pack_from_native(r, g, b)
}
BlendMode::Full => {
let (dr, dg, db) = Color::unpack_to_native(dst);
let (sr, sg, sb) = Color::unpack_to_native(src);
let r = (dr as u16 + sr as u16).min(31) as u8;
let g = (dg as u16 + sg as u16).min(63) as u8;
let b = (db as u16 + sb as u16).min(31) as u8;
Color::pack_from_native(r, g, b)
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::FrameComposer; use crate::FrameComposer;
use crate::asset::GlyphAssetSlotIndex;
use crate::memory_banks::{GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess}; use crate::memory_banks::{GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess};
use prometeu_hal::asset::AssetId;
use prometeu_hal::glyph_bank::TileSize; use prometeu_hal::glyph_bank::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};
@ -919,7 +988,7 @@ mod tests {
} }
fn make_layer( fn make_layer(
glyph_bank_id: u8, glyph_asset_id: AssetId,
glyph_id: u16, glyph_id: u16,
palette_id: u8, palette_id: u8,
width: usize, width: usize,
@ -927,7 +996,7 @@ mod tests {
) -> SceneLayer { ) -> SceneLayer {
SceneLayer { SceneLayer {
active: true, active: true,
glyph_bank_id, glyph_asset_id,
tile_size: TileSize::Size8, tile_size: TileSize::Size8,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap { tilemap: TileMap {
@ -946,10 +1015,10 @@ mod tests {
} }
} }
fn make_inactive_layer(glyph_bank_id: u8, width: usize, height: usize) -> SceneLayer { fn make_inactive_layer(glyph_asset_id: AssetId, width: usize, height: usize) -> SceneLayer {
SceneLayer { SceneLayer {
active: false, active: false,
glyph_bank_id, glyph_asset_id,
tile_size: TileSize::Size8, tile_size: TileSize::Size8,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap { width, height, tiles: vec![Tile::default(); width * height] }, tilemap: TileMap { width, height, tiles: vec![Tile::default(); width * height] },
@ -978,6 +1047,16 @@ mod tests {
} }
} }
fn make_glyph_slot_index(bindings: &[(AssetId, usize)]) -> GlyphAssetSlotIndex {
let index = GlyphAssetSlotIndex::new();
let mut slots = [None; 16];
for (asset_id, slot) in bindings {
slots[*slot] = Some(*asset_id);
}
index.rebuild_from_slots(&slots);
index
}
#[test] #[test]
fn test_draw_pixel() { fn test_draw_pixel() {
let banks = Arc::new(MemoryBanks::new()); let banks = Arc::new(MemoryBanks::new());
@ -1028,9 +1107,7 @@ mod tests {
gfx.begin_overlay_frame(); gfx.begin_overlay_frame();
gfx.draw_square(2, 2, 6, 6, Color::WHITE, Color::BLACK); gfx.draw_square(2, 2, 6, 6, Color::WHITE, Color::BLACK);
gfx.drain_overlay_debug(); gfx.drain_overlay_debug();
// Border
assert_eq!(gfx.back[2 * 10 + 2], Color::WHITE.0); assert_eq!(gfx.back[2 * 10 + 2], Color::WHITE.0);
// Fill
assert_eq!(gfx.back[3 * 10 + 3], Color::BLACK.0); assert_eq!(gfx.back[3 * 10 + 3], Color::BLACK.0);
assert_eq!(gfx.overlay().command_count(), 0); assert_eq!(gfx.overlay().command_count(), 0);
} }
@ -1053,8 +1130,12 @@ mod tests {
fn overlay_state_is_separate_from_frame_composer_sprite_state() { fn overlay_state_is_separate_from_frame_composer_sprite_state() {
let banks = Arc::new(MemoryBanks::new()); let banks = Arc::new(MemoryBanks::new());
let mut gfx = Gfx::new(32, 18, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>); let mut gfx = Gfx::new(32, 18, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>);
let mut frame_composer = let mut frame_composer = FrameComposer::new(
FrameComposer::new(32, 18, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>); 32,
18,
Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>,
make_glyph_slot_index(&[]),
);
gfx.begin_overlay_frame(); gfx.begin_overlay_frame();
frame_composer.begin_frame(); frame_composer.begin_frame();
@ -1095,7 +1176,7 @@ mod tests {
let mut gfx = Gfx::new(16, 16, banks); let mut gfx = Gfx::new(16, 16, banks);
gfx.scene_fade_level = 31; gfx.scene_fade_level = 31;
gfx.hud_fade_level = 31; gfx.hud_fade_level = 31;
gfx.render_scene_from_cache(&cache, &update); gfx.render_scene_from_cache(&cache, &update, &[0, 0, 0, 0]);
assert_eq!(gfx.back[0], Color::RED.raw()); assert_eq!(gfx.back[0], Color::RED.raw());
} }
@ -1146,7 +1227,7 @@ mod tests {
}; };
gfx.sprite_count = 2; gfx.sprite_count = 2;
gfx.render_scene_from_cache(&cache, &update); gfx.render_scene_from_cache(&cache, &update, &[0, 0, 0, 0]);
assert_eq!(gfx.back[0], Color::BLUE.raw()); assert_eq!(gfx.back[0], Color::BLUE.raw());
} }
@ -1189,53 +1270,3 @@ mod tests {
assert_eq!(gfx.sprites[1].glyph.glyph_id, 5); assert_eq!(gfx.sprites[1].glyph.glyph_id, 5);
} }
} }
/// Blends in RGB565 per channel with saturation.
/// `dst` and `src` are RGB565 pixels (u16).
fn blend_rgb565(dst: u16, src: u16, mode: BlendMode) -> u16 {
match mode {
BlendMode::None => src,
BlendMode::Half => {
let (dr, dg, db) = Color::unpack_to_native(dst);
let (sr, sg, sb) = Color::unpack_to_native(src);
let r = ((dr as u16 + sr as u16) >> 1) as u8;
let g = ((dg as u16 + sg as u16) >> 1) as u8;
let b = ((db as u16 + sb as u16) >> 1) as u8;
Color::pack_from_native(r, g, b)
}
BlendMode::HalfPlus => {
let (dr, dg, db) = Color::unpack_to_native(dst);
let (sr, sg, sb) = Color::unpack_to_native(src);
let r = (dr as u16 + ((sr as u16) >> 1)).min(31) as u8;
let g = (dg as u16 + ((sg as u16) >> 1)).min(63) as u8;
let b = (db as u16 + ((sb as u16) >> 1)).min(31) as u8;
Color::pack_from_native(r, g, b)
}
BlendMode::HalfMinus => {
let (dr, dg, db) = Color::unpack_to_native(dst);
let (sr, sg, sb) = Color::unpack_to_native(src);
let r = (dr as i16 - ((sr as i16) >> 1)).max(0) as u8;
let g = (dg as i16 - ((sg as i16) >> 1)).max(0) as u8;
let b = (db as i16 - ((sb as i16) >> 1)).max(0) as u8;
Color::pack_from_native(r, g, b)
}
BlendMode::Full => {
let (dr, dg, db) = Color::unpack_to_native(dst);
let (sr, sg, sb) = Color::unpack_to_native(src);
let r = (dr as u16 + sr as u16).min(31) as u8;
let g = (dg as u16 + sg as u16).min(63) as u8;
let b = (db as u16 + sb as u16).min(31) as u8;
Color::pack_from_native(r, g, b)
}
}
}

View File

@ -126,6 +126,14 @@ impl Hardware {
/// Creates hardware with explicit shared bank ownership. /// Creates hardware with explicit shared bank ownership.
pub fn new_with_memory_banks(memory_banks: Arc<MemoryBanks>) -> Self { pub fn new_with_memory_banks(memory_banks: Arc<MemoryBanks>) -> Self {
let assets = AssetManager::new(
vec![],
AssetsPayloadSource::empty(),
Arc::clone(&memory_banks) as Arc<dyn GlyphBankPoolInstaller>,
Arc::clone(&memory_banks) as Arc<dyn SoundBankPoolInstaller>,
Arc::clone(&memory_banks) as Arc<dyn SceneBankPoolInstaller>,
);
let glyph_slot_index = assets.glyph_asset_slot_index();
Self { Self {
gfx: Gfx::new( gfx: Gfx::new(
Self::W, Self::W,
@ -136,17 +144,12 @@ impl Hardware {
Self::W, Self::W,
Self::H, Self::H,
Arc::clone(&memory_banks) as Arc<dyn SceneBankPoolAccess>, Arc::clone(&memory_banks) as Arc<dyn SceneBankPoolAccess>,
glyph_slot_index,
), ),
audio: Audio::new(Arc::clone(&memory_banks) as Arc<dyn SoundBankPoolAccess>), audio: Audio::new(Arc::clone(&memory_banks) as Arc<dyn SoundBankPoolAccess>),
pad: Pad::default(), pad: Pad::default(),
touch: Touch::default(), touch: Touch::default(),
assets: AssetManager::new( assets,
vec![],
AssetsPayloadSource::empty(),
Arc::clone(&memory_banks) as Arc<dyn GlyphBankPoolInstaller>,
Arc::clone(&memory_banks) as Arc<dyn SoundBankPoolInstaller>,
Arc::clone(&memory_banks) as Arc<dyn SceneBankPoolInstaller>,
),
} }
} }
} }
@ -179,7 +182,7 @@ mod tests {
fn make_scene() -> SceneBank { fn make_scene() -> SceneBank {
let layer = SceneLayer { let layer = SceneLayer {
active: true, active: true,
glyph_bank_id: 0, glyph_asset_id: 1,
tile_size: TileSize::Size8, tile_size: TileSize::Size8,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap { tilemap: TileMap {
@ -216,7 +219,7 @@ mod tests {
hardware.gfx.scene_fade_level = 31; hardware.gfx.scene_fade_level = 31;
hardware.gfx.hud_fade_level = 31; hardware.gfx.hud_fade_level = 31;
hardware.gfx.render_scene_from_cache(&cache, &update); hardware.gfx.render_scene_from_cache(&cache, &update, &[0, 0, 0, 0]);
hardware.gfx.present(); hardware.gfx.present();
assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw()); assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw());

View File

@ -53,7 +53,11 @@ pub const SCENE_PAYLOAD_MAGIC_V1: [u8; 4] = *b"SCNE";
pub const SCENE_PAYLOAD_VERSION_V1: u16 = 1; pub const SCENE_PAYLOAD_VERSION_V1: u16 = 1;
pub const SCENE_LAYER_COUNT_V1: usize = 4; pub const SCENE_LAYER_COUNT_V1: usize = 4;
pub const SCENE_HEADER_BYTES_V1: usize = 12; pub const SCENE_HEADER_BYTES_V1: usize = 12;
pub const SCENE_LAYER_HEADER_BYTES_V1: usize = 28; // Per-layer header:
// flags:u8 + glyph_asset_id:i32 + tile_size:u8 + reserved:u16
// + parallax_x:f32 + parallax_y:f32 + width:u32 + height:u32
// + tile_count:u32 + reserved:u32
pub const SCENE_LAYER_HEADER_BYTES_V1: usize = 32;
pub const SCENE_TILE_RECORD_BYTES_V1: usize = 4; pub const SCENE_TILE_RECORD_BYTES_V1: usize = 4;
pub const SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1: usize = 16; pub const SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1: usize = 16;

View File

@ -57,7 +57,12 @@ pub trait GfxBridge {
/// ///
/// Deferred `gfx.*` overlay/debug primitives are intentionally outside this /// Deferred `gfx.*` overlay/debug primitives are intentionally outside this
/// contract and are drained by a separate final overlay stage. /// contract and are drained by a separate final overlay stage.
fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate); fn render_scene_from_cache(
&mut self,
cache: &SceneViewportCache,
update: &ResolverUpdate,
resolved_glyph_slots: &[usize; 4],
);
fn load_frame_sprites(&mut self, sprites: &[Sprite]); fn load_frame_sprites(&mut self, sprites: &[Sprite]);
/// Submit text into the `gfx.*` primitive path. /// Submit text into the `gfx.*` primitive path.
/// ///

View File

@ -8,16 +8,22 @@ pub struct SceneBank {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::asset::AssetId;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::glyph_bank::TileSize; use crate::glyph_bank::TileSize;
use crate::scene_layer::ParallaxFactor; use crate::scene_layer::ParallaxFactor;
use crate::tile::Tile; use crate::tile::Tile;
use crate::tilemap::TileMap; use crate::tilemap::TileMap;
fn layer(glyph_bank_id: u8, parallax_x: f32, parallax_y: f32, glyph_id: u16) -> SceneLayer { fn layer(
glyph_asset_id: AssetId,
parallax_x: f32,
parallax_y: f32,
glyph_id: u16,
) -> SceneLayer {
SceneLayer { SceneLayer {
active: true, active: true,
glyph_bank_id, glyph_asset_id,
tile_size: TileSize::Size16, tile_size: TileSize::Size16,
parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y }, parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
tilemap: TileMap { tilemap: TileMap {
@ -25,7 +31,7 @@ mod tests {
height: 1, height: 1,
tiles: vec![Tile { tiles: vec![Tile {
active: true, active: true,
glyph: Glyph { glyph_id, palette_id: glyph_bank_id }, glyph: Glyph { glyph_id, palette_id: 1 },
flip_x: false, flip_x: false,
flip_y: false, flip_y: false,
}], }],

View File

@ -1,3 +1,4 @@
use crate::asset::AssetId;
use crate::glyph_bank::TileSize; use crate::glyph_bank::TileSize;
use crate::tilemap::TileMap; use crate::tilemap::TileMap;
@ -10,7 +11,7 @@ pub struct ParallaxFactor {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct SceneLayer { pub struct SceneLayer {
pub active: bool, pub active: bool,
pub glyph_bank_id: u8, pub glyph_asset_id: AssetId,
pub tile_size: TileSize, pub tile_size: TileSize,
pub parallax_factor: ParallaxFactor, pub parallax_factor: ParallaxFactor,
pub tilemap: TileMap, pub tilemap: TileMap,
@ -26,7 +27,7 @@ mod tests {
fn scene_layer_preserves_parallax_factor_and_tilemap_ownership() { fn scene_layer_preserves_parallax_factor_and_tilemap_ownership() {
let layer = SceneLayer { let layer = SceneLayer {
active: true, active: true,
glyph_bank_id: 7, glyph_asset_id: 7,
tile_size: TileSize::Size16, tile_size: TileSize::Size16,
parallax_factor: ParallaxFactor { x: 0.5, y: 0.75 }, parallax_factor: ParallaxFactor { x: 0.5, y: 0.75 },
tilemap: TileMap { tilemap: TileMap {
@ -49,7 +50,7 @@ mod tests {
}, },
}; };
assert_eq!(layer.glyph_bank_id, 7); assert_eq!(layer.glyph_asset_id, 7);
assert_eq!(layer.parallax_factor.x, 0.5); assert_eq!(layer.parallax_factor.x, 0.5);
assert_eq!(layer.parallax_factor.y, 0.75); assert_eq!(layer.parallax_factor.y, 0.75);
assert_eq!(layer.tilemap.width, 2); assert_eq!(layer.tilemap.width, 2);

View File

@ -1,3 +1,4 @@
use crate::asset::AssetId;
use crate::glyph_bank::TileSize; use crate::glyph_bank::TileSize;
use crate::scene_bank::SceneBank; use crate::scene_bank::SceneBank;
use crate::scene_layer::SceneLayer; use crate::scene_layer::SceneLayer;
@ -12,7 +13,7 @@ pub struct CachedTileEntry {
pub glyph_id: u16, pub glyph_id: u16,
pub palette_id: u8, pub palette_id: u8,
pub flags: u8, pub flags: u8,
pub glyph_bank_id: u8, pub glyph_asset_id: AssetId,
} }
impl CachedTileEntry { impl CachedTileEntry {
@ -38,7 +39,7 @@ impl CachedTileEntry {
glyph_id: tile.glyph.glyph_id, glyph_id: tile.glyph.glyph_id,
palette_id: tile.glyph.palette_id, palette_id: tile.glyph.palette_id,
flags, flags,
glyph_bank_id: layer.glyph_bank_id, glyph_asset_id: layer.glyph_asset_id,
} }
} }
} }
@ -65,7 +66,7 @@ pub struct SceneViewportLayerCache {
logical_origin_y: i32, logical_origin_y: i32,
ring_origin_x: usize, ring_origin_x: usize,
ring_origin_y: usize, ring_origin_y: usize,
pub glyph_bank_id: u8, pub glyph_asset_id: AssetId,
pub tile_size: TileSize, pub tile_size: TileSize,
entries: Vec<CachedTileEntry>, entries: Vec<CachedTileEntry>,
pub valid: bool, pub valid: bool,
@ -80,7 +81,7 @@ impl SceneViewportLayerCache {
logical_origin_y: 0, logical_origin_y: 0,
ring_origin_x: 0, ring_origin_x: 0,
ring_origin_y: 0, ring_origin_y: 0,
glyph_bank_id: layer.glyph_bank_id, glyph_asset_id: layer.glyph_asset_id,
tile_size: layer.tile_size, tile_size: layer.tile_size,
entries: vec![CachedTileEntry::default(); width * height], entries: vec![CachedTileEntry::default(); width * height],
valid: false, valid: false,
@ -136,7 +137,7 @@ impl SceneViewportLayerCache {
} }
pub fn refresh_region(&mut self, layer: &SceneLayer, region: ViewportRegion) { pub fn refresh_region(&mut self, layer: &SceneLayer, region: ViewportRegion) {
self.glyph_bank_id = layer.glyph_bank_id; self.glyph_asset_id = layer.glyph_asset_id;
self.tile_size = layer.tile_size; self.tile_size = layer.tile_size;
let max_x = region.x.saturating_add(region.width).min(self.width); let max_x = region.x.saturating_add(region.width).min(self.width);
@ -268,6 +269,7 @@ impl SceneViewportCache {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::asset::AssetId;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::glyph_bank::TileSize; use crate::glyph_bank::TileSize;
use crate::scene_layer::ParallaxFactor; use crate::scene_layer::ParallaxFactor;
@ -278,22 +280,17 @@ mod tests {
Tile { active: true, glyph: Glyph { glyph_id, palette_id }, flip_x, flip_y } Tile { active: true, glyph: Glyph { glyph_id, palette_id }, flip_x, flip_y }
} }
fn make_layer(glyph_bank_id: u8, base_glyph: u16) -> SceneLayer { fn make_layer(glyph_asset_id: AssetId, base_glyph: u16) -> SceneLayer {
let mut tiles = Vec::new(); let mut tiles = Vec::new();
for y in 0..4 { for y in 0..4 {
for x in 0..4 { for x in 0..4 {
tiles.push(make_tile( tiles.push(make_tile(base_glyph + (y * 4 + x) as u16, 1, x % 2 == 0, y % 2 == 1));
base_glyph + (y * 4 + x) as u16,
glyph_bank_id,
x % 2 == 0,
y % 2 == 1,
));
} }
} }
SceneLayer { SceneLayer {
active: true, active: true,
glyph_bank_id, glyph_asset_id,
tile_size: TileSize::Size16, tile_size: TileSize::Size16,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap { width: 4, height: 4, tiles }, tilemap: TileMap { width: 4, height: 4, tiles },
@ -364,7 +361,7 @@ mod tests {
assert!(entry.active); assert!(entry.active);
assert_eq!(entry.glyph_id, 105); assert_eq!(entry.glyph_id, 105);
assert_eq!(entry.palette_id, 1); assert_eq!(entry.palette_id, 1);
assert_eq!(entry.glyph_bank_id, 1); assert_eq!(entry.glyph_asset_id, 1);
assert!(!entry.flip_x()); assert!(!entry.flip_x());
assert!(entry.flip_y()); assert!(entry.flip_y());
} }

View File

@ -386,6 +386,7 @@ impl SceneViewportResolver {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::asset::AssetId;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::glyph_bank::TileSize; use crate::glyph_bank::TileSize;
use crate::scene_layer::{ParallaxFactor, SceneLayer}; use crate::scene_layer::{ParallaxFactor, SceneLayer};
@ -411,7 +412,7 @@ mod tests {
SceneLayer { SceneLayer {
active: true, active: true,
glyph_bank_id: 1, glyph_asset_id: 1 as AssetId,
tile_size, tile_size,
parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y }, parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
tilemap: TileMap { width, height, tiles }, tilemap: TileMap { width, height, tiles },

View File

@ -13,9 +13,20 @@ use prometeu_hal::{
AudioOpStatus, ComposerOpStatus, HostContext, HostReturn, NativeInterface, SyscallId, AudioOpStatus, ComposerOpStatus, HostContext, HostReturn, NativeInterface, SyscallId,
expect_bool, expect_int, expect_bool, expect_int,
}; };
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
impl VirtualMachineRuntime { impl VirtualMachineRuntime {
fn panic_payload_to_string(payload: Box<dyn std::any::Any + Send>) -> String {
if let Some(message) = payload.downcast_ref::<String>() {
return message.clone();
}
if let Some(message) = payload.downcast_ref::<&str>() {
return (*message).to_string();
}
"host panic without string payload".to_string()
}
fn syscall_log_write(&mut self, level_val: i64, tag: u16, msg: String) -> Result<(), VmFault> { fn syscall_log_write(&mut self, level_val: i64, tag: u16, msg: String) -> Result<(), VmFault> {
let level = match level_val { let level = match level_val {
0 => LogLevel::Trace, 0 => LogLevel::Trace,
@ -177,11 +188,11 @@ impl NativeInterface for VirtualMachineRuntime {
} }
}; };
let status = if hw.bind_scene(scene_bank_id) { let bound = catch_unwind(AssertUnwindSafe(|| hw.bind_scene(scene_bank_id)))
ComposerOpStatus::Ok .map_err(|payload| VmFault::Panic(Self::panic_payload_to_string(payload)))?;
} else {
ComposerOpStatus::SceneUnavailable let status =
}; if bound { ComposerOpStatus::Ok } else { ComposerOpStatus::SceneUnavailable };
ret.push_int(status as i64); ret.push_int(status as i64);
Ok(()) Ok(())
} }

View File

@ -147,10 +147,10 @@ fn runtime_test_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) ->
bank bank
} }
fn runtime_test_scene(glyph_bank_id: u8, palette_id: u8, tile_size: TileSize) -> SceneBank { fn runtime_test_scene(glyph_asset_id: i32, palette_id: u8, tile_size: TileSize) -> SceneBank {
let layer = SceneLayer { let layer = SceneLayer {
active: true, active: true,
glyph_bank_id, glyph_asset_id,
tile_size, tile_size,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 }, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap { tilemap: TileMap {
@ -294,6 +294,10 @@ fn tick_renders_bound_eight_pixel_scene_through_frame_composer_path() {
banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 2, Color::BLUE))); banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 2, Color::BLUE)));
banks.install_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8))); banks.install_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8)));
let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks)); let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks));
let glyph_slot_index = hardware.assets.glyph_asset_slot_index();
let mut slots = [None; 16];
slots[0] = Some(0);
glyph_slot_index.rebuild_from_slots(&slots);
assert!(hardware.frame_composer.bind_scene(0)); assert!(hardware.frame_composer.bind_scene(0));
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
@ -348,6 +352,10 @@ fn tick_renders_scene_through_public_composer_syscalls() {
banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 2, Color::BLUE))); banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 2, Color::BLUE)));
banks.install_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8))); banks.install_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8)));
let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks)); let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks));
let glyph_slot_index = hardware.assets.glyph_asset_slot_index();
let mut slots = [None; 16];
slots[0] = Some(0);
glyph_slot_index.rebuild_from_slots(&slots);
hardware.gfx.scene_fade_level = 31; hardware.gfx.scene_fade_level = 31;
hardware.gfx.hud_fade_level = 31; hardware.gfx.hud_fade_level = 31;
@ -405,6 +413,10 @@ fn tick_draw_text_survives_scene_backed_frame_composition() {
banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 2, Color::BLUE))); banks.install_glyph_bank(0, Arc::new(runtime_test_glyph_bank(TileSize::Size8, 2, Color::BLUE)));
banks.install_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8))); banks.install_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8)));
let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks)); let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks));
let glyph_slot_index = hardware.assets.glyph_asset_slot_index();
let mut slots = [None; 16];
slots[0] = Some(0);
glyph_slot_index.rebuild_from_slots(&slots);
hardware.gfx.scene_fade_level = 31; hardware.gfx.scene_fade_level = 31;
hardware.gfx.hud_fade_level = 31; hardware.gfx.hud_fade_level = 31;

View File

@ -4,9 +4,20 @@ use prometeu_hal::asset::{BankTelemetry, BankType};
use prometeu_hal::log::{LogLevel, LogSource}; use prometeu_hal::log::{LogLevel, LogSource};
use prometeu_hal::{HardwareBridge, HostContext, InputSignals}; use prometeu_hal::{HardwareBridge, HostContext, InputSignals};
use prometeu_vm::LogicalFrameEndingReason; use prometeu_vm::LogicalFrameEndingReason;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
impl VirtualMachineRuntime { impl VirtualMachineRuntime {
fn host_panic_payload_to_string(payload: Box<dyn std::any::Any + Send>) -> String {
if let Some(message) = payload.downcast_ref::<String>() {
return message.clone();
}
if let Some(message) = payload.downcast_ref::<&str>() {
return (*message).to_string();
}
"host panic without string payload".to_string()
}
fn refresh_internal_allocation_telemetry(&self, vm: &VirtualMachine) { fn refresh_internal_allocation_telemetry(&self, vm: &VirtualMachine) {
let heap_allocations = let heap_allocations =
vm.heap().allocation_count().saturating_sub(self.frame_start_heap_allocations); vm.heap().allocation_count().saturating_sub(self.frame_start_heap_allocations);
@ -167,7 +178,18 @@ impl VirtualMachineRuntime {
if run.reason == LogicalFrameEndingReason::FrameSync if run.reason == LogicalFrameEndingReason::FrameSync
|| run.reason == LogicalFrameEndingReason::EndOfRom || run.reason == LogicalFrameEndingReason::EndOfRom
{ {
hw.render_frame(); if let Err(payload) = catch_unwind(AssertUnwindSafe(|| hw.render_frame())) {
let message = Self::host_panic_payload_to_string(payload);
let report = CrashReport::VmPanic { message, pc: Some(vm.pc() as u32) };
self.log(
LogLevel::Error,
LogSource::Vm,
report.log_tag(),
report.summary(),
);
self.last_crash_report = Some(report.clone());
return Some(report);
}
// 1. Snapshot full telemetry at logical frame end // 1. Snapshot full telemetry at logical frame end
let (glyph_bank, sound_bank, scene_bank) = Self::bank_telemetry_summary(hw); let (glyph_bank, sound_bank, scene_bank) = Self::bank_telemetry_summary(hw);

View File

@ -175,6 +175,7 @@ impl HostAudio {
} }
#[cfg(test)] #[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests { mod tests {
use super::*; use super::*;
use crate::stats::HostStats; use crate::stats::HostStats;

View File

@ -54,15 +54,13 @@ impl HostInputHandler {
self.signals.y_pos = v.1; self.signals.y_pos = v.1;
} }
WindowEvent::MouseInput { state, button, .. } => { WindowEvent::MouseInput { state, button, .. } if *button == MouseButton::Left => {
if *button == MouseButton::Left { match state {
match state { ElementState::Pressed => {
ElementState::Pressed => { self.signals.f_signal = true;
self.signals.f_signal = true; }
} ElementState::Released => {
ElementState::Released => { self.signals.f_signal = false;
self.signals.f_signal = false;
}
} }
} }
} }

View File

@ -782,10 +782,10 @@ mod tests {
break; break;
} }
if let Ok(resp) = serde_json::from_str::<serde_json::Value>(&line) { if let Ok(resp) = serde_json::from_str::<serde_json::Value>(&line)
if resp["type"] == "getState" { && resp["type"] == "getState"
return; {
} return;
} }
} }
panic!("Did not receive getState response"); panic!("Did not receive getState response");

View File

@ -4,7 +4,7 @@ use prometeu_bytecode::model::{
BytecodeModule, ConstantPoolEntry, DebugInfo, Export, FunctionMeta, SyscallDecl, BytecodeModule, ConstantPoolEntry, DebugInfo, Export, FunctionMeta, SyscallDecl,
}; };
use prometeu_hal::asset::{ use prometeu_hal::asset::{
AssetCodec, AssetEntry, BankType, PreloadEntry, SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1, AssetCodec, AssetEntry, AssetId, BankType, PreloadEntry, SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1,
SCENE_LAYER_COUNT_V1, SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1, SCENE_LAYER_COUNT_V1, SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1,
}; };
use prometeu_hal::cartridge::{ use prometeu_hal::cartridge::{
@ -24,6 +24,9 @@ use std::fs;
use std::mem::size_of; use std::mem::size_of;
use std::path::PathBuf; use std::path::PathBuf;
const STRESS_GLYPH_ASSET_ID: AssetId = 100;
const STRESS_SCENE_ASSET_ID: AssetId = 1;
fn asm(s: &str) -> Vec<u8> { fn asm(s: &str) -> Vec<u8> {
assemble(s).expect("assemble") assemble(s).expect("assemble")
} }
@ -223,7 +226,7 @@ fn build_assets_pack() -> Result<Vec<u8>> {
let scene = build_scene_bank(); let scene = build_scene_bank();
let scene_payload = encode_scene_payload(&scene); let scene_payload = encode_scene_payload(&scene);
let scene_entry = AssetEntry { let scene_entry = AssetEntry {
asset_id: 1, asset_id: STRESS_SCENE_ASSET_ID,
asset_name: "stress_scene".into(), asset_name: "stress_scene".into(),
bank_type: BankType::SCENE, bank_type: BankType::SCENE,
offset: glyph_payload.len() as u64, offset: glyph_payload.len() as u64,
@ -234,8 +237,10 @@ fn build_assets_pack() -> Result<Vec<u8>> {
}; };
let asset_table = vec![glyph_entry, scene_entry]; let asset_table = vec![glyph_entry, scene_entry];
let preload = let preload = vec![
vec![PreloadEntry { asset_id: 0, slot: 0 }, PreloadEntry { asset_id: 1, slot: 0 }]; PreloadEntry { asset_id: STRESS_GLYPH_ASSET_ID, slot: 0 },
PreloadEntry { asset_id: STRESS_SCENE_ASSET_ID, slot: 0 },
];
let payload_len = glyph_payload.len() + scene_payload.len(); let payload_len = glyph_payload.len() + scene_payload.len();
let header = serde_json::to_vec(&AssetsPackHeader { asset_table, preload })?; let header = serde_json::to_vec(&AssetsPackHeader { asset_table, preload })?;
let payload_offset = (ASSETS_PA_PRELUDE_SIZE + header.len()) as u64; let payload_offset = (ASSETS_PA_PRELUDE_SIZE + header.len()) as u64;
@ -263,7 +268,7 @@ fn build_glyph_asset() -> (AssetEntry, Vec<u8>) {
payload.extend_from_slice(&build_palette_bytes()); payload.extend_from_slice(&build_palette_bytes());
let entry = AssetEntry { let entry = AssetEntry {
asset_id: 0, asset_id: STRESS_GLYPH_ASSET_ID,
asset_name: "stress_square".into(), asset_name: "stress_square".into(),
bank_type: BankType::GLYPH, bank_type: BankType::GLYPH,
offset: 0, offset: 0,
@ -338,7 +343,7 @@ fn build_scene_bank() -> SceneBank {
SceneLayer { SceneLayer {
active: true, active: true,
glyph_bank_id: 0, glyph_asset_id: STRESS_GLYPH_ASSET_ID,
tile_size: TileSize::Size8, tile_size: TileSize::Size8,
parallax_factor: match layer_index { parallax_factor: match layer_index {
0 => ParallaxFactor { x: 1.0, y: 1.0 }, 0 => ParallaxFactor { x: 1.0, y: 1.0 },
@ -381,9 +386,9 @@ fn encode_scene_payload(scene: &SceneBank) -> Vec<u8> {
for layer in &scene.layers { for layer in &scene.layers {
let layer_flags = if layer.active { 0b0000_0001 } else { 0 }; let layer_flags = if layer.active { 0b0000_0001 } else { 0 };
data.push(layer_flags); data.push(layer_flags);
data.push(layer.glyph_bank_id); data.extend_from_slice(&(layer.glyph_asset_id as AssetId).to_le_bytes());
data.push(layer.tile_size as u8); data.push(layer.tile_size as u8);
data.push(0); data.extend_from_slice(&0_u16.to_le_bytes());
data.extend_from_slice(&layer.parallax_factor.x.to_le_bytes()); data.extend_from_slice(&layer.parallax_factor.x.to_le_bytes());
data.extend_from_slice(&layer.parallax_factor.y.to_le_bytes()); data.extend_from_slice(&layer.parallax_factor.y.to_le_bytes());
data.extend_from_slice(&(layer.tilemap.width as u32).to_le_bytes()); data.extend_from_slice(&(layer.tilemap.width as u32).to_le_bytes());
@ -430,8 +435,12 @@ mod tests {
assert_eq!(header.asset_table.len(), 2); assert_eq!(header.asset_table.len(), 2);
assert_eq!(header.preload.len(), 2); assert_eq!(header.preload.len(), 2);
assert_eq!(header.asset_table[0].asset_id, STRESS_GLYPH_ASSET_ID);
assert_eq!(header.asset_table[1].asset_id, STRESS_SCENE_ASSET_ID);
assert_eq!(header.asset_table[0].bank_type, BankType::GLYPH); assert_eq!(header.asset_table[0].bank_type, BankType::GLYPH);
assert_eq!(header.asset_table[1].bank_type, BankType::SCENE); assert_eq!(header.asset_table[1].bank_type, BankType::SCENE);
assert_eq!(header.preload[0].asset_id, STRESS_GLYPH_ASSET_ID);
assert_eq!(header.preload[1].asset_id, STRESS_SCENE_ASSET_ID);
assert_eq!(header.preload[0].slot, 0); assert_eq!(header.preload[0].slot, 0);
assert_eq!(header.preload[1].slot, 0); assert_eq!(header.preload[1].slot, 0);
assert_eq!(header.asset_table[0].offset, 0); assert_eq!(header.asset_table[0].offset, 0);

View File

@ -1,4 +1,4 @@
{"type":"meta","next_id":{"DSC":29,"AGD":29,"DEC":21,"PLN":40,"LSN":38,"CLSN":1}} {"type":"meta","next_id":{"DSC":30,"AGD":30,"DEC":22,"PLN":43,"LSN":39,"CLSN":1}}
{"type":"discussion","id":"DSC-0023","status":"done","ticket":"perf-full-migration-to-atomic-telemetry","title":"Agenda - [PERF] Full Migration to Atomic Telemetry","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["perf","runtime","telemetry"],"agendas":[{"id":"AGD-0021","file":"workflow/agendas/AGD-0021-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"decisions":[{"id":"DEC-0008","file":"workflow/decisions/DEC-0008-full-migration-to-atomic-telemetry.md","status":"accepted","created_at":"2026-04-10","updated_at":"2026-04-10"}],"plans":[{"id":"PLN-0007","file":"workflow/plans/PLN-0007-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"lessons":[{"id":"LSN-0028","file":"lessons/DSC-0023-perf-full-migration-to-atomic-telemetry/LSN-0028-converging-to-single-atomic-telemetry-source.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} {"type":"discussion","id":"DSC-0023","status":"done","ticket":"perf-full-migration-to-atomic-telemetry","title":"Agenda - [PERF] Full Migration to Atomic Telemetry","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["perf","runtime","telemetry"],"agendas":[{"id":"AGD-0021","file":"workflow/agendas/AGD-0021-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"decisions":[{"id":"DEC-0008","file":"workflow/decisions/DEC-0008-full-migration-to-atomic-telemetry.md","status":"accepted","created_at":"2026-04-10","updated_at":"2026-04-10"}],"plans":[{"id":"PLN-0007","file":"workflow/plans/PLN-0007-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"lessons":[{"id":"LSN-0028","file":"lessons/DSC-0023-perf-full-migration-to-atomic-telemetry/LSN-0028-converging-to-single-atomic-telemetry-source.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0020","status":"done","ticket":"jenkins-gitea-integration","title":"Jenkins Gitea Integration and Relocation","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["ci","jenkins","gitea"],"agendas":[{"id":"AGD-0018","file":"workflow/agendas/AGD-0018-jenkins-gitea-integration-and-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0003","file":"workflow/decisions/DEC-0003-jenkins-gitea-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0003","file":"workflow/plans/PLN-0003-jenkins-gitea-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0021","file":"lessons/DSC-0020-jenkins-gitea-integration/LSN-0021-jenkins-gitea-integration.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]} {"type":"discussion","id":"DSC-0020","status":"done","ticket":"jenkins-gitea-integration","title":"Jenkins Gitea Integration and Relocation","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["ci","jenkins","gitea"],"agendas":[{"id":"AGD-0018","file":"workflow/agendas/AGD-0018-jenkins-gitea-integration-and-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0003","file":"workflow/decisions/DEC-0003-jenkins-gitea-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0003","file":"workflow/plans/PLN-0003-jenkins-gitea-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0021","file":"lessons/DSC-0020-jenkins-gitea-integration/LSN-0021-jenkins-gitea-integration.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0021","status":"done","ticket":"asset-entry-codec-enum-with-metadata","title":"Asset Entry Codec Enum Contract","created_at":"2026-04-09","updated_at":"2026-04-09","tags":["asset","runtime","codec","metadata"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0024","file":"lessons/DSC-0021-asset-entry-codec-enum-contract/LSN-0024-string-on-the-wire-enum-in-runtime.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]} {"type":"discussion","id":"DSC-0021","status":"done","ticket":"asset-entry-codec-enum-with-metadata","title":"Asset Entry Codec Enum Contract","created_at":"2026-04-09","updated_at":"2026-04-09","tags":["asset","runtime","codec","metadata"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0024","file":"lessons/DSC-0021-asset-entry-codec-enum-contract/LSN-0024-string-on-the-wire-enum-in-runtime.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}
@ -21,6 +21,7 @@
{"type":"discussion","id":"DSC-0026","status":"done","ticket":"render-all-scene-cache-and-camera-integration","title":"Integrate render_all with Scene Cache and Camera","created_at":"2026-04-14","updated_at":"2026-04-18","tags":["gfx","runtime","render","camera","scene"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0031","file":"lessons/DSC-0026-render-all-scene-cache-and-camera-integration/LSN-0031-frame-composition-belongs-above-the-render-backend.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]} {"type":"discussion","id":"DSC-0026","status":"done","ticket":"render-all-scene-cache-and-camera-integration","title":"Integrate render_all with Scene Cache and Camera","created_at":"2026-04-14","updated_at":"2026-04-18","tags":["gfx","runtime","render","camera","scene"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0031","file":"lessons/DSC-0026-render-all-scene-cache-and-camera-integration/LSN-0031-frame-composition-belongs-above-the-render-backend.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]}
{"type":"discussion","id":"DSC-0027","status":"done","ticket":"frame-composer-public-syscall-surface","title":"Agenda - FrameComposer Public Syscall Surface","created_at":"2026-04-17","updated_at":"2026-04-18","tags":["gfx","runtime","syscall","abi","frame-composer","scene","camera","sprites"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0032","file":"lessons/DSC-0027-frame-composer-public-syscall-surface/LSN-0032-public-abi-must-follow-the-canonical-service-boundary.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]} {"type":"discussion","id":"DSC-0027","status":"done","ticket":"frame-composer-public-syscall-surface","title":"Agenda - FrameComposer Public Syscall Surface","created_at":"2026-04-17","updated_at":"2026-04-18","tags":["gfx","runtime","syscall","abi","frame-composer","scene","camera","sprites"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0032","file":"lessons/DSC-0027-frame-composer-public-syscall-surface/LSN-0032-public-abi-must-follow-the-canonical-service-boundary.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]}
{"type":"discussion","id":"DSC-0028","status":"done","ticket":"deferred-overlay-and-primitive-composition","title":"Deferred Overlay and Primitive Composition over FrameComposer","created_at":"2026-04-18","updated_at":"2026-04-18","tags":["gfx","runtime","render","frame-composer","overlay","primitives","hud"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0033","file":"lessons/DSC-0028-deferred-overlay-and-primitive-composition/LSN-0033-debug-primitives-should-be-a-final-overlay-not-part-of-game-composition.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]} {"type":"discussion","id":"DSC-0028","status":"done","ticket":"deferred-overlay-and-primitive-composition","title":"Deferred Overlay and Primitive Composition over FrameComposer","created_at":"2026-04-18","updated_at":"2026-04-18","tags":["gfx","runtime","render","frame-composer","overlay","primitives","hud"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0033","file":"lessons/DSC-0028-deferred-overlay-and-primitive-composition/LSN-0033-debug-primitives-should-be-a-final-overlay-not-part-of-game-composition.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]}
{"type":"discussion","id":"DSC-0029","status":"done","ticket":"scene-bank-glyph-runtime-binding-leak","title":"Scene Bank Glyph Runtime Binding Leak","created_at":"2026-04-24","updated_at":"2026-04-24","tags":["gfx","runtime","asset","scene","glyph","format","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0038","file":"lessons/DSC-0029-scene-bank-glyph-runtime-binding-leak/LSN-0038-cold-scene-dependencies-must-bind-by-asset-identity.md","status":"done","created_at":"2026-04-24","updated_at":"2026-04-24"}]}
{"type":"discussion","id":"DSC-0014","status":"done","ticket":"perf-vm-allocation-and-copy-pressure","title":"Agenda - [PERF] VM Allocation and Copy Pressure","created_at":"2026-03-27","updated_at":"2026-04-20","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0035","file":"lessons/DSC-0014-perf-vm-allocation-and-copy-pressure/LSN-0035-first-materialization-is-not-the-same-as-hot-path-copy-pressure.md","status":"done","created_at":"2026-04-20","updated_at":"2026-04-20"}]} {"type":"discussion","id":"DSC-0014","status":"done","ticket":"perf-vm-allocation-and-copy-pressure","title":"Agenda - [PERF] VM Allocation and Copy Pressure","created_at":"2026-03-27","updated_at":"2026-04-20","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0035","file":"lessons/DSC-0014-perf-vm-allocation-and-copy-pressure/LSN-0035-first-materialization-is-not-the-same-as-hot-path-copy-pressure.md","status":"done","created_at":"2026-04-20","updated_at":"2026-04-20"}]}
{"type":"discussion","id":"DSC-0015","status":"abandoned","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-04-20","tags":[],"agendas":[{"id":"AGD-0014","file":"workflow/agendas/AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"abandoned","created_at":"2026-03-27","updated_at":"2026-04-20","_override_reason":"User explicitly chose to close the discussion without decision because FS->memory copy for the program is already acceptable."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to abandon the discussion without creating a decision because FS->memory copy for the program is already acceptable."} {"type":"discussion","id":"DSC-0015","status":"abandoned","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-04-20","tags":[],"agendas":[{"id":"AGD-0014","file":"workflow/agendas/AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"abandoned","created_at":"2026-03-27","updated_at":"2026-04-20","_override_reason":"User explicitly chose to close the discussion without decision because FS->memory copy for the program is already acceptable."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to abandon the discussion without creating a decision because FS->memory copy for the program is already acceptable."}
{"type":"discussion","id":"DSC-0016","status":"done","ticket":"tilemap-empty-cell-vs-tile-id-zero","title":"Tilemap Empty Cell vs Tile ID Zero","created_at":"2026-03-27","updated_at":"2026-04-09","tags":[],"agendas":[{"id":"AGD-0015","file":"workflow/agendas/AGD-0015-tilemap-empty-cell-vs-tile-id-zero.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-09"}],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0022","file":"lessons/DSC-0016-tilemap-empty-cell-semantics/LSN-0022-tilemap-empty-cell-convergence.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]} {"type":"discussion","id":"DSC-0016","status":"done","ticket":"tilemap-empty-cell-vs-tile-id-zero","title":"Tilemap Empty Cell vs Tile ID Zero","created_at":"2026-03-27","updated_at":"2026-04-09","tags":[],"agendas":[{"id":"AGD-0015","file":"workflow/agendas/AGD-0015-tilemap-empty-cell-vs-tile-id-zero.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-09"}],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0022","file":"lessons/DSC-0016-tilemap-empty-cell-semantics/LSN-0022-tilemap-empty-cell-convergence.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}

View File

@ -0,0 +1,70 @@
---
id: LSN-0038
ticket: scene-bank-glyph-runtime-binding-leak
title: Cold Scene Dependencies Must Bind by Asset Identity, Not Runtime Residency
created: 2026-04-24
tags: [gfx, runtime, asset, scene, glyph, format, architecture]
---
## Context
`SCENE` payloads had started to serialize a per-layer `glyph_bank_id`, and the runtime was consuming that byte as if it were the authoritative glyph residency binding. That meant a cold scene asset was no longer just describing its dependencies; it was leaking the runtime slot topology that happened to exist when the asset was authored.
The implementation work for `DSC-0029` replaced that contract end to end. Scene layers now carry glyph dependencies by canonical `AssetId (i32)`, the runtime owns the reverse residency lookup, and scene activation/composition fail fatally when required glyph assets are not currently resident.
## Key Decisions
### Scene Assets Must Describe Dependencies, Not Slot Topology
**What:**
Each `SceneLayer` now declares its glyph dependency by canonical `AssetId`, and the `SCENE` wire format serializes that dependency as `i32`.
**Why:**
Runtime slot ids are operational state. They can change with preload order, slot reuse, and runtime relocation. Putting them on the wire couples a cold asset to an unstable residency layout and breaks the intended separation between authored content and runtime execution state.
**Trade-offs:**
The payload widened and old payload compatibility was intentionally dropped. That makes the migration more abrupt, but it restores the correct contract boundary instead of preserving an invalid one.
### Runtime Residency Must Be Resolved Through Runtime-Owned Indexes
**What:**
The runtime now owns the authoritative `asset_id -> slot` reverse index for committed glyph assets, and scene bind/draw paths consult that index instead of trusting scene bytes as slot bindings.
**Why:**
Residency is a runtime concern. A reverse index gives the renderer and scene binder the correct primitive while keeping slot ownership inside the asset system where preload, commit, overwrite, and invalidation already live.
**Trade-offs:**
The asset manager has more bookkeeping responsibility, but the model becomes coherent and future public lookup APIs can emerge from the same primitive.
### Missing Scene Dependencies Are Fatal, Not Passive Status Errors
**What:**
If `bind_scene` cannot resolve a declared glyph dependency, or if composition later loses a required dependency, the machine fails fatally with explicit logging.
**Why:**
A bound scene without its declared glyph dependencies is not operationally meaningful. Treating that condition as a soft status error would let the system continue from a broken render contract.
**Trade-offs:**
This is stricter than a passive operational rejection. The runtime becomes less forgiving, but it also becomes more honest: malformed residency state cannot masquerade as a valid scene activation path.
## Patterns and Algorithms
- Keep authored asset formats in cold identity space and runtime systems in residency space.
- Use `AssetId` as the handoff boundary between content and runtime resolution.
- Maintain reverse residency indexes inside the asset manager whenever runtime services need dependency lookup by identity.
- Re-resolve runtime bindings in the active path when residency can change after activation.
- Prefer fatal failure over passive degradation when a declared scene dependency is mandatory for correct composition.
## Pitfalls
- A one-byte field can look harmless while still encoding the wrong domain concept; width is less dangerous than semantics.
- Migrating the wire format without updating tooling and generated fixtures leaves the runtime correct but the stress assets incoherent.
- Freezing slot bindings too early would turn runtime residency changes into stale scene state.
- Introducing a reverse lookup only as an optimization shortcut invites future API and ownership problems; it should be modeled as a first-class runtime primitive.
## Takeaways
- Cold scene payloads should carry dependency identity, never runtime residency topology.
- Runtime slot lookup belongs to the asset manager, not to authored scene data.
- A reverse `asset_id -> slot` index is the clean bridge between authored dependencies and runtime composition.
- Missing scene dependencies should fail loudly because the render contract is broken, not merely unavailable.

View File

@ -579,7 +579,7 @@ Fault boundary:
| `gfx.draw_square` | `void` | no real operational failure path in v1 | | `gfx.draw_square` | `void` | no real operational failure path in v1 |
| `gfx.draw_text` | `void` | no real operational failure path in v1 | | `gfx.draw_text` | `void` | no real operational failure path in v1 |
| `gfx.clear_565` | `void` | no real operational failure path in v1 | | `gfx.clear_565` | `void` | no real operational failure path in v1 |
| `composer.bind_scene` | `status:int` | explicit orchestration-domain operational result | | `composer.bind_scene` | `status:int` | status-returning API, but missing scene glyph dependencies are fatal runtime errors |
| `composer.unbind_scene` | `status:int` | explicit orchestration-domain operational result | | `composer.unbind_scene` | `status:int` | explicit orchestration-domain operational result |
| `composer.set_camera` | `void` | no real operational failure path in v1 | | `composer.set_camera` | `void` | no real operational failure path in v1 |
| `composer.emit_sprite` | `status:int` | explicit orchestration-domain operational rejection | | `composer.emit_sprite` | `status:int` | explicit orchestration-domain operational rejection |
@ -596,6 +596,18 @@ The public `gfx.*` primitive family remains valid in v1, but its stable operatio
This means callers MUST NOT rely on stable immediate writes to the working back buffer as the public contract for `gfx.draw_text(...)` or sibling primitives. This means callers MUST NOT rely on stable immediate writes to the working back buffer as the public contract for `gfx.draw_text(...)` or sibling primitives.
### 19.1.b Scene dependency fatal boundary
`composer.bind_scene` remains a status-returning syscall in the public ABI, but scene glyph dependency absence is outside the accepted passive operational-error model.
Rules:
- the target scene slot may still be rejected through ordinary status-returning behavior when the scene itself is unavailable;
- missing glyph dependencies referenced by a resident scene are not a passive `status:int` case;
- if scene activation discovers that a layer dependency cannot be resolved to a committed glyph asset, the machine MUST fail fatally and emit a clear log;
- if scene composition later discovers that a layer dependency can no longer be resolved, the machine MUST fail fatally and emit a clear log;
- runtime MUST NOT continue canonical scene composition after such a dependency failure.
### 19.2 `composer.emit_sprite` ### 19.2 `composer.emit_sprite`
`composer.emit_sprite` returns `status:int`. `composer.emit_sprite` returns `status:int`.

View File

@ -108,9 +108,9 @@ This table describes content identity and storage layout, not live residency.
`codec` does not define the bank-specific serialized layout itself. Specialized banks may still have normative decode rules even when `codec = NONE`. `codec` does not define the bank-specific serialized layout itself. Specialized banks may still have normative decode rules even when `codec = NONE`.
### 4.1 `TILES` asset contract in v1 ### 4.1 `GLYPH` asset contract in v1
For `BankType::TILES`, the v1 runtime-facing contract is: For `BankType::GLYPH`, the v1 runtime-facing contract is:
- `codec = NONE` - `codec = NONE`
- serialized pixels use packed `u4` palette indices - serialized pixels use packed `u4` palette indices
@ -118,20 +118,20 @@ For `BankType::TILES`, the v1 runtime-facing contract is:
- `palette_count = 64` - `palette_count = 64`
- runtime materialization may expand pixel indices to one `u8` per pixel - runtime materialization may expand pixel indices to one `u8` per pixel
For `TILES`, `NONE` means there is no additional generic codec layer beyond the bank contract itself. For `GLYPH`, `NONE` means there is no additional generic codec layer beyond the bank contract itself.
For the current transition window: For the current transition window:
- `RAW` is a legacy deprecated alias of `NONE` - `RAW` is a legacy deprecated alias of `NONE`
- newly published material must use `NONE` as the canonical value - newly published material must use `NONE` as the canonical value
Even with `codec = NONE`, `TILES` still requires bank-specific decode from its serialized payload. The serialized byte layout therefore does not need to match the in-memory layout. Even with `codec = NONE`, `GLYPH` still requires bank-specific decode from its serialized payload. The serialized byte layout therefore does not need to match the in-memory layout.
#### 4.1.1 Metadata Normalization #### 4.1.1 Metadata Normalization
Following `DEC-0004`, the `AssetEntry.metadata` field must be structured in segmented form to avoid ambiguity. Following `DEC-0004`, the `AssetEntry.metadata` field must be structured in segmented form to avoid ambiguity.
Required effective metadata fields for `TILES` at the root level: Required effective metadata fields for `GLYPH` at the root level:
- `tile_size`: tile edge in pixels; valid values are `8`, `16`, or `32` - `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
@ -143,7 +143,7 @@ Optional informative subtrees:
- `metadata.codec`: codec/compressor-specific configuration (for example dictionaries or compression flags). - `metadata.codec`: codec/compressor-specific configuration (for example dictionaries or compression flags).
- `metadata.pipeline`: informative metadata from the Studio build process (for example source hashes, timestamps, or tool versions). - `metadata.pipeline`: informative metadata from the Studio build process (for example source hashes, timestamps, or tool versions).
Validation rules for `TILES` v1: Validation rules for `GLYPH` v1:
- `palette_count` must be `64` - `palette_count` must be `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
@ -160,17 +160,74 @@ The tile-bank payload therefore separates serialized storage form from runtime m
- decoded pixel plane: expanded `u8` indices, one entry per pixel - decoded pixel plane: expanded `u8` indices, one entry per pixel
- palette table: `64 * 16` colors in `RGB565` - palette table: `64 * 16` colors in `RGB565`
For `TILES` v1: For `GLYPH` v1:
- `size` must match `ceil(width * height / 2) + (palette_count * 16 * 2)` - `size` must match `ceil(width * height / 2) + (palette_count * 16 * 2)`
- `decoded_size` must match `(width * height) + (palette_count * 16 * 2)` - `decoded_size` must match `(width * height) + (palette_count * 16 * 2)`
### 4.2 `SCENE` asset contract in v1
For `BankType::SCENE`, the v1 runtime-facing contract is:
- `codec = NONE`
- payload magic is `SCNE`
- payload version is `1`
- layer count is fixed at `4`
- each layer carries its own glyph dependency
- the glyph dependency is serialized as canonical `AssetId` using signed 32-bit little-endian width
- the payload MUST describe cold asset dependencies only and MUST NOT encode runtime glyph slot topology
Even with `codec = NONE`, `SCENE` still has a bank-specific binary contract. The serialized layout is normative and distinct from any transient runtime residency state.
#### 4.2.1 Layer Header Layout
Each scene layer header is:
1. `flags: u8`
2. `glyph_asset_id: i32` (little-endian)
3. `tile_size: u8`
4. `reserved: u16`
5. `parallax_x: f32`
6. `parallax_y: f32`
7. `width: u32`
8. `height: u32`
9. `tile_count: u32`
10. `reserved: u32`
This makes the canonical per-layer header size `32` bytes.
Normative rules:
- `glyph_asset_id` MUST be interpreted as the cold identity of the glyph dependency for that layer;
- `glyph_asset_id` MUST NOT be interpreted as a runtime slot id;
- each layer MUST own exactly one glyph dependency reference;
- no shared dependency table exists in this contract;
- `tile_count` MUST equal `width * height`.
#### 4.2.2 Runtime Binding Semantics
`SCENE` assets do not own runtime residency.
Therefore:
- the runtime MUST resolve scene glyph dependencies through runtime-owned residency state;
- the runtime MUST maintain a runtime-owned mapping from committed glyph `AssetId` to glyph slot;
- scene bind and scene draw MUST consult runtime residency, not serialized slot topology;
- a missing scene glyph dependency is not a passive asset-status condition.
If a scene glyph dependency cannot be resolved:
- `bind_scene` MUST fail as a fatal machine error with clear logging;
- draw/composition MUST fail as a fatal machine error with clear logging;
- runtime MUST NOT continue scene composition after the missing dependency is detected.
## 5 Banks and Slots ## 5 Banks and Slots
The current runtime exposes bank types: The current runtime exposes bank types:
- `GLYPH` - `GLYPH`
- `SOUNDS` - `SOUNDS`
- `SCENE`
Assets are loaded into explicit slots identified by slot index at the public ABI boundary. Assets are loaded into explicit slots identified by slot index at the public ABI boundary.

View File

@ -229,6 +229,12 @@ For mutating composer operations:
- `set_camera` remains `void` in v1; - `set_camera` remains `void` in v1;
- no caller-provided sprite index or `active` flag is part of the canonical contract. - no caller-provided sprite index or `active` flag is part of the canonical contract.
Fatal boundary clarification for `bind_scene`:
- `bind_scene` remains status-returning for ordinary orchestration-domain rejection such as unresolved target scene slot;
- missing glyph dependencies inside a resident scene are not accepted as passive `ComposerOpStatus` outcomes;
- if scene activation or later composition detects an unresolved scene glyph dependency, runtime MUST escalate that condition as a fatal machine error with clear logging.
## 7 Syscalls as Callable Entities (Not First-Class) ## 7 Syscalls as Callable Entities (Not First-Class)
Syscalls behave like call sites, not like first-class guest values. Syscalls behave like call sites, not like first-class guest values.