implements PLN-0041 scene glyph runtime resolution

This commit is contained in:
bQUARKz 2026-04-24 11:08:39 +01:00
parent 26686c288c
commit 0d6ff430a0
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
14 changed files with 368 additions and 109 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)]
@ -1144,9 +1187,13 @@ mod tests {
fn test_scene() -> SceneBank { fn test_scene() -> SceneBank {
let make_layer = let make_layer =
|glyph_bank_id: u8, parallax_x: f32, parallax_y: f32, tile_size: TileSize| SceneLayer { |glyph_asset_id: AssetId,
active: glyph_bank_id != 3, active: bool,
glyph_bank_id, parallax_x: f32,
parallax_y: f32,
tile_size: TileSize| SceneLayer {
active,
glyph_asset_id,
tile_size, tile_size,
parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y }, parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
tilemap: TileMap { tilemap: TileMap {
@ -1155,37 +1202,25 @@ mod tests {
tiles: vec![ tiles: vec![
Tile { Tile {
active: true, active: true,
glyph: Glyph { glyph: Glyph { glyph_id: 10, palette_id: 1 },
glyph_id: 10 + glyph_bank_id as u16,
palette_id: glyph_bank_id,
},
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,
palette_id: glyph_bank_id + 1,
},
flip_x: true, flip_x: true,
flip_y: false, flip_y: false,
}, },
Tile { Tile {
active: true, active: true,
glyph: Glyph { glyph: Glyph { glyph_id: 30, palette_id: 3 },
glyph_id: 30 + glyph_bank_id as u16,
palette_id: glyph_bank_id + 2,
},
flip_x: false, flip_x: false,
flip_y: true, flip_y: true,
}, },
Tile { Tile {
active: glyph_bank_id != 2, active,
glyph: Glyph { glyph: Glyph { glyph_id: 40, palette_id: 4 },
glyph_id: 40 + glyph_bank_id as u16,
palette_id: glyph_bank_id + 3,
},
flip_x: true, flip_x: true,
flip_y: true, flip_y: true,
}, },
@ -1195,10 +1230,10 @@ mod tests {
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 +1259,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,7 +1393,7 @@ 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_eq!(decoded.layers[0].tilemap.tiles[1].flip_x, true);
@ -1382,7 +1417,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 +1441,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,19 @@ 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 +294,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,
@ -316,23 +343,25 @@ mod tests {
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller, GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller,
}; };
use prometeu_hal::color::Color; use prometeu_hal::color::Color;
use prometeu_hal::asset::AssetId;
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 +382,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 +404,12 @@ 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 +428,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 +444,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 +464,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 +479,12 @@ 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 +496,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 +506,12 @@ 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 +520,28 @@ 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 +661,12 @@ 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 {
@ -621,7 +705,12 @@ mod tests {
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(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>); FrameComposer::new(
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 },
@ -652,7 +741,12 @@ mod tests {
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(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>); FrameComposer::new(
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>);
@ -683,7 +777,12 @@ mod tests {
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(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>); FrameComposer::new(
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 +815,36 @@ 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"));
}
} }

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,7 +651,12 @@ 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();
@ -665,6 +675,7 @@ impl Gfx {
self.h, self.h,
cache, cache,
&update.copy_requests[layer_index], &update.copy_requests[layer_index],
resolved_glyph_slots[layer_index],
&*self.glyph_banks, &*self.glyph_banks,
); );
} }
@ -695,6 +706,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 +715,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;
@ -894,8 +909,10 @@ impl Gfx {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::asset::GlyphAssetSlotIndex;
use crate::FrameComposer; use crate::FrameComposer;
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 +936,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 +944,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 +963,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 +995,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());
@ -1054,7 +1081,12 @@ mod tests {
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(32, 18, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>); FrameComposer::new(
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 +1127,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 +1178,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());
} }

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

@ -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,17 @@ 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 +26,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,13 +280,13 @@ 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, base_glyph + (y * 4 + x) as u16,
glyph_bank_id, 1,
x % 2 == 0, x % 2 == 0,
y % 2 == 1, y % 2 == 1,
)); ));
@ -293,7 +295,7 @@ mod tests {
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 +366,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,7 +188,10 @@ impl NativeInterface for VirtualMachineRuntime {
} }
}; };
let status = if hw.bind_scene(scene_bank_id) { let bound = catch_unwind(AssertUnwindSafe(|| hw.bind_scene(scene_bank_id)))
.map_err(|payload| VmFault::Panic(Self::panic_payload_to_string(payload)))?;
let status = if bound {
ComposerOpStatus::Ok ComposerOpStatus::Ok
} else { } else {
ComposerOpStatus::SceneUnavailable ComposerOpStatus::SceneUnavailable

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,19 @@ 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

@ -21,7 +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":"open","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":[{"id":"AGD-0029","file":"AGD-0029-scene-bank-glyph-runtime-binding-leak.md","status":"accepted","created_at":"2026-04-24","updated_at":"2026-04-24"}],"decisions":[{"id":"DEC-0021","file":"DEC-0021-scene-bank-glyph-dependencies-must-bind-by-asset-id.md","status":"in_progress","created_at":"2026-04-24","updated_at":"2026-04-24","ref_agenda":"AGD-0029"}],"plans":[{"id":"PLN-0040","file":"PLN-0040-scene-glyph-dependency-spec-and-wire-layout.md","status":"done","created_at":"2026-04-24","updated_at":"2026-04-24","ref_decisions":["DEC-0021"]},{"id":"PLN-0041","file":"PLN-0041-scene-glyph-runtime-resolution-and-fatal-paths.md","status":"review","created_at":"2026-04-24","updated_at":"2026-04-24","ref_decisions":["DEC-0021"]},{"id":"PLN-0042","file":"PLN-0042-scene-glyph-tooling-and-fixture-migration.md","status":"review","created_at":"2026-04-24","updated_at":"2026-04-24","ref_decisions":["DEC-0021"]}],"lessons":[]} {"type":"discussion","id":"DSC-0029","status":"open","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":[{"id":"AGD-0029","file":"AGD-0029-scene-bank-glyph-runtime-binding-leak.md","status":"accepted","created_at":"2026-04-24","updated_at":"2026-04-24"}],"decisions":[{"id":"DEC-0021","file":"DEC-0021-scene-bank-glyph-dependencies-must-bind-by-asset-id.md","status":"in_progress","created_at":"2026-04-24","updated_at":"2026-04-24","ref_agenda":"AGD-0029"}],"plans":[{"id":"PLN-0040","file":"PLN-0040-scene-glyph-dependency-spec-and-wire-layout.md","status":"done","created_at":"2026-04-24","updated_at":"2026-04-24","ref_decisions":["DEC-0021"]},{"id":"PLN-0041","file":"PLN-0041-scene-glyph-runtime-resolution-and-fatal-paths.md","status":"done","created_at":"2026-04-24","updated_at":"2026-04-24","ref_decisions":["DEC-0021"]},{"id":"PLN-0042","file":"PLN-0042-scene-glyph-tooling-and-fixture-migration.md","status":"review","created_at":"2026-04-24","updated_at":"2026-04-24","ref_decisions":["DEC-0021"]}],"lessons":[]}
{"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

@ -2,9 +2,9 @@
id: PLN-0041 id: PLN-0041
ticket: scene-bank-glyph-runtime-binding-leak ticket: scene-bank-glyph-runtime-binding-leak
title: Scene glyph runtime resolution, reverse index, and fatal paths title: Scene glyph runtime resolution, reverse index, and fatal paths
status: review status: done
created: 2026-04-24 created: 2026-04-24
completed: completed: 2026-04-24
tags: [gfx, runtime, asset, scene, glyph, render] tags: [gfx, runtime, asset, scene, glyph, render]
--- ---
@ -138,4 +138,3 @@ Update the draw path so each layer resolves or revalidates the current glyph slo
- Runtime fatalization may expose pre-existing tests or flows that assumed passive failure. - Runtime fatalization may expose pre-existing tests or flows that assumed passive failure.
- Reverse-index maintenance can become incorrect if any overwrite/clear path is missed. - Reverse-index maintenance can become incorrect if any overwrite/clear path is missed.
- Removing slot ids from scene/cache types can cascade into a larger render refactor than expected if code paths implicitly rely on them. - Removing slot ids from scene/cache types can cascade into a larger render refactor than expected if code paths implicitly rely on them.