Merge pull request 'dev/scene-bank-glyph-runtime-binding-leak' (#21) from dev/scene-bank-glyph-runtime-binding-leak into master
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good
Reviewed-on: #21
This commit is contained in:
commit
b0762c776a
@ -32,6 +32,35 @@ type StagingMap<T> = HashMap<HandleId, StagedValue<T>>;
|
||||
type AssetTable = HashMap<AssetId, AssetEntry>;
|
||||
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_COLORS_PER_PALETTE: usize = 16;
|
||||
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).
|
||||
gfx_slots: Arc<RwLock<[Option<AssetId>; 16]>>,
|
||||
glyph_slot_index: GlyphAssetSlotIndex,
|
||||
sound_slots: Arc<RwLock<[Option<AssetId>; 16]>>,
|
||||
scene_slots: Arc<RwLock<[Option<AssetId>; 16]>>,
|
||||
|
||||
@ -309,6 +339,7 @@ impl AssetManager {
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
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))),
|
||||
scene_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
|
||||
gfx_policy: BankPolicy::new(),
|
||||
@ -361,6 +392,7 @@ impl AssetManager {
|
||||
let mut slots = self.gfx_slots.write().unwrap();
|
||||
if slot_index < slots.len() {
|
||||
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 layers = std::array::from_fn(|_| SceneLayer {
|
||||
active: false,
|
||||
glyph_bank_id: 0,
|
||||
glyph_asset_id: 0,
|
||||
tile_size: TileSize::Size8,
|
||||
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
|
||||
tilemap: TileMap { width: 0, height: 0, tiles: Vec::new() },
|
||||
@ -762,8 +794,13 @@ impl AssetManager {
|
||||
}
|
||||
|
||||
let flags = buffer[offset];
|
||||
let glyph_bank_id = buffer[offset + 1];
|
||||
let tile_size_raw = buffer[offset + 2];
|
||||
let glyph_asset_id = i32::from_le_bytes([
|
||||
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 {
|
||||
8 => TileSize::Size8,
|
||||
16 => TileSize::Size16,
|
||||
@ -771,39 +808,39 @@ impl AssetManager {
|
||||
other => return Err(format!("Invalid SCENE tile size: {}", other)),
|
||||
};
|
||||
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 + 9],
|
||||
buffer[offset + 10],
|
||||
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() {
|
||||
return Err("Invalid SCENE parallax_factor".to_string());
|
||||
}
|
||||
|
||||
let width = u32::from_le_bytes([
|
||||
buffer[offset + 12],
|
||||
buffer[offset + 13],
|
||||
buffer[offset + 14],
|
||||
buffer[offset + 15],
|
||||
]) as usize;
|
||||
let height = u32::from_le_bytes([
|
||||
buffer[offset + 16],
|
||||
buffer[offset + 17],
|
||||
buffer[offset + 18],
|
||||
buffer[offset + 19],
|
||||
]) as usize;
|
||||
let tile_count = u32::from_le_bytes([
|
||||
let height = u32::from_le_bytes([
|
||||
buffer[offset + 20],
|
||||
buffer[offset + 21],
|
||||
buffer[offset + 22],
|
||||
buffer[offset + 23],
|
||||
]) 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 =
|
||||
width.checked_mul(height).ok_or("SCENE tile count overflow")?;
|
||||
@ -845,7 +882,7 @@ impl AssetManager {
|
||||
|
||||
*layer = SceneLayer {
|
||||
active: (flags & 0b0000_0001) != 0,
|
||||
glyph_bank_id,
|
||||
glyph_asset_id,
|
||||
tile_size,
|
||||
parallax_factor: ParallaxFactor { x: parallax_factor_x, y: parallax_factor_y },
|
||||
tilemap: TileMap { width, height, tiles },
|
||||
@ -960,6 +997,7 @@ impl AssetManager {
|
||||
let mut slots = self.gfx_slots.write().unwrap();
|
||||
if h.slot.index < slots.len() {
|
||||
slots[h.slot.index] = Some(h._asset_id);
|
||||
self.glyph_slot_index.rebuild_from_slots(&slots);
|
||||
}
|
||||
h.status = LoadStatus::COMMITTED;
|
||||
}
|
||||
@ -1089,9 +1127,14 @@ impl AssetManager {
|
||||
self.handles.write().unwrap().clear();
|
||||
self.pending_commits.lock().unwrap().clear();
|
||||
self.gfx_slots.write().unwrap().fill(None);
|
||||
self.glyph_slot_index.clear();
|
||||
self.sound_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)]
|
||||
@ -1143,62 +1186,53 @@ mod tests {
|
||||
}
|
||||
|
||||
fn test_scene() -> SceneBank {
|
||||
let make_layer =
|
||||
|glyph_bank_id: u8, parallax_x: f32, parallax_y: f32, tile_size: TileSize| SceneLayer {
|
||||
active: glyph_bank_id != 3,
|
||||
glyph_bank_id,
|
||||
tile_size,
|
||||
parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
|
||||
tilemap: TileMap {
|
||||
width: 2,
|
||||
height: 2,
|
||||
tiles: vec![
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph {
|
||||
glyph_id: 10 + glyph_bank_id as u16,
|
||||
palette_id: glyph_bank_id,
|
||||
},
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
},
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph {
|
||||
glyph_id: 20 + glyph_bank_id as u16,
|
||||
palette_id: glyph_bank_id + 1,
|
||||
},
|
||||
flip_x: true,
|
||||
flip_y: false,
|
||||
},
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph {
|
||||
glyph_id: 30 + glyph_bank_id as u16,
|
||||
palette_id: glyph_bank_id + 2,
|
||||
},
|
||||
flip_x: false,
|
||||
flip_y: true,
|
||||
},
|
||||
Tile {
|
||||
active: glyph_bank_id != 2,
|
||||
glyph: Glyph {
|
||||
glyph_id: 40 + glyph_bank_id as u16,
|
||||
palette_id: glyph_bank_id + 3,
|
||||
},
|
||||
flip_x: true,
|
||||
flip_y: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
let make_layer = |glyph_asset_id: AssetId,
|
||||
active: bool,
|
||||
parallax_x: f32,
|
||||
parallax_y: f32,
|
||||
tile_size: TileSize| SceneLayer {
|
||||
active,
|
||||
glyph_asset_id,
|
||||
tile_size,
|
||||
parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
|
||||
tilemap: TileMap {
|
||||
width: 2,
|
||||
height: 2,
|
||||
tiles: vec![
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph { glyph_id: 10, palette_id: 1 },
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
},
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph { glyph_id: 20, palette_id: 2 },
|
||||
flip_x: true,
|
||||
flip_y: false,
|
||||
},
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph { glyph_id: 30, palette_id: 3 },
|
||||
flip_x: false,
|
||||
flip_y: true,
|
||||
},
|
||||
Tile {
|
||||
active,
|
||||
glyph: Glyph { glyph_id: 40, palette_id: 4 },
|
||||
flip_x: true,
|
||||
flip_y: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
SceneBank {
|
||||
layers: [
|
||||
make_layer(0, 1.0, 1.0, TileSize::Size16),
|
||||
make_layer(1, 0.5, 0.75, TileSize::Size8),
|
||||
make_layer(2, 1.0, 0.5, TileSize::Size32),
|
||||
make_layer(3, 0.25, 0.25, TileSize::Size16),
|
||||
make_layer(100, true, 1.0, 1.0, TileSize::Size16),
|
||||
make_layer(101, true, 0.5, 0.75, TileSize::Size8),
|
||||
make_layer(102, true, 1.0, 0.5, TileSize::Size32),
|
||||
make_layer(103, false, 0.25, 0.25, TileSize::Size16),
|
||||
],
|
||||
}
|
||||
}
|
||||
@ -1224,9 +1258,9 @@ mod tests {
|
||||
for layer in &scene.layers {
|
||||
let layer_flags = if layer.active { 0b0000_0001 } else { 0 };
|
||||
data.push(layer_flags);
|
||||
data.push(layer.glyph_bank_id);
|
||||
data.extend_from_slice(&layer.glyph_asset_id.to_le_bytes());
|
||||
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.y.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");
|
||||
|
||||
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[2].tile_size, TileSize::Size32);
|
||||
assert_eq!(decoded.layers[0].tilemap.tiles[1].flip_x, true);
|
||||
assert_eq!(decoded.layers[2].tilemap.tiles[2].flip_y, true);
|
||||
assert_eq!(decoded.layers[3].active, false);
|
||||
assert!(decoded.layers[0].tilemap.tiles[1].flip_x);
|
||||
assert!(decoded.layers[2].tilemap.tiles[2].flip_y);
|
||||
assert!(!decoded.layers[3].active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1382,7 +1416,7 @@ mod tests {
|
||||
fn test_decode_scene_bank_rejects_invalid_tile_size() {
|
||||
let scene = test_scene();
|
||||
let mut data = encode_scene_payload(&scene);
|
||||
data[14] = 12;
|
||||
data[17] = 12;
|
||||
let entry = test_scene_asset_entry("scene", &data, &scene);
|
||||
|
||||
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() {
|
||||
let scene = test_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 err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err();
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use crate::asset::GlyphAssetSlotIndex;
|
||||
use crate::memory_banks::SceneBankPoolAccess;
|
||||
use prometeu_hal::GfxBridge;
|
||||
use prometeu_hal::glyph::Glyph;
|
||||
@ -116,6 +117,7 @@ impl SpriteController {
|
||||
|
||||
pub struct FrameComposer {
|
||||
scene_bank_pool: Arc<dyn SceneBankPoolAccess>,
|
||||
glyph_slot_index: GlyphAssetSlotIndex,
|
||||
viewport_width_px: usize,
|
||||
viewport_height_px: usize,
|
||||
active_scene_id: Option<usize>,
|
||||
@ -133,9 +135,11 @@ impl FrameComposer {
|
||||
viewport_width_px: usize,
|
||||
viewport_height_px: usize,
|
||||
scene_bank_pool: Arc<dyn SceneBankPoolAccess>,
|
||||
glyph_slot_index: GlyphAssetSlotIndex,
|
||||
) -> Self {
|
||||
Self {
|
||||
scene_bank_pool,
|
||||
glyph_slot_index,
|
||||
viewport_width_px,
|
||||
viewport_height_px,
|
||||
active_scene_id: None,
|
||||
@ -187,6 +191,8 @@ impl FrameComposer {
|
||||
return false;
|
||||
};
|
||||
|
||||
let _ = self.resolve_glyph_slots(&scene, scene_bank_id, "bind_scene");
|
||||
|
||||
let (cache, resolver) =
|
||||
Self::build_scene_runtime(self.viewport_width_px, self.viewport_height_px, &scene);
|
||||
self.active_scene_id = Some(scene_bank_id);
|
||||
@ -242,15 +248,20 @@ impl FrameComposer {
|
||||
let ordered_sprites = self.ordered_sprites();
|
||||
gfx.load_frame_sprites(&ordered_sprites);
|
||||
|
||||
if let (Some(scene), Some(cache), Some(resolver)) =
|
||||
(self.active_scene.as_deref(), self.cache.as_mut(), self.resolver.as_mut())
|
||||
{
|
||||
if let Some(scene) = self.active_scene.as_deref() {
|
||||
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);
|
||||
Self::apply_refresh_requests(cache, scene, &update.refresh_requests);
|
||||
// `FrameComposer` owns only canonical game-frame composition.
|
||||
// Deferred `gfx.*` primitives are drained later by a separate
|
||||
// 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;
|
||||
}
|
||||
|
||||
@ -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(
|
||||
cache: &mut SceneViewportCache,
|
||||
scene: &SceneBank,
|
||||
@ -315,24 +343,26 @@ mod tests {
|
||||
use crate::memory_banks::{
|
||||
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller,
|
||||
};
|
||||
use prometeu_hal::asset::AssetId;
|
||||
use prometeu_hal::color::Color;
|
||||
use prometeu_hal::glyph_bank::{GlyphBank, TileSize};
|
||||
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
|
||||
use prometeu_hal::tile::Tile;
|
||||
use prometeu_hal::tilemap::TileMap;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
fn make_scene() -> SceneBank {
|
||||
make_scene_with_palette(1, 1, TileSize::Size8)
|
||||
}
|
||||
|
||||
fn make_scene_with_palette(
|
||||
glyph_bank_id: u8,
|
||||
glyph_asset_id: AssetId,
|
||||
palette_id: u8,
|
||||
tile_size: TileSize,
|
||||
) -> SceneBank {
|
||||
let layer = SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id,
|
||||
glyph_asset_id,
|
||||
tile_size,
|
||||
parallax_factor: ParallaxFactor { x: 1.0, y: 0.5 },
|
||||
tilemap: TileMap {
|
||||
@ -353,6 +383,16 @@ mod tests {
|
||||
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 {
|
||||
let size = tile_size as usize;
|
||||
let mut bank = GlyphBank::new(tile_size, size, size);
|
||||
@ -365,7 +405,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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.active_scene_id(), None);
|
||||
@ -384,7 +425,7 @@ mod tests {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
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 =
|
||||
frame_composer.scene_bank_slot(3).expect("scene bank slot 3 should be resident");
|
||||
|
||||
@ -400,7 +441,8 @@ mod tests {
|
||||
|
||||
let expected_scene =
|
||||
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));
|
||||
|
||||
@ -419,7 +461,8 @@ mod tests {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
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));
|
||||
|
||||
frame_composer.unbind_scene();
|
||||
@ -433,7 +476,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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);
|
||||
|
||||
@ -445,7 +489,8 @@ mod tests {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
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));
|
||||
|
||||
let cache = frame_composer.cache().expect("cache should exist for bound scene");
|
||||
@ -454,7 +499,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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));
|
||||
|
||||
@ -463,6 +509,27 @@ mod tests {
|
||||
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]
|
||||
fn sprite_controller_begin_frame_resets_sprite_count_and_buckets() {
|
||||
let mut controller = SpriteController::new();
|
||||
@ -582,7 +649,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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();
|
||||
|
||||
assert!(frame_composer.emit_sprite(Sprite {
|
||||
@ -620,8 +688,12 @@ mod tests {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
banks.install_glyph_bank(1, Arc::new(make_glyph_bank(TileSize::Size8, 3, Color::WHITE)));
|
||||
|
||||
let mut frame_composer =
|
||||
FrameComposer::new(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>);
|
||||
let mut frame_composer = FrameComposer::new(
|
||||
16,
|
||||
16,
|
||||
Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>,
|
||||
make_glyph_slot_index(&[]),
|
||||
);
|
||||
frame_composer.begin_frame();
|
||||
assert!(frame_composer.emit_sprite(Sprite {
|
||||
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_scene_bank(0, Arc::new(make_scene_with_palette(0, 2, TileSize::Size8)));
|
||||
|
||||
let mut frame_composer =
|
||||
FrameComposer::new(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>);
|
||||
let mut frame_composer = FrameComposer::new(
|
||||
16,
|
||||
16,
|
||||
Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>,
|
||||
make_glyph_slot_index(&[(0, 0)]),
|
||||
);
|
||||
assert!(frame_composer.bind_scene(0));
|
||||
|
||||
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(1, Arc::new(make_scene_with_palette(1, 2, TileSize::Size8)));
|
||||
|
||||
let mut frame_composer =
|
||||
FrameComposer::new(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>);
|
||||
let mut frame_composer = 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>);
|
||||
gfx.scene_fade_level = 31;
|
||||
gfx.hud_fade_level = 31;
|
||||
@ -716,4 +796,59 @@ mod tests {
|
||||
gfx.present();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -222,8 +222,13 @@ impl GfxBridge for Gfx {
|
||||
fn render_no_scene_frame(&mut self) {
|
||||
self.render_no_scene_frame()
|
||||
}
|
||||
fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) {
|
||||
self.render_scene_from_cache(cache, update)
|
||||
fn render_scene_from_cache(
|
||||
&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]) {
|
||||
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
|
||||
/// is not consulted here; the renderer only consumes prepared cache materialization
|
||||
/// plus sprite state and fade controls.
|
||||
pub fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) {
|
||||
pub fn render_scene_from_cache(
|
||||
&mut self,
|
||||
cache: &SceneViewportCache,
|
||||
update: &ResolverUpdate,
|
||||
resolved_glyph_slots: &[usize; 4],
|
||||
) {
|
||||
self.back.fill(Color::BLACK.raw());
|
||||
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(
|
||||
&mut self.back,
|
||||
self.w,
|
||||
@ -665,6 +677,7 @@ impl Gfx {
|
||||
self.h,
|
||||
cache,
|
||||
&update.copy_requests[layer_index],
|
||||
*glyph_slot,
|
||||
&*self.glyph_banks,
|
||||
);
|
||||
}
|
||||
@ -695,6 +708,7 @@ impl Gfx {
|
||||
screen_h: usize,
|
||||
cache: &SceneViewportCache,
|
||||
request: &LayerCopyRequest,
|
||||
glyph_slot: usize,
|
||||
glyph_banks: &dyn GlyphBankPoolAccess,
|
||||
) {
|
||||
let mut target = RenderTarget { back, screen_w, screen_h };
|
||||
@ -703,8 +717,11 @@ impl Gfx {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(bank) = glyph_banks.glyph_bank_slot(layer_cache.glyph_bank_id as usize) else {
|
||||
return;
|
||||
let Some(bank) = glyph_banks.glyph_bank_slot(glyph_slot) else {
|
||||
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;
|
||||
@ -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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::FrameComposer;
|
||||
use crate::asset::GlyphAssetSlotIndex;
|
||||
use crate::memory_banks::{GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess};
|
||||
use prometeu_hal::asset::AssetId;
|
||||
use prometeu_hal::glyph_bank::TileSize;
|
||||
use prometeu_hal::scene_bank::SceneBank;
|
||||
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
|
||||
@ -919,7 +988,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn make_layer(
|
||||
glyph_bank_id: u8,
|
||||
glyph_asset_id: AssetId,
|
||||
glyph_id: u16,
|
||||
palette_id: u8,
|
||||
width: usize,
|
||||
@ -927,7 +996,7 @@ mod tests {
|
||||
) -> SceneLayer {
|
||||
SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id,
|
||||
glyph_asset_id,
|
||||
tile_size: TileSize::Size8,
|
||||
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
|
||||
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 {
|
||||
active: false,
|
||||
glyph_bank_id,
|
||||
glyph_asset_id,
|
||||
tile_size: TileSize::Size8,
|
||||
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
|
||||
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]
|
||||
fn test_draw_pixel() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
@ -1028,9 +1107,7 @@ mod tests {
|
||||
gfx.begin_overlay_frame();
|
||||
gfx.draw_square(2, 2, 6, 6, Color::WHITE, Color::BLACK);
|
||||
gfx.drain_overlay_debug();
|
||||
// Border
|
||||
assert_eq!(gfx.back[2 * 10 + 2], Color::WHITE.0);
|
||||
// Fill
|
||||
assert_eq!(gfx.back[3 * 10 + 3], Color::BLACK.0);
|
||||
assert_eq!(gfx.overlay().command_count(), 0);
|
||||
}
|
||||
@ -1053,8 +1130,12 @@ mod tests {
|
||||
fn overlay_state_is_separate_from_frame_composer_sprite_state() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let mut gfx = Gfx::new(32, 18, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>);
|
||||
let mut frame_composer =
|
||||
FrameComposer::new(32, 18, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>);
|
||||
let mut frame_composer = FrameComposer::new(
|
||||
32,
|
||||
18,
|
||||
Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>,
|
||||
make_glyph_slot_index(&[]),
|
||||
);
|
||||
|
||||
gfx.begin_overlay_frame();
|
||||
frame_composer.begin_frame();
|
||||
@ -1095,7 +1176,7 @@ mod tests {
|
||||
let mut gfx = Gfx::new(16, 16, banks);
|
||||
gfx.scene_fade_level = 31;
|
||||
gfx.hud_fade_level = 31;
|
||||
gfx.render_scene_from_cache(&cache, &update);
|
||||
gfx.render_scene_from_cache(&cache, &update, &[0, 0, 0, 0]);
|
||||
|
||||
assert_eq!(gfx.back[0], Color::RED.raw());
|
||||
}
|
||||
@ -1146,7 +1227,7 @@ mod tests {
|
||||
};
|
||||
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());
|
||||
}
|
||||
@ -1189,53 +1270,3 @@ mod tests {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -126,6 +126,14 @@ impl Hardware {
|
||||
|
||||
/// Creates hardware with explicit shared bank ownership.
|
||||
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 {
|
||||
gfx: Gfx::new(
|
||||
Self::W,
|
||||
@ -136,17 +144,12 @@ impl Hardware {
|
||||
Self::W,
|
||||
Self::H,
|
||||
Arc::clone(&memory_banks) as Arc<dyn SceneBankPoolAccess>,
|
||||
glyph_slot_index,
|
||||
),
|
||||
audio: Audio::new(Arc::clone(&memory_banks) as Arc<dyn SoundBankPoolAccess>),
|
||||
pad: Pad::default(),
|
||||
touch: Touch::default(),
|
||||
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>,
|
||||
),
|
||||
assets,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -179,7 +182,7 @@ mod tests {
|
||||
fn make_scene() -> SceneBank {
|
||||
let layer = SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id: 0,
|
||||
glyph_asset_id: 1,
|
||||
tile_size: TileSize::Size8,
|
||||
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
|
||||
tilemap: TileMap {
|
||||
@ -216,7 +219,7 @@ mod tests {
|
||||
|
||||
hardware.gfx.scene_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();
|
||||
|
||||
assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw());
|
||||
|
||||
@ -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_LAYER_COUNT_V1: usize = 4;
|
||||
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_DECODED_LAYER_OVERHEAD_BYTES_V1: usize = 16;
|
||||
|
||||
|
||||
@ -57,7 +57,12 @@ pub trait GfxBridge {
|
||||
///
|
||||
/// Deferred `gfx.*` overlay/debug primitives are intentionally outside this
|
||||
/// 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]);
|
||||
/// Submit text into the `gfx.*` primitive path.
|
||||
///
|
||||
|
||||
@ -8,16 +8,22 @@ pub struct SceneBank {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::asset::AssetId;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::scene_layer::ParallaxFactor;
|
||||
use crate::tile::Tile;
|
||||
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 {
|
||||
active: true,
|
||||
glyph_bank_id,
|
||||
glyph_asset_id,
|
||||
tile_size: TileSize::Size16,
|
||||
parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
|
||||
tilemap: TileMap {
|
||||
@ -25,7 +31,7 @@ mod tests {
|
||||
height: 1,
|
||||
tiles: vec![Tile {
|
||||
active: true,
|
||||
glyph: Glyph { glyph_id, palette_id: glyph_bank_id },
|
||||
glyph: Glyph { glyph_id, palette_id: 1 },
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
}],
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use crate::asset::AssetId;
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::tilemap::TileMap;
|
||||
|
||||
@ -10,7 +11,7 @@ pub struct ParallaxFactor {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SceneLayer {
|
||||
pub active: bool,
|
||||
pub glyph_bank_id: u8,
|
||||
pub glyph_asset_id: AssetId,
|
||||
pub tile_size: TileSize,
|
||||
pub parallax_factor: ParallaxFactor,
|
||||
pub tilemap: TileMap,
|
||||
@ -26,7 +27,7 @@ mod tests {
|
||||
fn scene_layer_preserves_parallax_factor_and_tilemap_ownership() {
|
||||
let layer = SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id: 7,
|
||||
glyph_asset_id: 7,
|
||||
tile_size: TileSize::Size16,
|
||||
parallax_factor: ParallaxFactor { x: 0.5, y: 0.75 },
|
||||
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.y, 0.75);
|
||||
assert_eq!(layer.tilemap.width, 2);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use crate::asset::AssetId;
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::scene_bank::SceneBank;
|
||||
use crate::scene_layer::SceneLayer;
|
||||
@ -12,7 +13,7 @@ pub struct CachedTileEntry {
|
||||
pub glyph_id: u16,
|
||||
pub palette_id: u8,
|
||||
pub flags: u8,
|
||||
pub glyph_bank_id: u8,
|
||||
pub glyph_asset_id: AssetId,
|
||||
}
|
||||
|
||||
impl CachedTileEntry {
|
||||
@ -38,7 +39,7 @@ impl CachedTileEntry {
|
||||
glyph_id: tile.glyph.glyph_id,
|
||||
palette_id: tile.glyph.palette_id,
|
||||
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,
|
||||
ring_origin_x: usize,
|
||||
ring_origin_y: usize,
|
||||
pub glyph_bank_id: u8,
|
||||
pub glyph_asset_id: AssetId,
|
||||
pub tile_size: TileSize,
|
||||
entries: Vec<CachedTileEntry>,
|
||||
pub valid: bool,
|
||||
@ -80,7 +81,7 @@ impl SceneViewportLayerCache {
|
||||
logical_origin_y: 0,
|
||||
ring_origin_x: 0,
|
||||
ring_origin_y: 0,
|
||||
glyph_bank_id: layer.glyph_bank_id,
|
||||
glyph_asset_id: layer.glyph_asset_id,
|
||||
tile_size: layer.tile_size,
|
||||
entries: vec![CachedTileEntry::default(); width * height],
|
||||
valid: false,
|
||||
@ -136,7 +137,7 @@ impl SceneViewportLayerCache {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
let max_x = region.x.saturating_add(region.width).min(self.width);
|
||||
@ -268,6 +269,7 @@ impl SceneViewportCache {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::asset::AssetId;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::scene_layer::ParallaxFactor;
|
||||
@ -278,22 +280,17 @@ mod tests {
|
||||
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();
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
tiles.push(make_tile(
|
||||
base_glyph + (y * 4 + x) as u16,
|
||||
glyph_bank_id,
|
||||
x % 2 == 0,
|
||||
y % 2 == 1,
|
||||
));
|
||||
tiles.push(make_tile(base_glyph + (y * 4 + x) as u16, 1, x % 2 == 0, y % 2 == 1));
|
||||
}
|
||||
}
|
||||
|
||||
SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id,
|
||||
glyph_asset_id,
|
||||
tile_size: TileSize::Size16,
|
||||
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
|
||||
tilemap: TileMap { width: 4, height: 4, tiles },
|
||||
@ -364,7 +361,7 @@ mod tests {
|
||||
assert!(entry.active);
|
||||
assert_eq!(entry.glyph_id, 105);
|
||||
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_y());
|
||||
}
|
||||
|
||||
@ -386,6 +386,7 @@ impl SceneViewportResolver {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::asset::AssetId;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::scene_layer::{ParallaxFactor, SceneLayer};
|
||||
@ -411,7 +412,7 @@ mod tests {
|
||||
|
||||
SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id: 1,
|
||||
glyph_asset_id: 1 as AssetId,
|
||||
tile_size,
|
||||
parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
|
||||
tilemap: TileMap { width, height, tiles },
|
||||
|
||||
@ -13,9 +13,20 @@ use prometeu_hal::{
|
||||
AudioOpStatus, ComposerOpStatus, HostContext, HostReturn, NativeInterface, SyscallId,
|
||||
expect_bool, expect_int,
|
||||
};
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
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> {
|
||||
let level = match level_val {
|
||||
0 => LogLevel::Trace,
|
||||
@ -177,11 +188,11 @@ impl NativeInterface for VirtualMachineRuntime {
|
||||
}
|
||||
};
|
||||
|
||||
let status = if hw.bind_scene(scene_bank_id) {
|
||||
ComposerOpStatus::Ok
|
||||
} else {
|
||||
ComposerOpStatus::SceneUnavailable
|
||||
};
|
||||
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 } else { ComposerOpStatus::SceneUnavailable };
|
||||
ret.push_int(status as i64);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -147,10 +147,10 @@ fn runtime_test_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) ->
|
||||
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 {
|
||||
active: true,
|
||||
glyph_bank_id,
|
||||
glyph_asset_id,
|
||||
tile_size,
|
||||
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
|
||||
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_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8)));
|
||||
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));
|
||||
|
||||
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_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8)));
|
||||
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.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_scene_bank(0, Arc::new(runtime_test_scene(0, 2, TileSize::Size8)));
|
||||
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.hud_fade_level = 31;
|
||||
|
||||
|
||||
@ -4,9 +4,20 @@ use prometeu_hal::asset::{BankTelemetry, BankType};
|
||||
use prometeu_hal::log::{LogLevel, LogSource};
|
||||
use prometeu_hal::{HardwareBridge, HostContext, InputSignals};
|
||||
use prometeu_vm::LogicalFrameEndingReason;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
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) {
|
||||
let heap_allocations =
|
||||
vm.heap().allocation_count().saturating_sub(self.frame_start_heap_allocations);
|
||||
@ -167,7 +178,18 @@ impl VirtualMachineRuntime {
|
||||
if run.reason == LogicalFrameEndingReason::FrameSync
|
||||
|| 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
|
||||
let (glyph_bank, sound_bank, scene_bank) = Self::bank_telemetry_summary(hw);
|
||||
|
||||
@ -175,6 +175,7 @@ impl HostAudio {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::items_after_test_module)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::stats::HostStats;
|
||||
|
||||
@ -54,15 +54,13 @@ impl HostInputHandler {
|
||||
self.signals.y_pos = v.1;
|
||||
}
|
||||
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
if *button == MouseButton::Left {
|
||||
match state {
|
||||
ElementState::Pressed => {
|
||||
self.signals.f_signal = true;
|
||||
}
|
||||
ElementState::Released => {
|
||||
self.signals.f_signal = false;
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } if *button == MouseButton::Left => {
|
||||
match state {
|
||||
ElementState::Pressed => {
|
||||
self.signals.f_signal = true;
|
||||
}
|
||||
ElementState::Released => {
|
||||
self.signals.f_signal = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -782,10 +782,10 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Ok(resp) = serde_json::from_str::<serde_json::Value>(&line) {
|
||||
if resp["type"] == "getState" {
|
||||
return;
|
||||
}
|
||||
if let Ok(resp) = serde_json::from_str::<serde_json::Value>(&line)
|
||||
&& resp["type"] == "getState"
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("Did not receive getState response");
|
||||
|
||||
@ -4,7 +4,7 @@ use prometeu_bytecode::model::{
|
||||
BytecodeModule, ConstantPoolEntry, DebugInfo, Export, FunctionMeta, SyscallDecl,
|
||||
};
|
||||
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,
|
||||
};
|
||||
use prometeu_hal::cartridge::{
|
||||
@ -24,6 +24,9 @@ use std::fs;
|
||||
use std::mem::size_of;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const STRESS_GLYPH_ASSET_ID: AssetId = 100;
|
||||
const STRESS_SCENE_ASSET_ID: AssetId = 1;
|
||||
|
||||
fn asm(s: &str) -> Vec<u8> {
|
||||
assemble(s).expect("assemble")
|
||||
}
|
||||
@ -223,7 +226,7 @@ fn build_assets_pack() -> Result<Vec<u8>> {
|
||||
let scene = build_scene_bank();
|
||||
let scene_payload = encode_scene_payload(&scene);
|
||||
let scene_entry = AssetEntry {
|
||||
asset_id: 1,
|
||||
asset_id: STRESS_SCENE_ASSET_ID,
|
||||
asset_name: "stress_scene".into(),
|
||||
bank_type: BankType::SCENE,
|
||||
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 preload =
|
||||
vec![PreloadEntry { asset_id: 0, slot: 0 }, PreloadEntry { asset_id: 1, slot: 0 }];
|
||||
let preload = vec![
|
||||
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 header = serde_json::to_vec(&AssetsPackHeader { asset_table, preload })?;
|
||||
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());
|
||||
|
||||
let entry = AssetEntry {
|
||||
asset_id: 0,
|
||||
asset_id: STRESS_GLYPH_ASSET_ID,
|
||||
asset_name: "stress_square".into(),
|
||||
bank_type: BankType::GLYPH,
|
||||
offset: 0,
|
||||
@ -338,7 +343,7 @@ fn build_scene_bank() -> SceneBank {
|
||||
|
||||
SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id: 0,
|
||||
glyph_asset_id: STRESS_GLYPH_ASSET_ID,
|
||||
tile_size: TileSize::Size8,
|
||||
parallax_factor: match layer_index {
|
||||
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 {
|
||||
let layer_flags = if layer.active { 0b0000_0001 } else { 0 };
|
||||
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(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.y.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.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[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[1].slot, 0);
|
||||
assert_eq!(header.asset_table[0].offset, 0);
|
||||
|
||||
@ -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-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"}]}
|
||||
@ -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-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-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-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"}]}
|
||||
|
||||
@ -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.
|
||||
@ -579,7 +579,7 @@ Fault boundary:
|
||||
| `gfx.draw_square` | `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 |
|
||||
| `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.set_camera` | `void` | no real operational failure path in v1 |
|
||||
| `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.
|
||||
|
||||
### 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`
|
||||
|
||||
`composer.emit_sprite` returns `status:int`.
|
||||
|
||||
@ -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`.
|
||||
|
||||
### 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`
|
||||
- serialized pixels use packed `u4` palette indices
|
||||
@ -118,20 +118,20 @@ For `BankType::TILES`, the v1 runtime-facing contract is:
|
||||
- `palette_count = 64`
|
||||
- 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:
|
||||
|
||||
- `RAW` is a legacy deprecated alias of `NONE`
|
||||
- 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
|
||||
|
||||
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`
|
||||
- `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.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`
|
||||
- `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
|
||||
- palette table: `64 * 16` colors in `RGB565`
|
||||
|
||||
For `TILES` v1:
|
||||
For `GLYPH` v1:
|
||||
|
||||
- `size` must match `ceil(width * height / 2) + (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
|
||||
|
||||
The current runtime exposes bank types:
|
||||
|
||||
- `GLYPH`
|
||||
- `SOUNDS`
|
||||
- `SCENE`
|
||||
|
||||
Assets are loaded into explicit slots identified by slot index at the public ABI boundary.
|
||||
|
||||
|
||||
@ -229,6 +229,12 @@ For mutating composer operations:
|
||||
- `set_camera` remains `void` in v1;
|
||||
- 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)
|
||||
|
||||
Syscalls behave like call sites, not like first-class guest values.
|
||||
|
||||
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user