Compare commits

...

22 Commits

Author SHA1 Message Date
509b669db5 Merge pull request 'dev/render-all-scene-cache-and-camera-integration' (#16) from dev/render-all-scene-cache-and-camera-integration into master
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good
Reviewed-on: #16
2026-04-18 16:20:49 +00:00
73cf96ed6c
housekeep
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/pr-master This commit looks good
2026-04-18 16:31:23 +01:00
76254928e6
stress test cart fixes
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/pr-master This commit looks good
2026-04-18 16:24:20 +01:00
1c74631a5e
implements PLN-0029 2026-04-18 09:57:49 +01:00
aaed1e95dd
implements PLN-0028 2026-04-18 09:56:50 +01:00
2865cb3803
implements PLN-0027 2026-04-18 09:55:15 +01:00
4f52a65169
implements PLN-0026 2026-04-18 09:51:30 +01:00
b0b8bb9028
primitives pipeline adjustments 2026-04-18 09:50:13 +01:00
4a5210f347
implements PLN-0025 2026-04-17 19:45:03 +01:00
3fef407efc
implements PLN-0024 2026-04-17 17:56:37 +01:00
cc700c6cf8
implements PLN-0023 2026-04-17 17:55:04 +01:00
dd90ff812c
implements PLN-0022 2026-04-17 17:49:18 +01:00
240fe65da7
frame composer - abi adjustments 2026-04-17 17:43:25 +01:00
f4260d0cf4
adjustments over frame composer contract - agnostic tile size 2026-04-17 16:36:12 +01:00
a1bd60671b
implements PLN-0021 2026-04-17 13:32:11 +01:00
5ef43045bc
implements PLN-0020 2026-04-17 13:28:34 +01:00
3931e86b41
implements PLN-0019 2026-04-17 13:25:40 +01:00
5a0476e8b0
implements PLN-0018 2026-04-17 13:24:25 +01:00
ed05f337ce
implements PLN-0017 2026-04-17 13:19:03 +01:00
94c80e61ba
adjustments over frame composer contract - agnostic tile size 2026-04-17 13:09:27 +01:00
98d2d81882
adjustments over frame composer contract - agnostic tile size
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good
Intrepid/Prometeu/Runtime/pipeline/pr-master This commit looks good
2026-04-15 08:42:46 +01:00
d5d63d79c0
Integrate render_all with Scene Cache and Camera 2026-04-14 07:28:00 +01:00
40 changed files with 2460 additions and 379 deletions

2
Cargo.lock generated
View File

@ -1475,6 +1475,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"prometeu-bytecode", "prometeu-bytecode",
"prometeu-hal",
"serde_json",
] ]
[[package]] [[package]]

View File

@ -14,7 +14,7 @@ use prometeu_hal::glyph::Glyph;
use prometeu_hal::glyph_bank::{GlyphBank, TileSize}; use prometeu_hal::glyph_bank::{GlyphBank, TileSize};
use prometeu_hal::sample::Sample; use prometeu_hal::sample::Sample;
use prometeu_hal::scene_bank::SceneBank; use prometeu_hal::scene_bank::SceneBank;
use prometeu_hal::scene_layer::{MotionFactor, SceneLayer}; use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::sound_bank::SoundBank; use prometeu_hal::sound_bank::SoundBank;
use prometeu_hal::tile::Tile; use prometeu_hal::tile::Tile;
use prometeu_hal::tilemap::TileMap; use prometeu_hal::tilemap::TileMap;
@ -748,7 +748,7 @@ impl AssetManager {
active: false, active: false,
glyph_bank_id: 0, glyph_bank_id: 0,
tile_size: TileSize::Size8, tile_size: TileSize::Size8,
motion_factor: MotionFactor { 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() },
}); });
let mut layers = layers; let mut layers = layers;
@ -770,20 +770,20 @@ impl AssetManager {
32 => TileSize::Size32, 32 => TileSize::Size32,
other => return Err(format!("Invalid SCENE tile size: {}", other)), other => return Err(format!("Invalid SCENE tile size: {}", other)),
}; };
let motion_factor_x = f32::from_le_bytes([ let parallax_factor_x = f32::from_le_bytes([
buffer[offset + 4], buffer[offset + 4],
buffer[offset + 5], buffer[offset + 5],
buffer[offset + 6], buffer[offset + 6],
buffer[offset + 7], buffer[offset + 7],
]); ]);
let motion_factor_y = f32::from_le_bytes([ 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],
]); ]);
if !motion_factor_x.is_finite() || !motion_factor_y.is_finite() { if !parallax_factor_x.is_finite() || !parallax_factor_y.is_finite() {
return Err("Invalid SCENE motion_factor".to_string()); return Err("Invalid SCENE parallax_factor".to_string());
} }
let width = u32::from_le_bytes([ let width = u32::from_le_bytes([
@ -847,7 +847,7 @@ impl AssetManager {
active: (flags & 0b0000_0001) != 0, active: (flags & 0b0000_0001) != 0,
glyph_bank_id, glyph_bank_id,
tile_size, tile_size,
motion_factor: MotionFactor { x: motion_factor_x, y: motion_factor_y }, parallax_factor: ParallaxFactor { x: parallax_factor_x, y: parallax_factor_y },
tilemap: TileMap { width, height, tiles }, tilemap: TileMap { width, height, tiles },
}; };
} }
@ -1105,7 +1105,7 @@ mod tests {
SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1, SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1,
}; };
use prometeu_hal::glyph::Glyph; use prometeu_hal::glyph::Glyph;
use prometeu_hal::scene_layer::{MotionFactor, 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;
@ -1144,11 +1144,11 @@ mod tests {
fn test_scene() -> SceneBank { fn test_scene() -> SceneBank {
let make_layer = let make_layer =
|glyph_bank_id: u8, motion_x: f32, motion_y: f32, tile_size: TileSize| SceneLayer { |glyph_bank_id: u8, parallax_x: f32, parallax_y: f32, tile_size: TileSize| SceneLayer {
active: glyph_bank_id != 3, active: glyph_bank_id != 3,
glyph_bank_id, glyph_bank_id,
tile_size, tile_size,
motion_factor: MotionFactor { x: motion_x, y: motion_y }, parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
tilemap: TileMap { tilemap: TileMap {
width: 2, width: 2,
height: 2, height: 2,
@ -1227,8 +1227,8 @@ mod tests {
data.push(layer.glyph_bank_id); data.push(layer.glyph_bank_id);
data.push(layer.tile_size as u8); data.push(layer.tile_size as u8);
data.push(0); data.push(0);
data.extend_from_slice(&layer.motion_factor.x.to_le_bytes()); data.extend_from_slice(&layer.parallax_factor.x.to_le_bytes());
data.extend_from_slice(&layer.motion_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());
data.extend_from_slice(&(layer.tilemap.height as u32).to_le_bytes()); data.extend_from_slice(&(layer.tilemap.height as u32).to_le_bytes());
data.extend_from_slice(&(layer.tilemap.tiles.len() as u32).to_le_bytes()); data.extend_from_slice(&(layer.tilemap.tiles.len() as u32).to_le_bytes());
@ -1359,7 +1359,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_bank_id, 1);
assert_eq!(decoded.layers[1].motion_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);
assert_eq!(decoded.layers[2].tilemap.tiles[2].flip_y, true); assert_eq!(decoded.layers[2].tilemap.tiles[2].flip_y, true);

View File

@ -0,0 +1,719 @@
use crate::memory_banks::SceneBankPoolAccess;
use prometeu_hal::GfxBridge;
use prometeu_hal::glyph::Glyph;
use prometeu_hal::scene_bank::SceneBank;
use prometeu_hal::scene_viewport_cache::SceneViewportCache;
use prometeu_hal::scene_viewport_resolver::{CacheRefreshRequest, SceneViewportResolver};
use prometeu_hal::sprite::Sprite;
use std::sync::Arc;
const EMPTY_SPRITE: Sprite = Sprite {
glyph: Glyph { glyph_id: 0, palette_id: 0 },
x: 0,
y: 0,
layer: 0,
bank_id: 0,
active: false,
flip_x: false,
flip_y: false,
priority: 0,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SceneStatus {
#[default]
Unbound,
Available {
scene_bank_id: usize,
},
}
#[derive(Clone, Debug)]
pub struct SpriteController {
sprites: [Sprite; 512],
sprite_count: usize,
frame_counter: u64,
dropped_sprites: usize,
layer_buckets: [Vec<usize>; 4],
}
impl Default for SpriteController {
fn default() -> Self {
Self::new()
}
}
impl SpriteController {
pub fn new() -> Self {
Self {
sprites: [EMPTY_SPRITE; 512],
sprite_count: 0,
frame_counter: 0,
dropped_sprites: 0,
layer_buckets: std::array::from_fn(|_| Vec::with_capacity(128)),
}
}
pub fn sprites(&self) -> &[Sprite; 512] {
&self.sprites
}
pub fn sprites_mut(&mut self) -> &mut [Sprite; 512] {
&mut self.sprites
}
pub fn begin_frame(&mut self) {
self.frame_counter = self.frame_counter.wrapping_add(1);
self.sprite_count = 0;
self.dropped_sprites = 0;
for bucket in &mut self.layer_buckets {
bucket.clear();
}
}
pub fn emit_sprite(&mut self, mut sprite: Sprite) -> bool {
let Some(bucket) = self.layer_buckets.get_mut(sprite.layer as usize) else {
self.dropped_sprites += 1;
return false;
};
if self.sprite_count >= self.sprites.len() {
self.dropped_sprites += 1;
return false;
}
sprite.active = true;
let index = self.sprite_count;
self.sprites[index] = sprite;
self.sprite_count += 1;
bucket.push(index);
true
}
pub fn sprite_count(&self) -> usize {
self.sprite_count
}
pub fn frame_counter(&self) -> u64 {
self.frame_counter
}
pub fn dropped_sprites(&self) -> usize {
self.dropped_sprites
}
pub fn ordered_sprites(&self) -> Vec<Sprite> {
let mut ordered = Vec::with_capacity(self.sprite_count);
for bucket in &self.layer_buckets {
let mut indices = bucket.clone();
indices.sort_by_key(|&index| self.sprites[index].priority);
for index in indices {
ordered.push(self.sprites[index]);
}
}
ordered
}
}
pub struct FrameComposer {
scene_bank_pool: Arc<dyn SceneBankPoolAccess>,
viewport_width_px: usize,
viewport_height_px: usize,
active_scene_id: Option<usize>,
active_scene: Option<Arc<SceneBank>>,
scene_status: SceneStatus,
camera_x_px: i32,
camera_y_px: i32,
cache: Option<SceneViewportCache>,
resolver: Option<SceneViewportResolver>,
sprite_controller: SpriteController,
}
impl FrameComposer {
pub fn new(
viewport_width_px: usize,
viewport_height_px: usize,
scene_bank_pool: Arc<dyn SceneBankPoolAccess>,
) -> Self {
Self {
scene_bank_pool,
viewport_width_px,
viewport_height_px,
active_scene_id: None,
active_scene: None,
scene_status: SceneStatus::Unbound,
camera_x_px: 0,
camera_y_px: 0,
cache: None,
resolver: None,
sprite_controller: SpriteController::new(),
}
}
pub fn viewport_size(&self) -> (usize, usize) {
(self.viewport_width_px, self.viewport_height_px)
}
pub fn scene_bank_pool(&self) -> &Arc<dyn SceneBankPoolAccess> {
&self.scene_bank_pool
}
pub fn scene_bank_slot(&self, slot: usize) -> Option<Arc<SceneBank>> {
self.scene_bank_pool.scene_bank_slot(slot)
}
pub fn scene_bank_slot_count(&self) -> usize {
self.scene_bank_pool.scene_bank_slot_count()
}
pub fn active_scene_id(&self) -> Option<usize> {
self.active_scene_id
}
pub fn active_scene(&self) -> Option<&Arc<SceneBank>> {
self.active_scene.as_ref()
}
pub fn scene_status(&self) -> SceneStatus {
self.scene_status
}
pub fn camera(&self) -> (i32, i32) {
(self.camera_x_px, self.camera_y_px)
}
pub fn bind_scene(&mut self, scene_bank_id: usize) -> bool {
let Some(scene) = self.scene_bank_pool.scene_bank_slot(scene_bank_id) else {
self.unbind_scene();
return false;
};
let (cache, resolver) =
Self::build_scene_runtime(self.viewport_width_px, self.viewport_height_px, &scene);
self.active_scene_id = Some(scene_bank_id);
self.active_scene = Some(scene);
self.scene_status = SceneStatus::Available { scene_bank_id };
self.cache = Some(cache);
self.resolver = Some(resolver);
true
}
pub fn unbind_scene(&mut self) {
self.active_scene_id = None;
self.active_scene = None;
self.scene_status = SceneStatus::Unbound;
self.cache = None;
self.resolver = None;
}
pub fn set_camera(&mut self, x: i32, y: i32) {
self.camera_x_px = x;
self.camera_y_px = y;
}
pub fn cache(&self) -> Option<&SceneViewportCache> {
self.cache.as_ref()
}
pub fn resolver(&self) -> Option<&SceneViewportResolver> {
self.resolver.as_ref()
}
pub fn sprite_controller(&self) -> &SpriteController {
&self.sprite_controller
}
pub fn sprite_controller_mut(&mut self) -> &mut SpriteController {
&mut self.sprite_controller
}
pub fn begin_frame(&mut self) {
self.sprite_controller.begin_frame();
}
pub fn emit_sprite(&mut self, sprite: Sprite) -> bool {
self.sprite_controller.emit_sprite(sprite)
}
pub fn ordered_sprites(&self) -> Vec<Sprite> {
self.sprite_controller.ordered_sprites()
}
pub fn render_frame(&mut self, gfx: &mut dyn GfxBridge) {
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())
{
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);
return;
}
// No-scene frames still stop at canonical game composition. Final
// overlay/debug work remains outside `FrameComposer`.
gfx.render_no_scene_frame();
}
fn build_scene_runtime(
viewport_width_px: usize,
viewport_height_px: usize,
scene: &SceneBank,
) -> (SceneViewportCache, SceneViewportResolver) {
let min_tile_px =
scene.layers.iter().map(|layer| layer.tile_size as usize).min().unwrap_or(8);
let cache_width_tiles = viewport_width_px.div_ceil(min_tile_px) + 5;
let cache_height_tiles = viewport_height_px.div_ceil(min_tile_px) + 4;
let hysteresis_safe_px = min_tile_px.saturating_sub(4) as i32;
let hysteresis_trigger_px = (min_tile_px + 4) as i32;
(
SceneViewportCache::new(scene, cache_width_tiles, cache_height_tiles),
SceneViewportResolver::new(
viewport_width_px as i32,
viewport_height_px as i32,
cache_width_tiles,
cache_height_tiles,
hysteresis_safe_px,
hysteresis_trigger_px,
),
)
}
fn apply_refresh_requests(
cache: &mut SceneViewportCache,
scene: &SceneBank,
refresh_requests: &[CacheRefreshRequest],
) {
for request in refresh_requests {
match *request {
CacheRefreshRequest::InvalidateLayer { layer_index } => {
cache.layers[layer_index].invalidate_all();
}
CacheRefreshRequest::RefreshLine { layer_index, cache_y } => {
cache.refresh_layer_line(scene, layer_index, cache_y);
}
CacheRefreshRequest::RefreshColumn { layer_index, cache_x } => {
cache.refresh_layer_column(scene, layer_index, cache_x);
}
CacheRefreshRequest::RefreshRegion { layer_index, region } => {
cache.refresh_layer_region(scene, layer_index, region);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gfx::Gfx;
use crate::memory_banks::{
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller,
};
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;
fn make_scene() -> SceneBank {
make_scene_with_palette(1, 1, TileSize::Size8)
}
fn make_scene_with_palette(
glyph_bank_id: u8,
palette_id: u8,
tile_size: TileSize,
) -> SceneBank {
let layer = SceneLayer {
active: true,
glyph_bank_id,
tile_size,
parallax_factor: ParallaxFactor { x: 1.0, y: 0.5 },
tilemap: TileMap {
width: 2,
height: 2,
tiles: vec![
Tile {
active: true,
glyph: Glyph { glyph_id: 0, palette_id },
flip_x: false,
flip_y: false,
};
4
],
},
};
SceneBank { layers: std::array::from_fn(|_| layer.clone()) }
}
fn make_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank {
let size = tile_size as usize;
let mut bank = GlyphBank::new(tile_size, size, size);
bank.palettes[palette_id as usize][1] = color;
for pixel in &mut bank.pixel_indices {
*pixel = 1;
}
bank
}
#[test]
fn frame_composer_starts_unbound_with_empty_owned_state() {
let frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()));
assert_eq!(frame_composer.viewport_size(), (320, 180));
assert_eq!(frame_composer.active_scene_id(), None);
assert!(frame_composer.active_scene().is_none());
assert_eq!(frame_composer.scene_status(), SceneStatus::Unbound);
assert_eq!(frame_composer.camera(), (0, 0));
assert!(frame_composer.cache().is_none());
assert!(frame_composer.resolver().is_none());
assert_eq!(frame_composer.sprite_controller().sprites().len(), 512);
assert_eq!(frame_composer.sprite_controller().sprite_count(), 0);
assert_eq!(frame_composer.sprite_controller().dropped_sprites(), 0);
}
#[test]
fn frame_composer_exposes_shared_scene_bank_access() {
let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(3, Arc::new(make_scene()));
let frame_composer = FrameComposer::new(320, 180, banks);
let scene =
frame_composer.scene_bank_slot(3).expect("scene bank slot 3 should be resident");
assert_eq!(frame_composer.scene_bank_slot_count(), 16);
assert_eq!(scene.layers[0].tile_size, TileSize::Size8);
assert_eq!(scene.layers[0].parallax_factor.y, 0.5);
}
#[test]
fn bind_scene_stores_scene_identity_and_shared_reference() {
let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(3, Arc::new(make_scene()));
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);
assert!(frame_composer.bind_scene(3));
assert_eq!(frame_composer.active_scene_id(), Some(3));
assert!(Arc::ptr_eq(
frame_composer.active_scene().expect("active scene should exist"),
&expected_scene,
));
assert_eq!(frame_composer.scene_status(), SceneStatus::Available { scene_bank_id: 3 });
assert!(frame_composer.cache().is_some());
assert!(frame_composer.resolver().is_some());
}
#[test]
fn unbind_scene_clears_scene_and_cache_state() {
let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(1, Arc::new(make_scene()));
let mut frame_composer = FrameComposer::new(320, 180, banks);
assert!(frame_composer.bind_scene(1));
frame_composer.unbind_scene();
assert_eq!(frame_composer.active_scene_id(), None);
assert!(frame_composer.active_scene().is_none());
assert_eq!(frame_composer.scene_status(), SceneStatus::Unbound);
assert!(frame_composer.cache().is_none());
assert!(frame_composer.resolver().is_none());
}
#[test]
fn set_camera_stores_top_left_pixel_coordinates() {
let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()));
frame_composer.set_camera(-12, 48);
assert_eq!(frame_composer.camera(), (-12, 48));
}
#[test]
fn bind_scene_derives_cache_and_resolver_from_eight_pixel_layers() {
let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(0, Arc::new(make_scene()));
let mut frame_composer = FrameComposer::new(320, 180, banks);
assert!(frame_composer.bind_scene(0));
let cache = frame_composer.cache().expect("cache should exist for bound scene");
assert_eq!((cache.width(), cache.height()), (45, 27));
}
#[test]
fn missing_scene_binding_falls_back_to_no_scene_state() {
let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()));
assert!(!frame_composer.bind_scene(7));
assert_eq!(frame_composer.scene_status(), SceneStatus::Unbound);
assert!(frame_composer.cache().is_none());
assert!(frame_composer.resolver().is_none());
}
#[test]
fn sprite_controller_begin_frame_resets_sprite_count_and_buckets() {
let mut controller = SpriteController::new();
let emitted = controller.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 1, palette_id: 2 },
x: 4,
y: 5,
layer: 2,
bank_id: 3,
active: false,
flip_x: false,
flip_y: false,
priority: 1,
});
assert!(emitted);
controller.begin_frame();
assert_eq!(controller.frame_counter(), 1);
assert_eq!(controller.sprite_count(), 0);
assert_eq!(controller.dropped_sprites(), 0);
assert!(controller.ordered_sprites().is_empty());
}
#[test]
fn sprite_controller_orders_by_layer_then_priority_then_fifo() {
let mut controller = SpriteController::new();
controller.begin_frame();
assert!(controller.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 10, palette_id: 0 },
x: 0,
y: 0,
layer: 1,
bank_id: 0,
active: false,
flip_x: false,
flip_y: false,
priority: 2,
}));
assert!(controller.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 11, palette_id: 0 },
x: 0,
y: 0,
layer: 0,
bank_id: 0,
active: false,
flip_x: false,
flip_y: false,
priority: 3,
}));
assert!(controller.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 12, palette_id: 0 },
x: 0,
y: 0,
layer: 1,
bank_id: 0,
active: false,
flip_x: false,
flip_y: false,
priority: 1,
}));
assert!(controller.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 13, palette_id: 0 },
x: 0,
y: 0,
layer: 1,
bank_id: 0,
active: false,
flip_x: false,
flip_y: false,
priority: 2,
}));
let ordered = controller.ordered_sprites();
let ordered_ids: Vec<u16> = ordered.iter().map(|sprite| sprite.glyph.glyph_id).collect();
assert_eq!(ordered_ids, vec![11, 12, 10, 13]);
assert!(ordered.iter().all(|sprite| sprite.active));
}
#[test]
fn sprite_controller_drops_overflow_without_panicking() {
let mut controller = SpriteController::new();
controller.begin_frame();
for glyph_id in 0..512 {
assert!(controller.emit_sprite(Sprite {
glyph: Glyph { glyph_id, palette_id: 0 },
x: 0,
y: 0,
layer: 0,
bank_id: 0,
active: false,
flip_x: false,
flip_y: false,
priority: 0,
}));
}
let overflowed = controller.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 999, palette_id: 0 },
x: 0,
y: 0,
layer: 0,
bank_id: 0,
active: false,
flip_x: false,
flip_y: false,
priority: 0,
});
assert!(!overflowed);
assert_eq!(controller.sprite_count(), 512);
assert_eq!(controller.dropped_sprites(), 1);
}
#[test]
fn frame_composer_emits_ordered_sprites_for_rendering() {
let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()));
frame_composer.begin_frame();
assert!(frame_composer.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 21, palette_id: 0 },
x: 0,
y: 0,
layer: 2,
bank_id: 1,
active: false,
flip_x: false,
flip_y: false,
priority: 1,
}));
assert!(frame_composer.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 20, palette_id: 0 },
x: 0,
y: 0,
layer: 1,
bank_id: 1,
active: false,
flip_x: false,
flip_y: false,
priority: 0,
}));
let ordered = frame_composer.ordered_sprites();
assert_eq!(ordered.len(), 2);
assert_eq!(ordered[0].glyph.glyph_id, 20);
assert_eq!(ordered[1].glyph.glyph_id, 21);
}
#[test]
fn render_frame_without_scene_uses_sprite_only_path() {
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>);
frame_composer.begin_frame();
assert!(frame_composer.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 0, palette_id: 3 },
x: 0,
y: 0,
layer: 0,
bank_id: 1,
active: false,
flip_x: false,
flip_y: false,
priority: 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::WHITE.raw());
}
#[test]
fn render_frame_with_scene_applies_refreshes_before_composition() {
let banks = Arc::new(MemoryBanks::new());
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>);
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!(
frame_composer
.cache()
.expect("cache should exist")
.layers
.iter()
.all(|layer| layer.valid)
);
assert_eq!(gfx.front_buffer()[0], Color::BLUE.raw());
}
#[test]
fn render_frame_survives_scene_transition_through_unbind_and_rebind() {
let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(0, Arc::new(make_glyph_bank(TileSize::Size8, 1, Color::RED)));
banks.install_glyph_bank(1, Arc::new(make_glyph_bank(TileSize::Size8, 2, Color::BLUE)));
banks.install_glyph_bank(2, Arc::new(make_glyph_bank(TileSize::Size8, 3, Color::WHITE)));
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 gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>);
gfx.scene_fade_level = 31;
gfx.hud_fade_level = 31;
assert!(frame_composer.bind_scene(0));
frame_composer.render_frame(&mut gfx);
gfx.present();
assert_eq!(gfx.front_buffer()[0], Color::RED.raw());
frame_composer.unbind_scene();
frame_composer.begin_frame();
assert!(frame_composer.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 0, palette_id: 3 },
x: 0,
y: 0,
layer: 0,
bank_id: 2,
active: false,
flip_x: false,
flip_y: false,
priority: 0,
}));
frame_composer.render_frame(&mut gfx);
gfx.present();
assert_eq!(gfx.front_buffer()[0], Color::WHITE.raw());
frame_composer.begin_frame();
assert!(frame_composer.bind_scene(1));
frame_composer.render_frame(&mut gfx);
gfx.present();
assert_eq!(gfx.front_buffer()[0], Color::BLUE.raw());
}
}

View File

@ -1,3 +1,4 @@
use crate::gfx_overlay::{DeferredGfxOverlay, OverlayCommand};
use crate::memory_banks::GlyphBankPoolAccess; use crate::memory_banks::GlyphBankPoolAccess;
use prometeu_hal::GfxBridge; use prometeu_hal::GfxBridge;
use prometeu_hal::color::Color; use prometeu_hal::color::Color;
@ -31,22 +32,11 @@ pub enum BlendMode {
/// PROMETEU Graphics Subsystem (GFX). /// PROMETEU Graphics Subsystem (GFX).
/// ///
/// Models a specialized graphics chip with a fixed resolution, double buffering, /// `Gfx` owns the framebuffer backend and the canonical game-frame raster path
/// and a multi-layered tile/sprite architecture. /// consumed by `FrameComposer`. That canonical path covers scene composition,
/// /// sprite composition, and fades. Public `gfx.*` primitives remain valid, but
/// The GFX system works by composing several "layers" into a single 16-bit /// they do not define the canonical game composition contract; they belong to a
/// RGB565 framebuffer. It supports hardware-accelerated primitives (lines, rects) /// separate final overlay/debug stage.
/// and specialized console features like background scrolling and sprite sorting.
///
/// ### Layer Composition Order (back to front):
/// 1. **Priority 0 Sprites**: Objects behind everything else.
/// 2. **Tile Layer 0 + Priority 1 Sprites**: Background 0.
/// 3. **Tile Layer 1 + Priority 2 Sprites**: Background 1.
/// 4. **Tile Layer 2 + Priority 3 Sprites**: Background 2.
/// 5. **Tile Layer 3 + Priority 4 Sprites**: Foreground.
/// 6. **Scene Fade**: Global brightness/color filter.
/// 7. **HUD Layer**: Fixed UI elements (always on top).
/// 8. **HUD Fade**: Independent fade for the UI.
pub struct Gfx { pub struct Gfx {
/// Width of the internal framebuffer in pixels. /// Width of the internal framebuffer in pixels.
w: usize, w: usize,
@ -54,11 +44,16 @@ pub struct Gfx {
h: usize, h: usize,
/// Front buffer: the "VRAM" currently being displayed by the Host window. /// Front buffer: the "VRAM" currently being displayed by the Host window.
front: Vec<u16>, front: Vec<u16>,
/// Back buffer: the "Work RAM" where new frames are composed. /// Back buffer: the working buffer where canonical game frames are composed
/// before any final overlay/debug drain.
back: Vec<u16>, back: Vec<u16>,
/// Shared access to graphical memory banks (tiles and palettes). /// Shared access to graphical memory banks (tiles and palettes).
pub glyph_banks: Arc<dyn GlyphBankPoolAccess>, pub glyph_banks: Arc<dyn GlyphBankPoolAccess>,
/// Deferred overlay/debug capture kept separate from canonical game composition.
overlay: DeferredGfxOverlay,
/// Internal guard to replay deferred overlay commands without re-enqueueing them.
is_draining_overlay: bool,
/// Hardware sprite list (512 slots). Equivalent to OAM (Object Attribute Memory). /// Hardware sprite list (512 slots). Equivalent to OAM (Object Attribute Memory).
pub sprites: [Sprite; 512], pub sprites: [Sprite; 512],
@ -71,12 +66,29 @@ pub struct Gfx {
/// Target color for the HUD fade effect. /// Target color for the HUD fade effect.
pub hud_fade_color: Color, pub hud_fade_color: Color,
/// Internal cache used to sort sprites into priority groups to optimize rendering. /// Internal sprite count for the current frame state.
priority_buckets: [Vec<usize>; 5], sprite_count: usize,
/// Internal cache used to sort sprites by layer while keeping stable priority order.
layer_buckets: [Vec<usize>; 4],
} }
const GLYPH_UNKNOWN: [u8; 5] = [0x7, 0x7, 0x7, 0x7, 0x7]; const GLYPH_UNKNOWN: [u8; 5] = [0x7, 0x7, 0x7, 0x7, 0x7];
struct RenderTarget<'a> {
back: &'a mut [u16],
screen_w: usize,
screen_h: usize,
}
#[derive(Clone, Copy)]
struct CachedTileDraw<'a> {
x: i32,
y: i32,
entry: CachedTileEntry,
bank: &'a GlyphBank,
tile_size: prometeu_hal::glyph_bank::TileSize,
}
#[inline] #[inline]
fn glyph_for_char(c: char) -> &'static [u8; 5] { fn glyph_for_char(c: char) -> &'static [u8; 5] {
match c.to_ascii_uppercase() { match c.to_ascii_uppercase() {
@ -207,12 +219,15 @@ impl GfxBridge for Gfx {
fn present(&mut self) { fn present(&mut self) {
self.present() self.present()
} }
fn render_all(&mut self) { fn render_no_scene_frame(&mut self) {
self.render_all() self.render_no_scene_frame()
} }
fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) { fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) {
self.render_scene_from_cache(cache, update) self.render_scene_from_cache(cache, update)
} }
fn load_frame_sprites(&mut self, sprites: &[Sprite]) {
self.load_frame_sprites(sprites)
}
fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color) { fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color) {
self.draw_text(x, y, text, color) self.draw_text(x, y, text, color)
} }
@ -224,6 +239,7 @@ impl GfxBridge for Gfx {
&self.sprites[index] &self.sprites[index]
} }
fn sprite_mut(&mut self, index: usize) -> &mut Sprite { fn sprite_mut(&mut self, index: usize) -> &mut Sprite {
self.sprite_count = self.sprite_count.max(index.saturating_add(1)).min(self.sprites.len());
&mut self.sprites[index] &mut self.sprites[index]
} }
@ -262,6 +278,7 @@ impl Gfx {
glyph: EMPTY_GLYPH, glyph: EMPTY_GLYPH,
x: 0, x: 0,
y: 0, y: 0,
layer: 0,
bank_id: 0, bank_id: 0,
active: false, active: false,
flip_x: false, flip_x: false,
@ -276,13 +293,15 @@ impl Gfx {
front: vec![0; len], front: vec![0; len],
back: vec![0; len], back: vec![0; len],
glyph_banks, glyph_banks,
overlay: DeferredGfxOverlay::default(),
is_draining_overlay: false,
sprites: [EMPTY_SPRITE; 512], sprites: [EMPTY_SPRITE; 512],
sprite_count: 0,
scene_fade_level: 31, scene_fade_level: 31,
scene_fade_color: Color::BLACK, scene_fade_color: Color::BLACK,
hud_fade_level: 31, hud_fade_level: 31,
hud_fade_color: Color::BLACK, hud_fade_color: Color::BLACK,
priority_buckets: [ layer_buckets: [
Vec::with_capacity(128),
Vec::with_capacity(128), Vec::with_capacity(128),
Vec::with_capacity(128), Vec::with_capacity(128),
Vec::with_capacity(128), Vec::with_capacity(128),
@ -295,6 +314,42 @@ impl Gfx {
(self.w, self.h) (self.w, self.h)
} }
pub fn begin_overlay_frame(&mut self) {
self.overlay.begin_frame();
}
pub fn overlay(&self) -> &DeferredGfxOverlay {
&self.overlay
}
pub fn drain_overlay_debug(&mut self) {
let commands = self.overlay.take_commands();
self.is_draining_overlay = true;
for command in commands {
match command {
OverlayCommand::FillRectBlend { x, y, w, h, color, mode } => {
self.fill_rect_blend(x, y, w, h, color, mode)
}
OverlayCommand::DrawLine { x0, y0, x1, y1, color } => {
self.draw_line(x0, y0, x1, y1, color)
}
OverlayCommand::DrawCircle { x, y, r, color } => self.draw_circle(x, y, r, color),
OverlayCommand::DrawDisc { x, y, r, border_color, fill_color } => {
self.draw_disc(x, y, r, border_color, fill_color)
}
OverlayCommand::DrawSquare { x, y, w, h, border_color, fill_color } => {
self.draw_square(x, y, w, h, border_color, fill_color)
}
OverlayCommand::DrawText { x, y, text, color } => {
self.draw_text(x, y, &text, color)
}
}
}
self.is_draining_overlay = false;
}
/// The buffer that the host should display (RGB565). /// The buffer that the host should display (RGB565).
pub fn front_buffer(&self) -> &[u16] { pub fn front_buffer(&self) -> &[u16] {
&self.front &self.front
@ -314,6 +369,10 @@ impl Gfx {
color: Color, color: Color,
mode: BlendMode, mode: BlendMode,
) { ) {
if !self.is_draining_overlay {
self.overlay.push(OverlayCommand::FillRectBlend { x, y, w, h, color, mode });
return;
}
if color == Color::COLOR_KEY { if color == Color::COLOR_KEY {
return; return;
} }
@ -355,6 +414,10 @@ impl Gfx {
/// Draws a line between two points using Bresenham's algorithm. /// Draws a line between two points using Bresenham's algorithm.
pub fn draw_line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: Color) { pub fn draw_line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: Color) {
if !self.is_draining_overlay {
self.overlay.push(OverlayCommand::DrawLine { x0, y0, x1, y1, color });
return;
}
if color == Color::COLOR_KEY { if color == Color::COLOR_KEY {
return; return;
} }
@ -387,6 +450,10 @@ impl Gfx {
/// Draws a circle outline using Midpoint Circle Algorithm. /// Draws a circle outline using Midpoint Circle Algorithm.
pub fn draw_circle(&mut self, xc: i32, yc: i32, r: i32, color: Color) { pub fn draw_circle(&mut self, xc: i32, yc: i32, r: i32, color: Color) {
if !self.is_draining_overlay {
self.overlay.push(OverlayCommand::DrawCircle { x: xc, y: yc, r, color });
return;
}
if color == Color::COLOR_KEY { if color == Color::COLOR_KEY {
return; return;
} }
@ -455,6 +522,10 @@ impl Gfx {
/// Draws a disc (filled circle with border). /// Draws a disc (filled circle with border).
pub fn draw_disc(&mut self, x: i32, y: i32, r: i32, border_color: Color, fill_color: Color) { pub fn draw_disc(&mut self, x: i32, y: i32, r: i32, border_color: Color, fill_color: Color) {
if !self.is_draining_overlay {
self.overlay.push(OverlayCommand::DrawDisc { x, y, r, border_color, fill_color });
return;
}
self.fill_circle(x, y, r, fill_color); self.fill_circle(x, y, r, fill_color);
self.draw_circle(x, y, r, border_color); self.draw_circle(x, y, r, border_color);
} }
@ -484,6 +555,10 @@ impl Gfx {
border_color: Color, border_color: Color,
fill_color: Color, fill_color: Color,
) { ) {
if !self.is_draining_overlay {
self.overlay.push(OverlayCommand::DrawSquare { x, y, w, h, border_color, fill_color });
return;
}
self.fill_rect(x, y, w, h, fill_color); self.fill_rect(x, y, w, h, fill_color);
self.draw_rect(x, y, w, h, border_color); self.draw_rect(x, y, w, h, border_color);
} }
@ -530,23 +605,33 @@ impl Gfx {
std::mem::swap(&mut self.front, &mut self.back); std::mem::swap(&mut self.front, &mut self.back);
} }
pub fn load_frame_sprites(&mut self, sprites: &[Sprite]) {
self.sprite_count = sprites.len().min(self.sprites.len());
for (index, sprite) in sprites.iter().copied().take(self.sprites.len()).enumerate() {
self.sprites[index] = Sprite { active: true, ..sprite };
}
for sprite in self.sprites.iter_mut().skip(self.sprite_count) {
sprite.active = false;
}
}
/// The main rendering pipeline. /// The main rendering pipeline.
/// ///
/// This method composes the final frame by rasterizing layers and sprites in the /// This method composes the final frame by rasterizing layers and sprites in the
/// correct priority order into the back buffer. /// correct priority order into the back buffer.
/// Follows the hardware model where layers and sprites are composed every frame. /// Follows the hardware model where layers and sprites are composed every frame.
pub fn render_all(&mut self) { pub fn render_no_scene_frame(&mut self) {
self.populate_priority_buckets(); self.populate_layer_buckets();
for bucket in &self.layer_buckets {
// 1. Priority 0 sprites: drawn at the very back, behind everything else. Self::draw_bucket_on_buffer(
Self::draw_bucket_on_buffer( &mut self.back,
&mut self.back, self.w,
self.w, self.h,
self.h, bucket,
&self.priority_buckets[0], &self.sprites,
&self.sprites, &*self.glyph_banks,
&*self.glyph_banks, );
); }
// 2. Scene-only fallback path: sprites and fades still work even before a // 2. Scene-only fallback path: sprites and fades still work even before a
// cache-backed world composition request is issued for the frame. // cache-backed world composition request is issued for the frame.
@ -563,18 +648,17 @@ impl Gfx {
/// 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) {
self.back.fill(Color::BLACK.raw()); self.back.fill(Color::BLACK.raw());
self.populate_priority_buckets(); self.populate_layer_buckets();
Self::draw_bucket_on_buffer(
&mut self.back,
self.w,
self.h,
&self.priority_buckets[0],
&self.sprites,
&*self.glyph_banks,
);
for layer_index in 0..cache.layers.len() { for layer_index in 0..cache.layers.len() {
Self::draw_bucket_on_buffer(
&mut self.back,
self.w,
self.h,
&self.layer_buckets[layer_index],
&self.sprites,
&*self.glyph_banks,
);
Self::draw_cache_layer_to_buffer( Self::draw_cache_layer_to_buffer(
&mut self.back, &mut self.back,
self.w, self.w,
@ -583,31 +667,26 @@ impl Gfx {
&update.copy_requests[layer_index], &update.copy_requests[layer_index],
&*self.glyph_banks, &*self.glyph_banks,
); );
Self::draw_bucket_on_buffer(
&mut self.back,
self.w,
self.h,
&self.priority_buckets[layer_index + 1],
&self.sprites,
&*self.glyph_banks,
);
} }
Self::apply_fade_to_buffer(&mut self.back, self.scene_fade_level, self.scene_fade_color); Self::apply_fade_to_buffer(&mut self.back, self.scene_fade_level, self.scene_fade_color);
Self::apply_fade_to_buffer(&mut self.back, self.hud_fade_level, self.hud_fade_color); Self::apply_fade_to_buffer(&mut self.back, self.hud_fade_level, self.hud_fade_color);
} }
fn populate_priority_buckets(&mut self) { fn populate_layer_buckets(&mut self) {
for bucket in self.priority_buckets.iter_mut() { for bucket in self.layer_buckets.iter_mut() {
bucket.clear(); bucket.clear();
} }
for (idx, sprite) in self.sprites.iter().enumerate() { for (idx, sprite) in self.sprites.iter().take(self.sprite_count).enumerate() {
if sprite.active && sprite.priority < 5 { if sprite.active && (sprite.layer as usize) < self.layer_buckets.len() {
self.priority_buckets[sprite.priority as usize].push(idx); self.layer_buckets[sprite.layer as usize].push(idx);
} }
} }
for bucket in self.layer_buckets.iter_mut() {
bucket.sort_by_key(|&idx| self.sprites[idx].priority);
}
} }
fn draw_cache_layer_to_buffer( fn draw_cache_layer_to_buffer(
@ -618,6 +697,7 @@ impl Gfx {
request: &LayerCopyRequest, request: &LayerCopyRequest,
glyph_banks: &dyn GlyphBankPoolAccess, glyph_banks: &dyn GlyphBankPoolAccess,
) { ) {
let mut target = RenderTarget { back, screen_w, screen_h };
let layer_cache = &cache.layers[request.layer_index]; let layer_cache = &cache.layers[request.layer_index];
if !layer_cache.valid { if !layer_cache.valid {
return; return;
@ -646,52 +726,43 @@ impl Gfx {
} }
Self::draw_cached_tile_pixels( Self::draw_cached_tile_pixels(
back, &mut target,
screen_w, CachedTileDraw {
screen_h, x: screen_tile_x,
screen_tile_x, y: screen_tile_y,
screen_tile_y, entry,
entry, bank: &bank,
&bank, tile_size: request.tile_size,
request.tile_size, },
); );
} }
} }
} }
fn draw_cached_tile_pixels( fn draw_cached_tile_pixels(target: &mut RenderTarget<'_>, tile: CachedTileDraw<'_>) {
back: &mut [u16], let size = tile.tile_size as usize;
screen_w: usize,
screen_h: usize,
x: i32,
y: i32,
entry: CachedTileEntry,
bank: &GlyphBank,
tile_size: prometeu_hal::glyph_bank::TileSize,
) {
let size = tile_size as usize;
for local_y in 0..size { for local_y in 0..size {
let world_y = y + local_y as i32; let world_y = tile.y + local_y as i32;
if world_y < 0 || world_y >= screen_h as i32 { if world_y < 0 || world_y >= target.screen_h as i32 {
continue; continue;
} }
for local_x in 0..size { for local_x in 0..size {
let world_x = x + local_x as i32; let world_x = tile.x + local_x as i32;
if world_x < 0 || world_x >= screen_w as i32 { if world_x < 0 || world_x >= target.screen_w as i32 {
continue; continue;
} }
let fetch_x = if entry.flip_x() { size - 1 - local_x } else { local_x }; let fetch_x = if tile.entry.flip_x() { size - 1 - local_x } else { local_x };
let fetch_y = if entry.flip_y() { size - 1 - local_y } else { local_y }; let fetch_y = if tile.entry.flip_y() { size - 1 - local_y } else { local_y };
let px_index = bank.get_pixel_index(entry.glyph_id, fetch_x, fetch_y); let px_index = tile.bank.get_pixel_index(tile.entry.glyph_id, fetch_x, fetch_y);
if px_index == 0 { if px_index == 0 {
continue; continue;
} }
let color = bank.resolve_color(entry.palette_id, px_index); let color = tile.bank.resolve_color(tile.entry.palette_id, px_index);
back[world_y as usize * screen_w + world_x as usize] = color.raw(); target.back[world_y as usize * target.screen_w + world_x as usize] = color.raw();
} }
} }
} }
@ -775,6 +846,10 @@ impl Gfx {
} }
pub fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color) { pub fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color) {
if !self.is_draining_overlay {
self.overlay.push(OverlayCommand::DrawText { x, y, text: text.to_string(), color });
return;
}
let mut cx = x; let mut cx = x;
for c in text.chars() { for c in text.chars() {
self.draw_char(cx, y, c, color); self.draw_char(cx, y, c, color);
@ -819,10 +894,11 @@ impl Gfx {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::memory_banks::{GlyphBankPoolInstaller, MemoryBanks}; use crate::FrameComposer;
use crate::memory_banks::{GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess};
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::{MotionFactor, SceneLayer}; use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::scene_viewport_cache::SceneViewportCache; use prometeu_hal::scene_viewport_cache::SceneViewportCache;
use prometeu_hal::scene_viewport_resolver::SceneViewportResolver; use prometeu_hal::scene_viewport_resolver::SceneViewportResolver;
use prometeu_hal::tile::Tile; use prometeu_hal::tile::Tile;
@ -853,7 +929,7 @@ mod tests {
active: true, active: true,
glyph_bank_id, glyph_bank_id,
tile_size: TileSize::Size8, tile_size: TileSize::Size8,
motion_factor: MotionFactor { x: 1.0, y: 1.0 }, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap { tilemap: TileMap {
width, width,
height, height,
@ -875,7 +951,7 @@ mod tests {
active: false, active: false,
glyph_bank_id, glyph_bank_id,
tile_size: TileSize::Size8, tile_size: TileSize::Size8,
motion_factor: MotionFactor { 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] },
} }
} }
@ -918,9 +994,12 @@ mod tests {
fn test_draw_line() { fn test_draw_line() {
let banks = Arc::new(MemoryBanks::new()); let banks = Arc::new(MemoryBanks::new());
let mut gfx = Gfx::new(10, 10, banks); let mut gfx = Gfx::new(10, 10, banks);
gfx.begin_overlay_frame();
gfx.draw_line(0, 0, 9, 9, Color::WHITE); gfx.draw_line(0, 0, 9, 9, Color::WHITE);
gfx.drain_overlay_debug();
assert_eq!(gfx.back[0], Color::WHITE.0); assert_eq!(gfx.back[0], Color::WHITE.0);
assert_eq!(gfx.back[9 * 10 + 9], Color::WHITE.0); assert_eq!(gfx.back[9 * 10 + 9], Color::WHITE.0);
assert_eq!(gfx.overlay().command_count(), 0);
} }
#[test] #[test]
@ -946,11 +1025,54 @@ mod tests {
fn test_draw_square() { fn test_draw_square() {
let banks = Arc::new(MemoryBanks::new()); let banks = Arc::new(MemoryBanks::new());
let mut gfx = Gfx::new(10, 10, banks); let mut gfx = Gfx::new(10, 10, banks);
gfx.begin_overlay_frame();
gfx.draw_square(2, 2, 6, 6, Color::WHITE, Color::BLACK); gfx.draw_square(2, 2, 6, 6, Color::WHITE, Color::BLACK);
gfx.drain_overlay_debug();
// Border // Border
assert_eq!(gfx.back[2 * 10 + 2], Color::WHITE.0); assert_eq!(gfx.back[2 * 10 + 2], Color::WHITE.0);
// Fill // Fill
assert_eq!(gfx.back[3 * 10 + 3], Color::BLACK.0); assert_eq!(gfx.back[3 * 10 + 3], Color::BLACK.0);
assert_eq!(gfx.overlay().command_count(), 0);
}
#[test]
fn draw_text_captures_overlay_command() {
let banks = Arc::new(MemoryBanks::new());
let mut gfx = Gfx::new(32, 18, banks);
gfx.begin_overlay_frame();
gfx.draw_text(4, 5, "HUD", Color::WHITE);
assert_eq!(
gfx.overlay().commands(),
&[OverlayCommand::DrawText { x: 4, y: 5, text: "HUD".into(), color: Color::WHITE }]
);
}
#[test]
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>);
gfx.begin_overlay_frame();
frame_composer.begin_frame();
frame_composer.emit_sprite(Sprite {
glyph: Glyph { glyph_id: 0, palette_id: 0 },
x: 1,
y: 2,
layer: 0,
bank_id: 0,
active: true,
flip_x: false,
flip_y: false,
priority: 0,
});
gfx.draw_text(1, 1, "X", Color::WHITE);
assert_eq!(frame_composer.sprite_controller().sprite_count(), 1);
assert_eq!(gfx.overlay().command_count(), 1);
} }
#[test] #[test]
@ -1004,6 +1126,7 @@ mod tests {
glyph: Glyph { glyph_id: 0, palette_id: 4 }, glyph: Glyph { glyph_id: 0, palette_id: 4 },
x: 0, x: 0,
y: 0, y: 0,
layer: 0,
bank_id: 0, bank_id: 0,
active: true, active: true,
flip_x: false, flip_x: false,
@ -1014,17 +1137,57 @@ mod tests {
glyph: Glyph { glyph_id: 0, palette_id: 4 }, glyph: Glyph { glyph_id: 0, palette_id: 4 },
x: 0, x: 0,
y: 0, y: 0,
layer: 2,
bank_id: 0, bank_id: 0,
active: true, active: true,
flip_x: false, flip_x: false,
flip_y: false, flip_y: false,
priority: 2, priority: 2,
}; };
gfx.sprite_count = 2;
gfx.render_scene_from_cache(&cache, &update); gfx.render_scene_from_cache(&cache, &update);
assert_eq!(gfx.back[0], Color::BLUE.raw()); assert_eq!(gfx.back[0], Color::BLUE.raw());
} }
#[test]
fn load_frame_sprites_replaces_slot_first_submission_for_render_state() {
let banks = Arc::new(MemoryBanks::new());
let mut gfx = Gfx::new(16, 16, banks as Arc<dyn GlyphBankPoolAccess>);
gfx.load_frame_sprites(&[
Sprite {
glyph: Glyph { glyph_id: 1, palette_id: 2 },
x: 2,
y: 3,
layer: 1,
bank_id: 4,
active: false,
flip_x: true,
flip_y: false,
priority: 7,
},
Sprite {
glyph: Glyph { glyph_id: 5, palette_id: 6 },
x: 7,
y: 8,
layer: 3,
bank_id: 9,
active: false,
flip_x: false,
flip_y: true,
priority: 1,
},
]);
assert_eq!(gfx.sprite_count, 2);
assert!(gfx.sprites[0].active);
assert!(gfx.sprites[1].active);
assert!(!gfx.sprites[2].active);
assert_eq!(gfx.sprites[0].layer, 1);
assert_eq!(gfx.sprites[1].glyph.glyph_id, 5);
}
} }
/// Blends in RGB565 per channel with saturation. /// Blends in RGB565 per channel with saturation.

View File

@ -0,0 +1,39 @@
use crate::gfx::BlendMode;
use prometeu_hal::color::Color;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayCommand {
FillRectBlend { x: i32, y: i32, w: i32, h: i32, color: Color, mode: BlendMode },
DrawLine { x0: i32, y0: i32, x1: i32, y1: i32, color: Color },
DrawCircle { x: i32, y: i32, r: i32, color: Color },
DrawDisc { x: i32, y: i32, r: i32, border_color: Color, fill_color: Color },
DrawSquare { x: i32, y: i32, w: i32, h: i32, border_color: Color, fill_color: Color },
DrawText { x: i32, y: i32, text: String, color: Color },
}
#[derive(Debug, Clone, Default)]
pub struct DeferredGfxOverlay {
commands: Vec<OverlayCommand>,
}
impl DeferredGfxOverlay {
pub fn begin_frame(&mut self) {
self.commands.clear();
}
pub fn push(&mut self, command: OverlayCommand) {
self.commands.push(command);
}
pub fn commands(&self) -> &[OverlayCommand] {
&self.commands
}
pub fn command_count(&self) -> usize {
self.commands.len()
}
pub fn take_commands(&mut self) -> Vec<OverlayCommand> {
std::mem::take(&mut self.commands)
}
}

View File

@ -1,13 +1,15 @@
use crate::asset::AssetManager; use crate::asset::AssetManager;
use crate::audio::Audio; use crate::audio::Audio;
use crate::frame_composer::FrameComposer;
use crate::gfx::Gfx; use crate::gfx::Gfx;
use crate::memory_banks::{ use crate::memory_banks::{
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller, GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess,
SoundBankPoolAccess, SoundBankPoolInstaller, SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller,
}; };
use crate::pad::Pad; use crate::pad::Pad;
use crate::touch::Touch; use crate::touch::Touch;
use prometeu_hal::cartridge::AssetsPayloadSource; use prometeu_hal::cartridge::AssetsPayloadSource;
use prometeu_hal::sprite::Sprite;
use prometeu_hal::{AssetBridge, AudioBridge, GfxBridge, HardwareBridge, PadBridge, TouchBridge}; use prometeu_hal::{AssetBridge, AudioBridge, GfxBridge, HardwareBridge, PadBridge, TouchBridge};
use std::sync::Arc; use std::sync::Arc;
@ -26,6 +28,8 @@ use std::sync::Arc;
pub struct Hardware { pub struct Hardware {
/// The Graphics Processing Unit (GPU). Handles drawing primitives, sprites, and tilemaps. /// The Graphics Processing Unit (GPU). Handles drawing primitives, sprites, and tilemaps.
pub gfx: Gfx, pub gfx: Gfx,
/// Canonical frame orchestration owner for scene/camera/cache/resolver/sprites.
pub frame_composer: FrameComposer,
/// The Sound Processing Unit (SPU). Manages sample playback and volume control. /// The Sound Processing Unit (SPU). Manages sample playback and volume control.
pub audio: Audio, pub audio: Audio,
/// The standard digital gamepad. Provides state for D-Pad, face buttons, and triggers. /// The standard digital gamepad. Provides state for D-Pad, face buttons, and triggers.
@ -43,6 +47,36 @@ impl Default for Hardware {
} }
impl HardwareBridge for Hardware { impl HardwareBridge for Hardware {
fn begin_frame(&mut self) {
self.gfx.begin_overlay_frame();
self.frame_composer.begin_frame();
}
fn bind_scene(&mut self, scene_bank_id: usize) -> bool {
self.frame_composer.bind_scene(scene_bank_id)
}
fn unbind_scene(&mut self) {
self.frame_composer.unbind_scene();
}
fn set_camera(&mut self, x: i32, y: i32) {
self.frame_composer.set_camera(x, y);
}
fn emit_sprite(&mut self, sprite: Sprite) -> bool {
self.frame_composer.emit_sprite(sprite)
}
fn render_frame(&mut self) {
self.frame_composer.render_frame(&mut self.gfx);
self.gfx.drain_overlay_debug();
}
fn has_glyph_bank(&self, bank_id: usize) -> bool {
self.gfx.glyph_banks.glyph_bank_slot(bank_id).is_some()
}
fn gfx(&self) -> &dyn GfxBridge { fn gfx(&self) -> &dyn GfxBridge {
&self.gfx &self.gfx
} }
@ -98,6 +132,11 @@ impl Hardware {
Self::H, Self::H,
Arc::clone(&memory_banks) as Arc<dyn GlyphBankPoolAccess>, Arc::clone(&memory_banks) as Arc<dyn GlyphBankPoolAccess>,
), ),
frame_composer: FrameComposer::new(
Self::W,
Self::H,
Arc::clone(&memory_banks) as Arc<dyn SceneBankPoolAccess>,
),
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(),
@ -122,7 +161,7 @@ mod tests {
use prometeu_hal::glyph::Glyph; use prometeu_hal::glyph::Glyph;
use prometeu_hal::glyph_bank::{GlyphBank, TileSize}; use prometeu_hal::glyph_bank::{GlyphBank, TileSize};
use prometeu_hal::scene_bank::SceneBank; use prometeu_hal::scene_bank::SceneBank;
use prometeu_hal::scene_layer::{MotionFactor, SceneLayer}; use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::scene_viewport_cache::SceneViewportCache; use prometeu_hal::scene_viewport_cache::SceneViewportCache;
use prometeu_hal::scene_viewport_resolver::SceneViewportResolver; use prometeu_hal::scene_viewport_resolver::SceneViewportResolver;
use prometeu_hal::tile::Tile; use prometeu_hal::tile::Tile;
@ -142,7 +181,7 @@ mod tests {
active: true, active: true,
glyph_bank_id: 0, glyph_bank_id: 0,
tile_size: TileSize::Size8, tile_size: TileSize::Size8,
motion_factor: MotionFactor { x: 1.0, y: 1.0 }, parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap { tilemap: TileMap {
width: 4, width: 4,
height: 4, height: 4,
@ -182,4 +221,20 @@ mod tests {
assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw()); assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw());
} }
#[test]
fn hardware_constructs_frame_composer_with_shared_scene_bank_access() {
let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(2, Arc::new(make_scene()));
let hardware = Hardware::new_with_memory_banks(banks);
let scene = hardware
.frame_composer
.scene_bank_slot(2)
.expect("scene bank slot 2 should be resident");
assert_eq!(hardware.frame_composer.viewport_size(), (Hardware::W, Hardware::H));
assert_eq!(hardware.frame_composer.scene_bank_slot_count(), 16);
assert_eq!(scene.layers[0].tile_size, TileSize::Size8);
}
} }

View File

@ -1,6 +1,8 @@
mod asset; mod asset;
mod audio; mod audio;
mod frame_composer;
mod gfx; mod gfx;
mod gfx_overlay;
pub mod hardware; pub mod hardware;
mod memory_banks; mod memory_banks;
mod pad; mod pad;
@ -8,7 +10,9 @@ mod touch;
pub use crate::asset::AssetManager; pub use crate::asset::AssetManager;
pub use crate::audio::{Audio, AudioCommand, Channel, MAX_CHANNELS, OUTPUT_SAMPLE_RATE}; pub use crate::audio::{Audio, AudioCommand, Channel, MAX_CHANNELS, OUTPUT_SAMPLE_RATE};
pub use crate::frame_composer::{FrameComposer, SceneStatus, SpriteController};
pub use crate::gfx::Gfx; pub use crate::gfx::Gfx;
pub use crate::gfx_overlay::{DeferredGfxOverlay, OverlayCommand};
pub use crate::memory_banks::{ pub use crate::memory_banks::{
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess, GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess,
SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller, SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller,

View File

@ -0,0 +1,10 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum ComposerOpStatus {
Ok = 0,
SceneUnavailable = 1,
ArgRangeInvalid = 2,
BankInvalid = 3,
LayerInvalid = 4,
SpriteOverflow = 5,
}

View File

@ -48,8 +48,21 @@ pub trait GfxBridge {
fn draw_horizontal_line(&mut self, x0: i32, x1: i32, y: i32, color: Color); fn draw_horizontal_line(&mut self, x0: i32, x1: i32, y: i32, color: Color);
fn draw_vertical_line(&mut self, x: i32, y0: i32, y1: i32, color: Color); fn draw_vertical_line(&mut self, x: i32, y0: i32, y1: i32, color: Color);
fn present(&mut self); fn present(&mut self);
fn render_all(&mut self); /// Render the canonical game frame with no bound scene.
///
/// Deferred `gfx.*` overlay/debug primitives are intentionally outside this
/// contract and are drained by a separate final overlay stage.
fn render_no_scene_frame(&mut self);
/// Render the canonical scene-backed game frame from cache/resolver state.
///
/// 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);
fn load_frame_sprites(&mut self, sprites: &[Sprite]);
/// Submit text into the `gfx.*` primitive path.
///
/// Under the accepted runtime contract this is not the canonical game
/// composition path; it belongs to the deferred final overlay/debug family.
fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color); fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color);
fn draw_char(&mut self, x: i32, y: i32, c: char, color: Color); fn draw_char(&mut self, x: i32, y: i32, c: char, color: Color);

View File

@ -2,9 +2,18 @@ use crate::asset_bridge::AssetBridge;
use crate::audio_bridge::AudioBridge; use crate::audio_bridge::AudioBridge;
use crate::gfx_bridge::GfxBridge; use crate::gfx_bridge::GfxBridge;
use crate::pad_bridge::PadBridge; use crate::pad_bridge::PadBridge;
use crate::sprite::Sprite;
use crate::touch_bridge::TouchBridge; use crate::touch_bridge::TouchBridge;
pub trait HardwareBridge { pub trait HardwareBridge {
fn begin_frame(&mut self);
fn bind_scene(&mut self, scene_bank_id: usize) -> bool;
fn unbind_scene(&mut self);
fn set_camera(&mut self, x: i32, y: i32);
fn emit_sprite(&mut self, sprite: Sprite) -> bool;
fn render_frame(&mut self);
fn has_glyph_bank(&self, bank_id: usize) -> bool;
fn gfx(&self) -> &dyn GfxBridge; fn gfx(&self) -> &dyn GfxBridge;
fn gfx_mut(&mut self) -> &mut dyn GfxBridge; fn gfx_mut(&mut self) -> &mut dyn GfxBridge;

View File

@ -5,6 +5,7 @@ pub mod button;
pub mod cartridge; pub mod cartridge;
pub mod cartridge_loader; pub mod cartridge_loader;
pub mod color; pub mod color;
pub mod composer_status;
pub mod debugger_protocol; pub mod debugger_protocol;
pub mod gfx_bridge; pub mod gfx_bridge;
pub mod glyph; pub mod glyph;
@ -34,6 +35,7 @@ pub mod window;
pub use asset_bridge::AssetBridge; pub use asset_bridge::AssetBridge;
pub use audio_bridge::{AudioBridge, AudioOpStatus, LoopMode}; pub use audio_bridge::{AudioBridge, AudioOpStatus, LoopMode};
pub use composer_status::ComposerOpStatus;
pub use gfx_bridge::{BlendMode, GfxBridge, GfxOpStatus}; pub use gfx_bridge::{BlendMode, GfxBridge, GfxOpStatus};
pub use hardware_bridge::HardwareBridge; pub use hardware_bridge::HardwareBridge;
pub use host_context::{HostContext, HostContextProvider}; pub use host_context::{HostContext, HostContextProvider};

View File

@ -10,16 +10,16 @@ mod tests {
use super::*; use super::*;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::glyph_bank::TileSize; use crate::glyph_bank::TileSize;
use crate::scene_layer::MotionFactor; 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, motion_x: f32, motion_y: f32, glyph_id: u16) -> SceneLayer { fn layer(glyph_bank_id: u8, parallax_x: f32, parallax_y: f32, glyph_id: u16) -> SceneLayer {
SceneLayer { SceneLayer {
active: true, active: true,
glyph_bank_id, glyph_bank_id,
tile_size: TileSize::Size16, tile_size: TileSize::Size16,
motion_factor: MotionFactor { x: motion_x, y: motion_y }, parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
tilemap: TileMap { tilemap: TileMap {
width: 1, width: 1,
height: 1, height: 1,

View File

@ -2,7 +2,7 @@ use crate::glyph_bank::TileSize;
use crate::tilemap::TileMap; use crate::tilemap::TileMap;
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct MotionFactor { pub struct ParallaxFactor {
pub x: f32, pub x: f32,
pub y: f32, pub y: f32,
} }
@ -12,7 +12,7 @@ pub struct SceneLayer {
pub active: bool, pub active: bool,
pub glyph_bank_id: u8, pub glyph_bank_id: u8,
pub tile_size: TileSize, pub tile_size: TileSize,
pub motion_factor: MotionFactor, pub parallax_factor: ParallaxFactor,
pub tilemap: TileMap, pub tilemap: TileMap,
} }
@ -23,12 +23,12 @@ mod tests {
use crate::tile::Tile; use crate::tile::Tile;
#[test] #[test]
fn scene_layer_preserves_motion_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_bank_id: 7,
tile_size: TileSize::Size16, tile_size: TileSize::Size16,
motion_factor: MotionFactor { x: 0.5, y: 0.75 }, parallax_factor: ParallaxFactor { x: 0.5, y: 0.75 },
tilemap: TileMap { tilemap: TileMap {
width: 2, width: 2,
height: 1, height: 1,
@ -50,8 +50,8 @@ mod tests {
}; };
assert_eq!(layer.glyph_bank_id, 7); assert_eq!(layer.glyph_bank_id, 7);
assert_eq!(layer.motion_factor.x, 0.5); assert_eq!(layer.parallax_factor.x, 0.5);
assert_eq!(layer.motion_factor.y, 0.75); assert_eq!(layer.parallax_factor.y, 0.75);
assert_eq!(layer.tilemap.width, 2); assert_eq!(layer.tilemap.width, 2);
assert_eq!(layer.tilemap.tiles[1].glyph.glyph_id, 22); assert_eq!(layer.tilemap.tiles[1].glyph.glyph_id, 22);
assert!(layer.tilemap.tiles[1].flip_x); assert!(layer.tilemap.tiles[1].flip_x);

View File

@ -270,7 +270,7 @@ mod tests {
use super::*; use super::*;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::glyph_bank::TileSize; use crate::glyph_bank::TileSize;
use crate::scene_layer::MotionFactor; use crate::scene_layer::ParallaxFactor;
use crate::tile::Tile; use crate::tile::Tile;
use crate::tilemap::TileMap; use crate::tilemap::TileMap;
@ -295,7 +295,7 @@ mod tests {
active: true, active: true,
glyph_bank_id, glyph_bank_id,
tile_size: TileSize::Size16, tile_size: TileSize::Size16,
motion_factor: MotionFactor { 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 },
} }
} }
@ -325,6 +325,34 @@ mod tests {
assert_eq!(cache.layers[0].ring_origin(), (1, 1)); assert_eq!(cache.layers[0].ring_origin(), (1, 1));
} }
#[test]
fn layer_cache_wraps_ring_origin_for_negative_and_large_movements() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
cache.move_layer_window_by(0, -1, -4);
assert_eq!(cache.layers[0].logical_origin(), (-1, -4));
assert_eq!(cache.layers[0].ring_origin(), (2, 2));
cache.move_layer_window_by(0, 7, 8);
assert_eq!(cache.layers[0].logical_origin(), (6, 4));
assert_eq!(cache.layers[0].ring_origin(), (0, 1));
}
#[test]
fn move_window_to_matches_incremental_ring_movement() {
let scene = make_scene();
let mut direct = SceneViewportCache::new(&scene, 4, 4);
let mut incremental = SceneViewportCache::new(&scene, 4, 4);
direct.move_layer_window_to(0, 9, -6);
incremental.move_layer_window_by(0, 5, -2);
incremental.move_layer_window_by(0, 4, -4);
assert_eq!(direct.layers[0].logical_origin(), incremental.layers[0].logical_origin());
assert_eq!(direct.layers[0].ring_origin(), incremental.layers[0].ring_origin());
}
#[test] #[test]
fn cache_entry_fields_are_derived_from_scene_tiles() { fn cache_entry_fields_are_derived_from_scene_tiles() {
let scene = make_scene(); let scene = make_scene();
@ -415,6 +443,113 @@ mod tests {
assert_eq!(cache.layers[3].entry(3, 3).glyph_id, 415); assert_eq!(cache.layers[3].entry(3, 3).glyph_id, 415);
} }
#[test]
fn refresh_after_wrapped_window_move_materializes_new_logical_tiles() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
cache.refresh_layer_all(&scene, 0);
cache.move_layer_window_to(0, 1, 2);
cache.refresh_layer_all(&scene, 0);
assert_eq!(cache.layers[0].logical_origin(), (1, 2));
assert_eq!(cache.layers[0].ring_origin(), (1, 2));
assert_eq!(cache.layers[0].entry(0, 0).glyph_id, 109);
assert_eq!(cache.layers[0].entry(1, 0).glyph_id, 110);
assert_eq!(cache.layers[0].entry(2, 0).glyph_id, 111);
assert_eq!(cache.layers[0].entry(0, 1).glyph_id, 113);
assert_eq!(cache.layers[0].entry(2, 1).glyph_id, 115);
assert_eq!(cache.layers[0].entry(0, 2), CachedTileEntry::default());
}
#[test]
fn partial_refresh_uses_wrapped_physical_slots_after_window_move() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
cache.move_layer_window_to(0, 1, 0);
cache.refresh_layer_column(&scene, 0, 0);
assert_eq!(cache.layers[0].ring_origin(), (1, 0));
assert_eq!(cache.layers[0].entry(0, 0).glyph_id, 101);
assert_eq!(cache.layers[0].entry(0, 1).glyph_id, 105);
assert_eq!(cache.layers[0].entry(0, 2).glyph_id, 109);
assert_eq!(cache.layers[0].entry(1, 0), CachedTileEntry::default());
assert_eq!(cache.layers[0].entry(2, 0), CachedTileEntry::default());
}
#[test]
fn out_of_bounds_logical_origins_materialize_default_entries_after_wrap() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 2, 2);
cache.move_layer_window_to(0, -2, 3);
cache.refresh_layer_all(&scene, 0);
for y in 0..2 {
for x in 0..2 {
assert_eq!(cache.layers[0].entry(x, y), CachedTileEntry::default());
}
}
}
#[test]
fn ringbuffer_preserves_logical_tile_mapping_across_long_mixed_movements() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
let motions = [
(1, 0),
(0, 1),
(2, 2),
(-1, 0),
(0, -2),
(4, 1),
(-3, 3),
(5, -4),
(-6, 2),
(3, -3),
(7, 7),
(-8, -5),
];
for &(dx, dy) in &motions {
cache.move_layer_window_by(0, dx, dy);
cache.refresh_layer_all(&scene, 0);
let (origin_x, origin_y) = cache.layers[0].logical_origin();
for cache_y in 0..cache.height() {
for cache_x in 0..cache.width() {
let expected_scene_x = origin_x + cache_x as i32;
let expected_scene_y = origin_y + cache_y as i32;
let expected = if expected_scene_x < 0
|| expected_scene_y < 0
|| expected_scene_x as usize >= scene.layers[0].tilemap.width
|| expected_scene_y as usize >= scene.layers[0].tilemap.height
{
CachedTileEntry::default()
} else {
let tile_x = expected_scene_x as usize;
let tile_y = expected_scene_y as usize;
let tile = scene.layers[0].tilemap.tiles
[tile_y * scene.layers[0].tilemap.width + tile_x];
CachedTileEntry::from_tile(&scene.layers[0], tile)
};
assert_eq!(
cache.layers[0].entry(cache_x, cache_y),
expected,
"mismatch at logical origin ({}, {}), cache ({}, {})",
origin_x,
origin_y,
cache_x,
cache_y
);
}
}
}
}
#[test] #[test]
fn materialization_populates_all_four_layers() { fn materialization_populates_all_four_layers() {
let scene = make_scene(); let scene = make_scene();

View File

@ -96,8 +96,8 @@ impl SceneViewportResolver {
let layer_inputs: [(i32, i32, i32, i32, i32); 4] = std::array::from_fn(|i| { let layer_inputs: [(i32, i32, i32, i32, i32); 4] = std::array::from_fn(|i| {
let layer = &scene.layers[i]; let layer = &scene.layers[i];
let tile_size_px = layer.tile_size as i32; let tile_size_px = layer.tile_size as i32;
let layer_camera_x_px = ((camera_x_px as f32) * layer.motion_factor.x).floor() as i32; let layer_camera_x_px = ((camera_x_px as f32) * layer.parallax_factor.x).floor() as i32;
let layer_camera_y_px = ((camera_y_px as f32) * layer.motion_factor.y).floor() as i32; let layer_camera_y_px = ((camera_y_px as f32) * layer.parallax_factor.y).floor() as i32;
let layer_center_x_px = layer_camera_x_px + self.viewport_width_px / 2; let layer_center_x_px = layer_camera_x_px + self.viewport_width_px / 2;
let layer_center_y_px = layer_camera_y_px + self.viewport_height_px / 2; let layer_center_y_px = layer_camera_y_px + self.viewport_height_px / 2;
( (
@ -388,14 +388,14 @@ mod tests {
use super::*; use super::*;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::glyph_bank::TileSize; use crate::glyph_bank::TileSize;
use crate::scene_layer::{MotionFactor, SceneLayer}; use crate::scene_layer::{ParallaxFactor, SceneLayer};
use crate::tile::Tile; use crate::tile::Tile;
use crate::tilemap::TileMap; use crate::tilemap::TileMap;
fn make_layer( fn make_layer(
tile_size: TileSize, tile_size: TileSize,
motion_x: f32, parallax_x: f32,
motion_y: f32, parallax_y: f32,
width: usize, width: usize,
height: usize, height: usize,
) -> SceneLayer { ) -> SceneLayer {
@ -413,7 +413,7 @@ mod tests {
active: true, active: true,
glyph_bank_id: 1, glyph_bank_id: 1,
tile_size, tile_size,
motion_factor: MotionFactor { x: motion_x, y: motion_y }, parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
tilemap: TileMap { width, height, tiles }, tilemap: TileMap { width, height, tiles },
} }
} }
@ -443,7 +443,7 @@ mod tests {
} }
#[test] #[test]
fn per_layer_copy_requests_follow_motion_factor() { fn per_layer_copy_requests_follow_parallax_factor() {
let scene = make_scene(); let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20); let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);

View File

@ -5,6 +5,7 @@ pub struct Sprite {
pub glyph: Glyph, pub glyph: Glyph,
pub x: i32, pub x: i32,
pub y: i32, pub y: i32,
pub layer: u8,
pub bank_id: u8, pub bank_id: u8,
pub active: bool, pub active: bool,
pub flip_x: bool, pub flip_x: bool,

View File

@ -19,6 +19,7 @@ pub use resolver::{
/// Each Syscall has a unique 32-bit ID. The IDs are grouped by category: /// Each Syscall has a unique 32-bit ID. The IDs are grouped by category:
/// - **0x0xxx**: System & OS Control /// - **0x0xxx**: System & OS Control
/// - **0x1xxx**: Graphics (GFX) /// - **0x1xxx**: Graphics (GFX)
/// - **0x11xx**: Frame Composer orchestration
/// - **0x2xxx**: Reserved for legacy input syscalls (disabled for v1 VM-owned input) /// - **0x2xxx**: Reserved for legacy input syscalls (disabled for v1 VM-owned input)
/// - **0x3xxx**: Audio (PCM & Mixing) /// - **0x3xxx**: Audio (PCM & Mixing)
/// - **0x4xxx**: Filesystem (Sandboxed I/O) /// - **0x4xxx**: Filesystem (Sandboxed I/O)
@ -35,9 +36,12 @@ pub enum Syscall {
GfxDrawCircle = 0x1004, GfxDrawCircle = 0x1004,
GfxDrawDisc = 0x1005, GfxDrawDisc = 0x1005,
GfxDrawSquare = 0x1006, GfxDrawSquare = 0x1006,
GfxSetSprite = 0x1007,
GfxDrawText = 0x1008, GfxDrawText = 0x1008,
GfxClear565 = 0x1010, GfxClear565 = 0x1010,
ComposerBindScene = 0x1101,
ComposerUnbindScene = 0x1102,
ComposerSetCamera = 0x1103,
ComposerEmitSprite = 0x1104,
AudioPlaySample = 0x3001, AudioPlaySample = 0x3001,
AudioPlay = 0x3002, AudioPlay = 0x3002,
FsOpen = 0x4001, FsOpen = 0x4001,

View File

@ -0,0 +1,22 @@
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
SyscallRegistryEntry::builder(Syscall::ComposerBindScene, "composer", "bind_scene")
.args(1)
.rets(1)
.caps(caps::GFX)
.cost(5),
SyscallRegistryEntry::builder(Syscall::ComposerUnbindScene, "composer", "unbind_scene")
.rets(1)
.caps(caps::GFX)
.cost(2),
SyscallRegistryEntry::builder(Syscall::ComposerSetCamera, "composer", "set_camera")
.args(2)
.caps(caps::GFX)
.cost(2),
SyscallRegistryEntry::builder(Syscall::ComposerEmitSprite, "composer", "emit_sprite")
.args(9)
.rets(1)
.caps(caps::GFX)
.cost(5),
];

View File

@ -25,11 +25,6 @@ pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
.args(6) .args(6)
.caps(caps::GFX) .caps(caps::GFX)
.cost(5), .cost(5),
SyscallRegistryEntry::builder(Syscall::GfxSetSprite, "gfx", "set_sprite")
.args(10)
.rets(1)
.caps(caps::GFX)
.cost(5),
SyscallRegistryEntry::builder(Syscall::GfxDrawText, "gfx", "draw_text") SyscallRegistryEntry::builder(Syscall::GfxDrawText, "gfx", "draw_text")
.args(4) .args(4)
.caps(caps::GFX) .caps(caps::GFX)

View File

@ -1,6 +1,7 @@
mod asset; mod asset;
mod audio; mod audio;
mod bank; mod bank;
mod composer;
mod fs; mod fs;
mod gfx; mod gfx;
mod log; mod log;
@ -12,6 +13,7 @@ pub(crate) fn all_entries() -> impl Iterator<Item = &'static SyscallRegistryEntr
system::ENTRIES system::ENTRIES
.iter() .iter()
.chain(gfx::ENTRIES.iter()) .chain(gfx::ENTRIES.iter())
.chain(composer::ENTRIES.iter())
.chain(audio::ENTRIES.iter()) .chain(audio::ENTRIES.iter())
.chain(fs::ENTRIES.iter()) .chain(fs::ENTRIES.iter())
.chain(log::ENTRIES.iter()) .chain(log::ENTRIES.iter())

View File

@ -20,9 +20,12 @@ impl Syscall {
0x1004 => Some(Self::GfxDrawCircle), 0x1004 => Some(Self::GfxDrawCircle),
0x1005 => Some(Self::GfxDrawDisc), 0x1005 => Some(Self::GfxDrawDisc),
0x1006 => Some(Self::GfxDrawSquare), 0x1006 => Some(Self::GfxDrawSquare),
0x1007 => Some(Self::GfxSetSprite),
0x1008 => Some(Self::GfxDrawText), 0x1008 => Some(Self::GfxDrawText),
0x1010 => Some(Self::GfxClear565), 0x1010 => Some(Self::GfxClear565),
0x1101 => Some(Self::ComposerBindScene),
0x1102 => Some(Self::ComposerUnbindScene),
0x1103 => Some(Self::ComposerSetCamera),
0x1104 => Some(Self::ComposerEmitSprite),
0x3001 => Some(Self::AudioPlaySample), 0x3001 => Some(Self::AudioPlaySample),
0x3002 => Some(Self::AudioPlay), 0x3002 => Some(Self::AudioPlay),
0x4001 => Some(Self::FsOpen), 0x4001 => Some(Self::FsOpen),
@ -68,9 +71,12 @@ impl Syscall {
Self::GfxDrawCircle => "GfxDrawCircle", Self::GfxDrawCircle => "GfxDrawCircle",
Self::GfxDrawDisc => "GfxDrawDisc", Self::GfxDrawDisc => "GfxDrawDisc",
Self::GfxDrawSquare => "GfxDrawSquare", Self::GfxDrawSquare => "GfxDrawSquare",
Self::GfxSetSprite => "GfxSetSprite",
Self::GfxDrawText => "GfxDrawText", Self::GfxDrawText => "GfxDrawText",
Self::GfxClear565 => "GfxClear565", Self::GfxClear565 => "GfxClear565",
Self::ComposerBindScene => "ComposerBindScene",
Self::ComposerUnbindScene => "ComposerUnbindScene",
Self::ComposerSetCamera => "ComposerSetCamera",
Self::ComposerEmitSprite => "ComposerEmitSprite",
Self::AudioPlaySample => "AudioPlaySample", Self::AudioPlaySample => "AudioPlaySample",
Self::AudioPlay => "AudioPlay", Self::AudioPlay => "AudioPlay",
Self::FsOpen => "FsOpen", Self::FsOpen => "FsOpen",

View File

@ -63,6 +63,18 @@ fn resolver_rejects_unknown_identity() {
} }
} }
#[test]
fn resolver_rejects_removed_legacy_gfx_set_sprite_identity() {
assert!(resolve_syscall("gfx", "set_sprite", 1).is_none());
let requested = [SyscallIdentity { module: "gfx", name: "set_sprite", version: 1 }];
let err = resolve_program_syscalls(&requested, caps::ALL).unwrap_err();
assert_eq!(
err,
LoadError::UnknownSyscall { module: "gfx".into(), name: "set_sprite".into(), version: 1 }
);
}
#[test] #[test]
fn resolver_enforces_capabilities() { fn resolver_enforces_capabilities() {
let requested = [SyscallIdentity { module: "gfx", name: "clear", version: 1 }]; let requested = [SyscallIdentity { module: "gfx", name: "clear", version: 1 }];
@ -194,10 +206,6 @@ fn status_first_syscall_signatures_are_pinned() {
assert_eq!(draw_square.arg_slots, 6); assert_eq!(draw_square.arg_slots, 6);
assert_eq!(draw_square.ret_slots, 0); assert_eq!(draw_square.ret_slots, 0);
let set_sprite = meta_for(Syscall::GfxSetSprite);
assert_eq!(set_sprite.arg_slots, 10);
assert_eq!(set_sprite.ret_slots, 1);
let draw_text = meta_for(Syscall::GfxDrawText); let draw_text = meta_for(Syscall::GfxDrawText);
assert_eq!(draw_text.arg_slots, 4); assert_eq!(draw_text.arg_slots, 4);
assert_eq!(draw_text.ret_slots, 0); assert_eq!(draw_text.ret_slots, 0);
@ -206,6 +214,22 @@ fn status_first_syscall_signatures_are_pinned() {
assert_eq!(clear_565.arg_slots, 1); assert_eq!(clear_565.arg_slots, 1);
assert_eq!(clear_565.ret_slots, 0); assert_eq!(clear_565.ret_slots, 0);
let bind_scene = meta_for(Syscall::ComposerBindScene);
assert_eq!(bind_scene.arg_slots, 1);
assert_eq!(bind_scene.ret_slots, 1);
let unbind_scene = meta_for(Syscall::ComposerUnbindScene);
assert_eq!(unbind_scene.arg_slots, 0);
assert_eq!(unbind_scene.ret_slots, 1);
let set_camera = meta_for(Syscall::ComposerSetCamera);
assert_eq!(set_camera.arg_slots, 2);
assert_eq!(set_camera.ret_slots, 0);
let emit_sprite = meta_for(Syscall::ComposerEmitSprite);
assert_eq!(emit_sprite.arg_slots, 9);
assert_eq!(emit_sprite.ret_slots, 1);
let audio_play_sample = meta_for(Syscall::AudioPlaySample); let audio_play_sample = meta_for(Syscall::AudioPlaySample);
assert_eq!(audio_play_sample.arg_slots, 5); assert_eq!(audio_play_sample.arg_slots, 5);
assert_eq!(audio_play_sample.ret_slots, 1); assert_eq!(audio_play_sample.ret_slots, 1);
@ -231,10 +255,10 @@ fn status_first_syscall_signatures_are_pinned() {
fn declared_resolver_rejects_legacy_status_first_signatures() { fn declared_resolver_rejects_legacy_status_first_signatures() {
let declared = vec![ let declared = vec![
prometeu_bytecode::SyscallDecl { prometeu_bytecode::SyscallDecl {
module: "gfx".into(), module: "composer".into(),
name: "set_sprite".into(), name: "bind_scene".into(),
version: 1, version: 1,
arg_slots: 10, arg_slots: 1,
ret_slots: 0, ret_slots: 0,
}, },
prometeu_bytecode::SyscallDecl { prometeu_bytecode::SyscallDecl {
@ -306,10 +330,24 @@ fn declared_resolver_rejects_legacy_status_first_signatures() {
fn declared_resolver_accepts_mixed_status_first_surface_as_a_single_module() { fn declared_resolver_accepts_mixed_status_first_surface_as_a_single_module() {
let declared = vec![ let declared = vec![
prometeu_bytecode::SyscallDecl { prometeu_bytecode::SyscallDecl {
module: "gfx".into(), module: "composer".into(),
name: "set_sprite".into(), name: "bind_scene".into(),
version: 1, version: 1,
arg_slots: 10, arg_slots: 1,
ret_slots: 1,
},
prometeu_bytecode::SyscallDecl {
module: "composer".into(),
name: "unbind_scene".into(),
version: 1,
arg_slots: 0,
ret_slots: 1,
},
prometeu_bytecode::SyscallDecl {
module: "composer".into(),
name: "emit_sprite".into(),
version: 1,
arg_slots: 9,
ret_slots: 1, ret_slots: 1,
}, },
prometeu_bytecode::SyscallDecl { prometeu_bytecode::SyscallDecl {
@ -342,8 +380,10 @@ fn declared_resolver_accepts_mixed_status_first_surface_as_a_single_module() {
assert_eq!(resolved.len(), declared.len()); assert_eq!(resolved.len(), declared.len());
assert_eq!(resolved[0].meta.ret_slots, 1); assert_eq!(resolved[0].meta.ret_slots, 1);
assert_eq!(resolved[1].meta.ret_slots, 1); assert_eq!(resolved[1].meta.ret_slots, 1);
assert_eq!(resolved[2].meta.ret_slots, 2); assert_eq!(resolved[2].meta.ret_slots, 1);
assert_eq!(resolved[3].meta.ret_slots, 1); assert_eq!(resolved[3].meta.ret_slots, 1);
assert_eq!(resolved[4].meta.ret_slots, 2);
assert_eq!(resolved[5].meta.ret_slots, 1);
} }
#[test] #[test]

View File

@ -10,8 +10,8 @@ use prometeu_hal::sprite::Sprite;
use prometeu_hal::syscalls::Syscall; use prometeu_hal::syscalls::Syscall;
use prometeu_hal::vm_fault::VmFault; use prometeu_hal::vm_fault::VmFault;
use prometeu_hal::{ use prometeu_hal::{
AudioOpStatus, GfxOpStatus, HostContext, HostReturn, NativeInterface, SyscallId, expect_bool, AudioOpStatus, ComposerOpStatus, HostContext, HostReturn, NativeInterface, SyscallId,
expect_int, expect_bool, expect_int,
}; };
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@ -56,6 +56,23 @@ impl VirtualMachineRuntime {
pub(crate) fn get_color(&self, value: i64) -> Color { pub(crate) fn get_color(&self, value: i64) -> Color {
Color::from_raw(value as u16) Color::from_raw(value as u16)
} }
fn int_arg_to_usize_status(value: i64) -> Result<usize, ComposerOpStatus> {
usize::try_from(value).map_err(|_| ComposerOpStatus::ArgRangeInvalid)
}
fn int_arg_to_i32_trap(value: i64, name: &str) -> Result<i32, VmFault> {
i32::try_from(value)
.map_err(|_| VmFault::Trap(TRAP_OOB, format!("{name} value out of bounds")))
}
fn int_arg_to_u8_status(value: i64) -> Result<u8, ComposerOpStatus> {
u8::try_from(value).map_err(|_| ComposerOpStatus::ArgRangeInvalid)
}
fn int_arg_to_u16_status(value: i64) -> Result<u16, ComposerOpStatus> {
u16::try_from(value).map_err(|_| ComposerOpStatus::ArgRangeInvalid)
}
} }
impl NativeInterface for VirtualMachineRuntime { impl NativeInterface for VirtualMachineRuntime {
@ -135,46 +152,6 @@ impl NativeInterface for VirtualMachineRuntime {
hw.gfx_mut().draw_square(x, y, w, h, border_color, fill_color); hw.gfx_mut().draw_square(x, y, w, h, border_color, fill_color);
Ok(()) Ok(())
} }
Syscall::GfxSetSprite => {
let bank_id = expect_int(args, 0)? as u8;
let index = expect_int(args, 1)? as usize;
let x = expect_int(args, 2)? as i32;
let y = expect_int(args, 3)? as i32;
let glyph_id = expect_int(args, 4)? as u16;
let palette_id = expect_int(args, 5)? as u8;
let active = expect_bool(args, 6)?;
let flip_x = expect_bool(args, 7)?;
let flip_y = expect_bool(args, 8)?;
let priority = expect_int(args, 9)? as u8;
if index >= 512 {
ret.push_int(GfxOpStatus::InvalidSpriteIndex as i64);
return Ok(());
}
if hw.assets().slot_info(SlotRef::gfx(bank_id as usize)).asset_id.is_none() {
ret.push_int(GfxOpStatus::BankInvalid as i64);
return Ok(());
}
if palette_id >= 64 || priority >= 5 {
ret.push_int(GfxOpStatus::ArgRangeInvalid as i64);
return Ok(());
}
*hw.gfx_mut().sprite_mut(index) = Sprite {
glyph: Glyph { glyph_id, palette_id },
x,
y,
bank_id,
active,
flip_x,
flip_y,
priority,
};
ret.push_int(GfxOpStatus::Ok as i64);
Ok(())
}
Syscall::GfxDrawText => { Syscall::GfxDrawText => {
let x = expect_int(args, 0)? as i32; let x = expect_int(args, 0)? as i32;
let y = expect_int(args, 1)? as i32; let y = expect_int(args, 1)? as i32;
@ -191,6 +168,100 @@ impl NativeInterface for VirtualMachineRuntime {
hw.gfx_mut().clear(Color::from_raw(color_val as u16)); hw.gfx_mut().clear(Color::from_raw(color_val as u16));
Ok(()) Ok(())
} }
Syscall::ComposerBindScene => {
let scene_bank_id = match Self::int_arg_to_usize_status(expect_int(args, 0)?) {
Ok(id) => id,
Err(status) => {
ret.push_int(status as i64);
return Ok(());
}
};
let status = if hw.bind_scene(scene_bank_id) {
ComposerOpStatus::Ok
} else {
ComposerOpStatus::SceneUnavailable
};
ret.push_int(status as i64);
Ok(())
}
Syscall::ComposerUnbindScene => {
hw.unbind_scene();
ret.push_int(ComposerOpStatus::Ok as i64);
Ok(())
}
Syscall::ComposerSetCamera => {
let x = Self::int_arg_to_i32_trap(expect_int(args, 0)?, "camera x")?;
let y = Self::int_arg_to_i32_trap(expect_int(args, 1)?, "camera y")?;
hw.set_camera(x, y);
Ok(())
}
Syscall::ComposerEmitSprite => {
let glyph_id = match Self::int_arg_to_u16_status(expect_int(args, 0)?) {
Ok(value) => value,
Err(status) => {
ret.push_int(status as i64);
return Ok(());
}
};
let palette_id = match Self::int_arg_to_u8_status(expect_int(args, 1)?) {
Ok(value) if value < 64 => value,
_ => {
ret.push_int(ComposerOpStatus::ArgRangeInvalid as i64);
return Ok(());
}
};
let x = Self::int_arg_to_i32_trap(expect_int(args, 2)?, "sprite x")?;
let y = Self::int_arg_to_i32_trap(expect_int(args, 3)?, "sprite y")?;
let layer = match Self::int_arg_to_u8_status(expect_int(args, 4)?) {
Ok(value) if value < 4 => value,
Ok(_) => {
ret.push_int(ComposerOpStatus::LayerInvalid as i64);
return Ok(());
}
Err(status) => {
ret.push_int(status as i64);
return Ok(());
}
};
let bank_id = match Self::int_arg_to_u8_status(expect_int(args, 5)?) {
Ok(value) => value,
Err(status) => {
ret.push_int(status as i64);
return Ok(());
}
};
let flip_x = expect_bool(args, 6)?;
let flip_y = expect_bool(args, 7)?;
let priority = match Self::int_arg_to_u8_status(expect_int(args, 8)?) {
Ok(value) => value,
Err(status) => {
ret.push_int(status as i64);
return Ok(());
}
};
if !hw.has_glyph_bank(bank_id as usize) {
ret.push_int(ComposerOpStatus::BankInvalid as i64);
return Ok(());
}
let emitted = hw.emit_sprite(Sprite {
glyph: Glyph { glyph_id, palette_id },
x,
y,
layer,
bank_id,
active: false,
flip_x,
flip_y,
priority,
});
let status =
if emitted { ComposerOpStatus::Ok } else { ComposerOpStatus::SpriteOverflow };
ret.push_int(status as i64);
Ok(())
}
Syscall::AudioPlaySample => { Syscall::AudioPlaySample => {
let sample_id_raw = expect_int(args, 0)?; let sample_id_raw = expect_int(args, 0)?;
let voice_id_raw = expect_int(args, 1)?; let voice_id_raw = expect_int(args, 1)?;

View File

@ -5,17 +5,25 @@ use prometeu_bytecode::Value;
use prometeu_bytecode::assembler::assemble; use prometeu_bytecode::assembler::assemble;
use prometeu_bytecode::model::{BytecodeModule, ConstantPoolEntry, FunctionMeta, SyscallDecl}; use prometeu_bytecode::model::{BytecodeModule, ConstantPoolEntry, FunctionMeta, SyscallDecl};
use prometeu_drivers::hardware::Hardware; use prometeu_drivers::hardware::Hardware;
use prometeu_drivers::{GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller};
use prometeu_hal::AudioOpStatus; use prometeu_hal::AudioOpStatus;
use prometeu_hal::GfxOpStatus; use prometeu_hal::ComposerOpStatus;
use prometeu_hal::InputSignals; use prometeu_hal::InputSignals;
use prometeu_hal::asset::{ use prometeu_hal::asset::{
AssetCodec, AssetEntry, AssetLoadError, AssetOpStatus, BankType, LoadStatus, AssetCodec, AssetEntry, AssetLoadError, AssetOpStatus, BankType, LoadStatus,
}; };
use prometeu_hal::cartridge::{AssetsPayloadSource, Cartridge}; use prometeu_hal::cartridge::{AssetsPayloadSource, Cartridge};
use prometeu_hal::glyph_bank::GLYPH_BANK_PALETTE_COUNT_V1; use prometeu_hal::color::Color;
use prometeu_hal::glyph::Glyph;
use prometeu_hal::glyph_bank::{GLYPH_BANK_PALETTE_COUNT_V1, GlyphBank, TileSize};
use prometeu_hal::scene_bank::SceneBank;
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::syscalls::caps; use prometeu_hal::syscalls::caps;
use prometeu_hal::tile::Tile;
use prometeu_hal::tilemap::TileMap;
use prometeu_vm::VmInitError; use prometeu_vm::VmInitError;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
#[derive(Default)] #[derive(Default)]
@ -129,6 +137,40 @@ fn test_glyph_asset_data() -> Vec<u8> {
data data
} }
fn runtime_test_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank {
let size = tile_size as usize;
let mut bank = GlyphBank::new(tile_size, size, size);
bank.palettes[palette_id as usize][1] = color;
for pixel in &mut bank.pixel_indices {
*pixel = 1;
}
bank
}
fn runtime_test_scene(glyph_bank_id: u8, palette_id: u8, tile_size: TileSize) -> SceneBank {
let layer = SceneLayer {
active: true,
glyph_bank_id,
tile_size,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap {
width: 2,
height: 2,
tiles: vec![
Tile {
active: true,
glyph: Glyph { glyph_id: 0, palette_id },
flip_x: false,
flip_y: false,
};
4
],
},
};
SceneBank { layers: std::array::from_fn(|_| layer.clone()) }
}
#[test] #[test]
fn initialize_vm_applies_cartridge_capabilities_before_loader_resolution() { fn initialize_vm_applies_cartridge_capabilities_before_loader_resolution() {
let mut runtime = VirtualMachineRuntime::new(None); let mut runtime = VirtualMachineRuntime::new(None);
@ -233,6 +275,174 @@ fn tick_returns_panic_report_distinct_from_trap() {
assert!(matches!(runtime.last_crash_report, Some(CrashReport::VmPanic { .. }))); assert!(matches!(runtime.last_crash_report, Some(CrashReport::VmPanic { .. })));
} }
#[test]
fn tick_renders_bound_eight_pixel_scene_through_frame_composer_path() {
let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default();
let signals = InputSignals::default();
let program =
serialized_single_function_module(assemble("FRAME_SYNC\nHALT").expect("assemble"), vec![]);
let cartridge = cartridge_with_program(program, caps::NONE);
let banks = Arc::new(MemoryBanks::new());
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));
assert!(hardware.frame_composer.bind_scene(0));
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(&mut vm, &signals, &mut hardware);
assert!(report.is_none(), "frame render path must not crash");
hardware.gfx.present();
assert_eq!(hardware.gfx.front_buffer()[0], Color::BLUE.raw());
}
#[test]
fn tick_renders_scene_through_public_composer_syscalls() {
let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 0\nHOSTCALL 0\nPOP_N 1\n\
PUSH_I32 0\nPUSH_I32 0\nHOSTCALL 1\n\
PUSH_I32 0\nPUSH_I32 2\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 2\n\
FRAME_SYNC\nHALT",
)
.expect("assemble");
let program = serialized_single_function_module(
code,
vec![
SyscallDecl {
module: "composer".into(),
name: "bind_scene".into(),
version: 1,
arg_slots: 1,
ret_slots: 1,
},
SyscallDecl {
module: "composer".into(),
name: "set_camera".into(),
version: 1,
arg_slots: 2,
ret_slots: 0,
},
SyscallDecl {
module: "composer".into(),
name: "emit_sprite".into(),
version: 1,
arg_slots: 9,
ret_slots: 1,
},
],
);
let cartridge = cartridge_with_program(program, caps::GFX);
let banks = Arc::new(MemoryBanks::new());
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));
hardware.gfx.scene_fade_level = 31;
hardware.gfx.hud_fade_level = 31;
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(&mut vm, &signals, &mut hardware);
assert!(report.is_none(), "public composer path must not crash");
assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(ComposerOpStatus::Ok as i64)]);
hardware.gfx.present();
assert_eq!(hardware.gfx.front_buffer()[0], Color::BLUE.raw());
}
#[test]
fn tick_draw_text_survives_scene_backed_frame_composition() {
let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 0\nHOSTCALL 0\nPOP_N 1\n\
PUSH_I32 0\nPUSH_I32 0\nHOSTCALL 1\n\
PUSH_I32 0\nPUSH_I32 0\nPUSH_CONST 0\nPUSH_I32 65535\nHOSTCALL 2\n\
FRAME_SYNC\nHALT",
)
.expect("assemble");
let program = serialized_single_function_module_with_consts(
code,
vec![ConstantPoolEntry::String("I".into())],
vec![
SyscallDecl {
module: "composer".into(),
name: "bind_scene".into(),
version: 1,
arg_slots: 1,
ret_slots: 1,
},
SyscallDecl {
module: "composer".into(),
name: "set_camera".into(),
version: 1,
arg_slots: 2,
ret_slots: 0,
},
SyscallDecl {
module: "gfx".into(),
name: "draw_text".into(),
version: 1,
arg_slots: 4,
ret_slots: 0,
},
],
);
let cartridge = cartridge_with_program(program, caps::GFX);
let banks = Arc::new(MemoryBanks::new());
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));
hardware.gfx.scene_fade_level = 31;
hardware.gfx.hud_fade_level = 31;
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(&mut vm, &signals, &mut hardware);
assert!(report.is_none(), "scene-backed overlay text must not crash");
hardware.gfx.present();
assert_eq!(hardware.gfx.front_buffer()[0], Color::WHITE.raw());
}
#[test]
fn tick_draw_text_survives_no_scene_frame_path() {
let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 0\nPUSH_I32 0\nPUSH_CONST 0\nPUSH_I32 65535\nHOSTCALL 0\nFRAME_SYNC\nHALT",
)
.expect("assemble");
let program = serialized_single_function_module_with_consts(
code,
vec![ConstantPoolEntry::String("I".into())],
vec![SyscallDecl {
module: "gfx".into(),
name: "draw_text".into(),
version: 1,
arg_slots: 4,
ret_slots: 0,
}],
);
let cartridge = cartridge_with_program(program, caps::GFX);
let mut hardware = Hardware::new();
hardware.gfx.scene_fade_level = 31;
hardware.gfx.hud_fade_level = 31;
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(&mut vm, &signals, &mut hardware);
assert!(report.is_none(), "no-scene overlay text must not crash");
hardware.gfx.present();
assert_eq!(hardware.gfx.front_buffer()[0], Color::WHITE.raw());
}
#[test] #[test]
fn initialize_vm_success_clears_previous_crash_report() { fn initialize_vm_success_clears_previous_crash_report() {
let mut runtime = VirtualMachineRuntime::new(None); let mut runtime = VirtualMachineRuntime::new(None);
@ -364,22 +574,19 @@ fn initialize_vm_failure_clears_previous_identity_and_handles() {
} }
#[test] #[test]
fn tick_gfx_set_sprite_operational_error_returns_status_not_crash() { fn tick_composer_bind_scene_operational_error_returns_status_not_crash() {
let mut runtime = VirtualMachineRuntime::new(None); let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default(); let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new(); let mut hardware = Hardware::new();
let signals = InputSignals::default(); let signals = InputSignals::default();
let code = assemble( let code = assemble("PUSH_I32 99\nHOSTCALL 0\nHALT").expect("assemble");
"PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 1\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT",
)
.expect("assemble");
let program = serialized_single_function_module( let program = serialized_single_function_module(
code, code,
vec![SyscallDecl { vec![SyscallDecl {
module: "gfx".into(), module: "composer".into(),
name: "set_sprite".into(), name: "bind_scene".into(),
version: 1, version: 1,
arg_slots: 10, arg_slots: 1,
ret_slots: 1, ret_slots: 1,
}], }],
); );
@ -389,26 +596,29 @@ fn tick_gfx_set_sprite_operational_error_returns_status_not_crash() {
let report = runtime.tick(&mut vm, &signals, &mut hardware); let report = runtime.tick(&mut vm, &signals, &mut hardware);
assert!(report.is_none(), "operational error must not crash"); assert!(report.is_none(), "operational error must not crash");
assert!(vm.is_halted()); assert!(vm.is_halted());
assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(GfxOpStatus::BankInvalid as i64)]); assert_eq!(
vm.operand_stack_top(1),
vec![Value::Int64(ComposerOpStatus::SceneUnavailable as i64)]
);
} }
#[test] #[test]
fn tick_gfx_set_sprite_invalid_index_returns_status_not_crash() { fn tick_composer_emit_sprite_operational_error_returns_status_not_crash() {
let mut runtime = VirtualMachineRuntime::new(None); let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default(); let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new(); let mut hardware = Hardware::new();
let signals = InputSignals::default(); let signals = InputSignals::default();
let code = assemble( let code = assemble(
"PUSH_I32 0\nPUSH_I32 512\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 1\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT", "PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT",
) )
.expect("assemble"); .expect("assemble");
let program = serialized_single_function_module( let program = serialized_single_function_module(
code, code,
vec![SyscallDecl { vec![SyscallDecl {
module: "gfx".into(), module: "composer".into(),
name: "set_sprite".into(), name: "emit_sprite".into(),
version: 1, version: 1,
arg_slots: 10, arg_slots: 9,
ret_slots: 1, ret_slots: 1,
}], }],
); );
@ -416,28 +626,57 @@ fn tick_gfx_set_sprite_invalid_index_returns_status_not_crash() {
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(&mut vm, &signals, &mut hardware); let report = runtime.tick(&mut vm, &signals, &mut hardware);
assert!(report.is_none(), "invalid sprite index must not crash"); assert!(report.is_none(), "operational error must not crash");
assert!(vm.is_halted()); assert!(vm.is_halted());
assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(GfxOpStatus::InvalidSpriteIndex as i64)]); assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(ComposerOpStatus::BankInvalid as i64)]);
} }
#[test] #[test]
fn tick_gfx_set_sprite_invalid_range_returns_status_not_crash() { fn tick_composer_emit_sprite_invalid_layer_returns_status_not_crash() {
let mut runtime = VirtualMachineRuntime::new(None); let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default(); let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new(); let mut hardware = Hardware::new();
let signals = InputSignals::default(); let signals = InputSignals::default();
let code = assemble( let code = assemble(
"PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 64\nPUSH_BOOL 1\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT", "PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 4\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT",
) )
.expect("assemble"); .expect("assemble");
let program = serialized_single_function_module( let program = serialized_single_function_module(
code, code,
vec![SyscallDecl { vec![SyscallDecl {
module: "gfx".into(), module: "composer".into(),
name: "set_sprite".into(), name: "emit_sprite".into(),
version: 1, version: 1,
arg_slots: 10, arg_slots: 9,
ret_slots: 1,
}],
);
let cartridge = cartridge_with_program(program, caps::GFX);
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(&mut vm, &signals, &mut hardware);
assert!(report.is_none(), "invalid layer must not crash");
assert!(vm.is_halted());
assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(ComposerOpStatus::LayerInvalid as i64)]);
}
#[test]
fn tick_composer_emit_sprite_invalid_range_returns_status_not_crash() {
let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let signals = InputSignals::default();
let code = assemble(
"PUSH_I32 0\nPUSH_I32 64\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT",
)
.expect("assemble");
let program = serialized_single_function_module(
code,
vec![SyscallDecl {
module: "composer".into(),
name: "emit_sprite".into(),
version: 1,
arg_slots: 9,
ret_slots: 1, ret_slots: 1,
}], }],
); );
@ -452,9 +691,12 @@ fn tick_gfx_set_sprite_invalid_range_returns_status_not_crash() {
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(&mut vm, &signals, &mut hardware); let report = runtime.tick(&mut vm, &signals, &mut hardware);
assert!(report.is_none(), "invalid gfx parameter range must not crash"); assert!(report.is_none(), "invalid composer parameter range must not crash");
assert!(vm.is_halted()); assert!(vm.is_halted());
assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(GfxOpStatus::ArgRangeInvalid as i64)]); assert_eq!(
vm.operand_stack_top(1),
vec![Value::Int64(ComposerOpStatus::ArgRangeInvalid as i64)]
);
} }
#[test] #[test]
@ -816,13 +1058,13 @@ fn tick_asset_cancel_invalid_transition_returns_status_not_crash() {
} }
#[test] #[test]
fn tick_status_first_surface_smoke_across_gfx_audio_and_asset() { fn tick_status_first_surface_smoke_across_composer_audio_and_asset() {
let mut runtime = VirtualMachineRuntime::new(None); let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default(); let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new(); let mut hardware = Hardware::new();
let signals = InputSignals::default(); let signals = InputSignals::default();
let code = assemble( let code = assemble(
"PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 1\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\n\ "PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\n\
PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 255\nPUSH_I32 128\nPUSH_I32 1\nPUSH_I32 0\nHOSTCALL 1\n\ PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 255\nPUSH_I32 128\nPUSH_I32 1\nPUSH_I32 0\nHOSTCALL 1\n\
PUSH_I32 999\nPUSH_I32 0\nHOSTCALL 2\n\ PUSH_I32 999\nPUSH_I32 0\nHOSTCALL 2\n\
HALT" HALT"
@ -832,10 +1074,10 @@ fn tick_status_first_surface_smoke_across_gfx_audio_and_asset() {
code, code,
vec![ vec![
SyscallDecl { SyscallDecl {
module: "gfx".into(), module: "composer".into(),
name: "set_sprite".into(), name: "emit_sprite".into(),
version: 1, version: 1,
arg_slots: 10, arg_slots: 9,
ret_slots: 1, ret_slots: 1,
}, },
SyscallDecl { SyscallDecl {
@ -866,28 +1108,28 @@ fn tick_status_first_surface_smoke_across_gfx_audio_and_asset() {
Value::Int64(0), Value::Int64(0),
Value::Int64(AssetLoadError::AssetNotFound as i64), Value::Int64(AssetLoadError::AssetNotFound as i64),
Value::Int64(AudioOpStatus::BankInvalid as i64), Value::Int64(AudioOpStatus::BankInvalid as i64),
Value::Int64(GfxOpStatus::BankInvalid as i64), Value::Int64(ComposerOpStatus::BankInvalid as i64),
] ]
); );
} }
#[test] #[test]
fn tick_gfx_set_sprite_type_mismatch_surfaces_trap_not_panic() { fn tick_composer_emit_sprite_type_mismatch_surfaces_trap_not_panic() {
let mut runtime = VirtualMachineRuntime::new(None); let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default(); let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new(); let mut hardware = Hardware::new();
let signals = InputSignals::default(); let signals = InputSignals::default();
let code = assemble( let code = assemble(
"PUSH_BOOL 1\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 1\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT", "PUSH_BOOL 1\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\nHALT",
) )
.expect("assemble"); .expect("assemble");
let program = serialized_single_function_module( let program = serialized_single_function_module(
code, code,
vec![SyscallDecl { vec![SyscallDecl {
module: "gfx".into(), module: "composer".into(),
name: "set_sprite".into(), name: "emit_sprite".into(),
version: 1, version: 1,
arg_slots: 10, arg_slots: 9,
ret_slots: 1, ret_slots: 1,
}], }],
); );

View File

@ -145,7 +145,7 @@ impl VirtualMachineRuntime {
if run.reason == LogicalFrameEndingReason::FrameSync if run.reason == LogicalFrameEndingReason::FrameSync
|| run.reason == LogicalFrameEndingReason::EndOfRom || run.reason == LogicalFrameEndingReason::EndOfRom
{ {
hw.gfx_mut().render_all(); hw.render_frame();
// 1. Snapshot full telemetry at logical frame end // 1. Snapshot full telemetry at logical frame end
let (glyph_bank, sound_bank) = Self::bank_telemetry_summary(hw); let (glyph_bank, sound_bank) = Self::bank_telemetry_summary(hw);
@ -250,6 +250,7 @@ impl VirtualMachineRuntime {
_signals: &InputSignals, _signals: &InputSignals,
hw: &mut dyn HardwareBridge, hw: &mut dyn HardwareBridge,
) { ) {
hw.begin_frame();
hw.audio_mut().clear_commands(); hw.audio_mut().clear_commands();
self.logs_written_this_frame.clear(); self.logs_written_this_frame.clear();
} }

View File

@ -2567,11 +2567,8 @@ mod tests {
#[test] #[test]
fn test_status_first_syscall_results_count_mismatch_panic() { fn test_status_first_syscall_results_count_mismatch_panic() {
// GfxSetSprite (0x1007) expects 1 result. // ComposerBindScene (0x1101) expects 1 result.
let code = assemble( let code = assemble("PUSH_I32 0\nSYSCALL 0x1101").expect("assemble");
"PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nSYSCALL 0x1007",
)
.expect("assemble");
struct BadNativeNoReturn; struct BadNativeNoReturn;
impl NativeInterface for BadNativeNoReturn { impl NativeInterface for BadNativeNoReturn {
@ -2921,10 +2918,24 @@ mod tests {
fn test_loader_patching_accepts_status_first_signatures() { fn test_loader_patching_accepts_status_first_signatures() {
let cases = vec![ let cases = vec![
SyscallDecl { SyscallDecl {
module: "gfx".into(), module: "composer".into(),
name: "set_sprite".into(), name: "bind_scene".into(),
version: 1, version: 1,
arg_slots: 10, arg_slots: 1,
ret_slots: 1,
},
SyscallDecl {
module: "composer".into(),
name: "unbind_scene".into(),
version: 1,
arg_slots: 0,
ret_slots: 1,
},
SyscallDecl {
module: "composer".into(),
name: "emit_sprite".into(),
version: 1,
arg_slots: 9,
ret_slots: 1, ret_slots: 1,
}, },
SyscallDecl { SyscallDecl {
@ -2977,10 +2988,10 @@ mod tests {
fn test_loader_patching_rejects_legacy_status_first_ret_slots() { fn test_loader_patching_rejects_legacy_status_first_ret_slots() {
let cases = vec![ let cases = vec![
SyscallDecl { SyscallDecl {
module: "gfx".into(), module: "composer".into(),
name: "set_sprite".into(), name: "bind_scene".into(),
version: 1, version: 1,
arg_slots: 10, arg_slots: 1,
ret_slots: 0, ret_slots: 0,
}, },
SyscallDecl { SyscallDecl {

View File

@ -5,4 +5,6 @@ edition = "2021"
[dependencies] [dependencies]
prometeu-bytecode = { path = "../../console/prometeu-bytecode" } prometeu-bytecode = { path = "../../console/prometeu-bytecode" }
prometeu-hal = { path = "../../console/prometeu-hal" }
anyhow = "1" anyhow = "1"
serde_json = "1"

View File

@ -3,7 +3,25 @@ use prometeu_bytecode::assembler::assemble;
use prometeu_bytecode::model::{ use prometeu_bytecode::model::{
BytecodeModule, ConstantPoolEntry, DebugInfo, Export, FunctionMeta, SyscallDecl, BytecodeModule, ConstantPoolEntry, DebugInfo, Export, FunctionMeta, SyscallDecl,
}; };
use prometeu_hal::asset::{
AssetCodec, AssetEntry, BankType, PreloadEntry, SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1,
SCENE_LAYER_COUNT_V1, SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1,
};
use prometeu_hal::cartridge::{
AssetsPackHeader, AssetsPackPrelude, ASSETS_PA_MAGIC, ASSETS_PA_PRELUDE_SIZE,
ASSETS_PA_SCHEMA_VERSION,
};
use prometeu_hal::color::Color;
use prometeu_hal::glyph::Glyph;
use prometeu_hal::glyph_bank::{
TileSize, GLYPH_BANK_COLORS_PER_PALETTE, GLYPH_BANK_PALETTE_COUNT_V1,
};
use prometeu_hal::scene_bank::SceneBank;
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::tile::Tile;
use prometeu_hal::tilemap::TileMap;
use std::fs; use std::fs;
use std::mem::size_of;
use std::path::PathBuf; use std::path::PathBuf;
fn asm(s: &str) -> Vec<u8> { fn asm(s: &str) -> Vec<u8> {
@ -20,13 +38,6 @@ pub fn generate() -> Result<()> {
arg_slots: 1, arg_slots: 1,
ret_slots: 0, ret_slots: 0,
}, },
SyscallDecl {
module: "gfx".into(),
name: "draw_disc".into(),
version: 1,
arg_slots: 5,
ret_slots: 0,
},
SyscallDecl { SyscallDecl {
module: "gfx".into(), module: "gfx".into(),
name: "draw_text".into(), name: "draw_text".into(),
@ -42,10 +53,24 @@ pub fn generate() -> Result<()> {
ret_slots: 0, ret_slots: 0,
}, },
SyscallDecl { SyscallDecl {
module: "gfx".into(), module: "composer".into(),
name: "set_sprite".into(), name: "bind_scene".into(),
version: 1, version: 1,
arg_slots: 10, arg_slots: 1,
ret_slots: 1,
},
SyscallDecl {
module: "composer".into(),
name: "set_camera".into(),
version: 1,
arg_slots: 2,
ret_slots: 0,
},
SyscallDecl {
module: "composer".into(),
name: "emit_sprite".into(),
version: 1,
arg_slots: 9,
ret_slots: 1, ret_slots: 1,
}, },
]; ];
@ -59,7 +84,7 @@ pub fn generate() -> Result<()> {
param_slots: 0, param_slots: 0,
local_slots: 2, local_slots: 2,
return_slots: 0, return_slots: 0,
max_stack_slots: 16, max_stack_slots: 32,
}]; }];
let module = BytecodeModule { let module = BytecodeModule {
@ -67,7 +92,8 @@ pub fn generate() -> Result<()> {
const_pool: vec![ const_pool: vec![
ConstantPoolEntry::String("stress".into()), ConstantPoolEntry::String("stress".into()),
ConstantPoolEntry::String("frame".into()), ConstantPoolEntry::String("frame".into()),
ConstantPoolEntry::String("missing_glyph_bank".into()), ConstantPoolEntry::String("overlay".into()),
ConstantPoolEntry::String("composer".into()),
], ],
functions, functions,
code: rom, code: rom,
@ -89,129 +115,332 @@ pub fn generate() -> Result<()> {
out_dir.push("stress-console"); out_dir.push("stress-console");
fs::create_dir_all(&out_dir)?; fs::create_dir_all(&out_dir)?;
fs::write(out_dir.join("program.pbx"), bytes)?; fs::write(out_dir.join("program.pbx"), bytes)?;
let assets_pa_path = out_dir.join("assets.pa"); fs::write(out_dir.join("assets.pa"), build_assets_pack()?)?;
if assets_pa_path.exists() { fs::write(out_dir.join("manifest.json"), b"{\n \"magic\": \"PMTU\",\n \"cartridge_version\": 1,\n \"app_id\": 1,\n \"title\": \"Stress Console\",\n \"app_version\": \"0.1.0\",\n \"app_mode\": \"Game\",\n \"capabilities\": [\"gfx\", \"log\", \"asset\"]\n}\n")?;
fs::remove_file(&assets_pa_path)?;
}
fs::write(out_dir.join("manifest.json"), b"{\n \"magic\": \"PMTU\",\n \"cartridge_version\": 1,\n \"app_id\": 1,\n \"title\": \"Stress Console\",\n \"app_version\": \"0.1.0\",\n \"app_mode\": \"Game\",\n \"capabilities\": [\"gfx\", \"log\"]\n}\n")?;
Ok(()) Ok(())
} }
#[allow(dead_code)]
fn heavy_load(rom: &mut Vec<u8>) { fn heavy_load(rom: &mut Vec<u8>) {
// Single function 0: main // Single function 0: main
// Everything runs here — no coroutines, no SPAWN, no YIELD. // Global 0 = frame counter
// // Global 1 = scene bound flag
// Global 0 = t (frame counter) // Local 0 = sprite row
// Local 0 = scratch // Local 1 = sprite col
// Local 1 = loop counter for discs
//
// Loop:
// t = (t + 1)
// clear screen
// draw 500 discs using t for animation
// draw 20 texts using t for animation
// RET (runtime handles the frame loop)
// --- init locals ---
// local 0: scratch
// local 1: loop counter for discs
rom.extend(asm("PUSH_I32 0\nSET_LOCAL 0\nPUSH_I32 0\nSET_LOCAL 1"));
// --- t = (t + 1) ---
// t is global 0 to persist across prepare_call resets
rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 1\nADD\nSET_GLOBAL 0")); rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 1\nADD\nSET_GLOBAL 0"));
// --- clear screen --- rom.extend(asm("GET_GLOBAL 1\nPUSH_I32 0\nEQ"));
let jif_bind_done_offset = rom.len() + 2;
rom.extend(asm("JMP_IF_FALSE 0"));
rom.extend(asm("PUSH_I32 0\nHOSTCALL 3\nPOP_N 1\nPUSH_I32 1\nSET_GLOBAL 1"));
let bind_done_target = rom.len() as u32;
rom.extend(asm("PUSH_I32 0\nHOSTCALL 0")); rom.extend(asm("PUSH_I32 0\nHOSTCALL 0"));
// --- call status-first syscall path once per frame and drop status ---
rom.extend(asm( rom.extend(asm(
"PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 4\nPOP_N 1", "GET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 192\nMOD\nGET_GLOBAL 0\nPUSH_I32 76\nMOD\nHOSTCALL 4",
)); ));
// --- draw 500 discs --- rom.extend(asm("PUSH_I32 0\nSET_LOCAL 0"));
rom.extend(asm("PUSH_I32 0\nSET_LOCAL 1")); let row_loop_start = rom.len() as u32;
let disc_loop_start = rom.len() as u32; rom.extend(asm("GET_LOCAL 0\nPUSH_I32 16\nLT"));
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 500\nLT")); let jif_row_end_offset = rom.len() + 2;
let jif_disc_end_offset = rom.len() + 2;
rom.extend(asm("JMP_IF_FALSE 0")); rom.extend(asm("JMP_IF_FALSE 0"));
// x = (t * (i+7) + i * 13) % 320
rom.extend(asm("GET_GLOBAL 0\nGET_LOCAL 1\nPUSH_I32 7\nADD\nMUL\nGET_LOCAL 1\nPUSH_I32 13\nMUL\nADD\nPUSH_I32 320\nMOD"));
// y = (t * (i+11) + i * 17) % 180
rom.extend(asm("GET_GLOBAL 0\nGET_LOCAL 1\nPUSH_I32 11\nADD\nMUL\nGET_LOCAL 1\nPUSH_I32 17\nMUL\nADD\nPUSH_I32 180\nMOD"));
// r = ( (i*13) % 20 ) + 5
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 13\nMUL\nPUSH_I32 20\nMOD\nPUSH_I32 5\nADD"));
// border color = (i * 1234) & 0xFFFF
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 1234\nMUL\nPUSH_I32 65535\nBIT_AND"));
// fill color = (i * 5678 + t) & 0xFFFF
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 5678\nMUL\nGET_GLOBAL 0\nADD\nPUSH_I32 65535\nBIT_AND"));
// HOSTCALL gfx.draw_disc (x, y, r, border, fill)
rom.extend(asm("HOSTCALL 1"));
// i++
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 1\nADD\nSET_LOCAL 1"));
let jmp_disc_loop_offset = rom.len() + 2;
rom.extend(asm("JMP 0"));
let disc_loop_end = rom.len() as u32;
// --- draw 20 texts ---
rom.extend(asm("PUSH_I32 0\nSET_LOCAL 1")); rom.extend(asm("PUSH_I32 0\nSET_LOCAL 1"));
let text_loop_start = rom.len() as u32; let col_loop_start = rom.len() as u32;
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 20\nLT")); rom.extend(asm("GET_LOCAL 1\nPUSH_I32 32\nLT"));
let jif_text_end_offset = rom.len() + 2; let jif_col_end_offset = rom.len() + 2;
rom.extend(asm("JMP_IF_FALSE 0")); rom.extend(asm("JMP_IF_FALSE 0"));
// x = (t * 3 + i * 40) % 320
rom.extend(asm( rom.extend(asm(
"GET_GLOBAL 0\nPUSH_I32 3\nMUL\nGET_LOCAL 1\nPUSH_I32 40\nMUL\nADD\nPUSH_I32 320\nMOD", "PUSH_I32 0\n\
GET_LOCAL 0\nPUSH_I32 32\nMUL\nGET_LOCAL 1\nADD\nGET_GLOBAL 0\nADD\nPUSH_I32 15\nMOD\nPUSH_I32 1\nADD\n\
GET_LOCAL 1\nPUSH_I32 10\nMUL\nGET_GLOBAL 0\nPUSH_I32 10\nMOD\nADD\nPUSH_I32 320\nMOD\n\
GET_LOCAL 0\nPUSH_I32 10\nMUL\nGET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 10\nMOD\nADD\nPUSH_I32 180\nMOD\n\
GET_LOCAL 0\nGET_LOCAL 1\nADD\nGET_GLOBAL 0\nADD\nPUSH_I32 4\nMOD\n\
PUSH_I32 0\n\
GET_LOCAL 1\nGET_GLOBAL 0\nADD\nPUSH_I32 1\nBIT_AND\nPUSH_I32 0\nNEQ\n\
GET_LOCAL 0\nGET_GLOBAL 0\nADD\nPUSH_I32 1\nBIT_AND\nPUSH_I32 0\nNEQ\n\
GET_LOCAL 0\nGET_LOCAL 1\nADD\nPUSH_I32 4\nMOD\n\
HOSTCALL 5\nPOP_N 1",
)); ));
// y = (i * 30 + t) % 180
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 30\nMUL\nGET_GLOBAL 0\nADD\nPUSH_I32 180\nMOD"));
// string (toggle between "stress" and "frame")
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 1\nBIT_AND\nPUSH_I32 0\nNEQ"));
let jif_text_alt_offset = rom.len() + 2;
rom.extend(asm("JMP_IF_FALSE 0"));
rom.extend(asm("PUSH_CONST 0")); // "stress"
let jmp_text_join_offset = rom.len() + 2;
rom.extend(asm("JMP 0"));
let text_alt_target = rom.len() as u32;
rom.extend(asm("PUSH_CONST 1")); // "frame"
let text_join_target = rom.len() as u32;
// color = (t * 10 + i * 1000) & 0xFFFF
rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 10\nMUL\nGET_LOCAL 1\nPUSH_I32 1000\nMUL\nADD\nPUSH_I32 65535\nBIT_AND"));
// HOSTCALL gfx.draw_text (x, y, str, color)
rom.extend(asm("HOSTCALL 2"));
// i++
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 1\nADD\nSET_LOCAL 1")); rom.extend(asm("GET_LOCAL 1\nPUSH_I32 1\nADD\nSET_LOCAL 1"));
let jmp_text_loop_offset = rom.len() + 2; let jmp_col_loop_offset = rom.len() + 2;
rom.extend(asm("JMP 0")); rom.extend(asm("JMP 0"));
let text_loop_end = rom.len() as u32; let col_loop_end = rom.len() as u32;
rom.extend(asm("GET_LOCAL 0\nPUSH_I32 1\nADD\nSET_LOCAL 0"));
let jmp_row_loop_offset = rom.len() + 2;
rom.extend(asm("JMP 0"));
let row_loop_end = rom.len() as u32;
rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 3\nMUL\nPUSH_I32 220\nMOD\n\
PUSH_I32 8\n\
PUSH_CONST 0\n\
GET_GLOBAL 0\nPUSH_I32 2047\nMUL\nPUSH_I32 65535\nBIT_AND\n\
HOSTCALL 1"));
rom.extend(asm("PUSH_I32 12\n\
GET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 120\nMOD\nPUSH_I32 24\nADD\n\
PUSH_CONST 1\n\
GET_GLOBAL 0\nPUSH_I32 4093\nMUL\nPUSH_I32 65535\nBIT_AND\n\
HOSTCALL 1"));
rom.extend(asm("PUSH_I32 220\n\
GET_GLOBAL 0\nPUSH_I32 5\nMUL\nPUSH_I32 140\nMOD\n\
PUSH_CONST 2\n\
GET_GLOBAL 0\nPUSH_I32 1237\nMUL\nPUSH_I32 65535\nBIT_AND\n\
HOSTCALL 1"));
rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 4\nMUL\nPUSH_I32 180\nMOD\nPUSH_I32 80\nADD\n\
GET_GLOBAL 0\nPUSH_I32 3\nMUL\nPUSH_I32 90\nMOD\nPUSH_I32 70\nADD\n\
PUSH_CONST 3\n\
GET_GLOBAL 0\nPUSH_I32 3001\nMUL\nPUSH_I32 65535\nBIT_AND\n\
HOSTCALL 1"));
// --- log every 60 frames ---
rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 60\nMOD\nPUSH_I32 0\nEQ")); rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 60\nMOD\nPUSH_I32 0\nEQ"));
let jif_log_offset = rom.len() + 2; let jif_log_offset = rom.len() + 2;
rom.extend(asm("JMP_IF_FALSE 0")); rom.extend(asm("JMP_IF_FALSE 0"));
rom.extend(asm("PUSH_I32 2\nPUSH_CONST 0\nHOSTCALL 3")); rom.extend(asm("PUSH_I32 2\nPUSH_CONST 0\nHOSTCALL 2"));
let after_log = rom.len() as u32; let after_log = rom.len() as u32;
// --- end of function ---
rom.extend(asm("FRAME_SYNC\nRET")); rom.extend(asm("FRAME_SYNC\nRET"));
// --- Patch jump targets ---
let patch = |buf: &mut Vec<u8>, imm_offset: usize, target: u32| { let patch = |buf: &mut Vec<u8>, imm_offset: usize, target: u32| {
buf[imm_offset..imm_offset + 4].copy_from_slice(&target.to_le_bytes()); buf[imm_offset..imm_offset + 4].copy_from_slice(&target.to_le_bytes());
}; };
patch(rom, jif_disc_end_offset, disc_loop_end); patch(rom, jif_bind_done_offset, bind_done_target);
patch(rom, jmp_disc_loop_offset, disc_loop_start); patch(rom, jif_row_end_offset, row_loop_end);
patch(rom, jif_col_end_offset, col_loop_end);
patch(rom, jif_text_end_offset, text_loop_end); patch(rom, jmp_col_loop_offset, col_loop_start);
patch(rom, jif_text_alt_offset, text_alt_target); patch(rom, jmp_row_loop_offset, row_loop_start);
patch(rom, jmp_text_join_offset, text_join_target);
patch(rom, jmp_text_loop_offset, text_loop_start);
patch(rom, jif_log_offset, after_log); patch(rom, jif_log_offset, after_log);
} }
fn build_assets_pack() -> Result<Vec<u8>> {
let (glyph_entry, glyph_payload) = build_glyph_asset();
let scene = build_scene_bank();
let scene_payload = encode_scene_payload(&scene);
let scene_entry = AssetEntry {
asset_id: 1,
asset_name: "stress_scene".into(),
bank_type: BankType::SCENE,
offset: glyph_payload.len() as u64,
size: scene_payload.len() as u64,
decoded_size: expected_scene_decoded_size(&scene) as u64,
codec: AssetCodec::None,
metadata: serde_json::json!({}),
};
let asset_table = vec![glyph_entry, scene_entry];
let preload =
vec![PreloadEntry { asset_id: 0, slot: 0 }, PreloadEntry { asset_id: 1, 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;
let prelude = AssetsPackPrelude {
magic: ASSETS_PA_MAGIC,
schema_version: ASSETS_PA_SCHEMA_VERSION,
header_len: header.len() as u32,
payload_offset,
flags: 0,
reserved: 0,
header_checksum: 0,
};
let mut bytes = prelude.to_bytes().to_vec();
bytes.extend_from_slice(&header);
bytes.extend_from_slice(&glyph_payload);
bytes.extend_from_slice(&scene_payload);
debug_assert_eq!(bytes.len(), payload_offset as usize + payload_len);
Ok(bytes)
}
fn build_glyph_asset() -> (AssetEntry, Vec<u8>) {
let pixel_indices = vec![1_u8; 8 * 8];
let mut payload = pack_4bpp(&pixel_indices);
payload.extend_from_slice(&build_palette_bytes());
let entry = AssetEntry {
asset_id: 0,
asset_name: "stress_square".into(),
bank_type: BankType::GLYPH,
offset: 0,
size: payload.len() as u64,
decoded_size: (8 * 8 + GLYPH_BANK_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * 2)
as u64,
codec: AssetCodec::None,
metadata: serde_json::json!({
"tile_size": 8,
"width": 8,
"height": 8,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1
}),
};
(entry, payload)
}
fn build_palette_bytes() -> Vec<u8> {
let mut bytes =
Vec::with_capacity(GLYPH_BANK_PALETTE_COUNT_V1 * GLYPH_BANK_COLORS_PER_PALETTE * 2);
for palette_id in 0..GLYPH_BANK_PALETTE_COUNT_V1 {
for color_index in 0..GLYPH_BANK_COLORS_PER_PALETTE {
let color = if color_index == 1 { stress_color(palette_id) } else { Color::BLACK };
bytes.extend_from_slice(&color.raw().to_le_bytes());
}
}
bytes
}
fn stress_color(palette_id: usize) -> Color {
let r = ((palette_id * 53) % 256) as u8;
let g = ((palette_id * 97 + 64) % 256) as u8;
let b = ((palette_id * 29 + 128) % 256) as u8;
Color::rgb(r, g, b)
}
fn pack_4bpp(indices: &[u8]) -> Vec<u8> {
let mut packed = Vec::with_capacity(indices.len().div_ceil(2));
for chunk in indices.chunks(2) {
let hi = chunk[0] & 0x0f;
let lo = chunk.get(1).copied().unwrap_or(0) & 0x0f;
packed.push((hi << 4) | lo);
}
packed
}
fn build_scene_bank() -> SceneBank {
let mut layers = std::array::from_fn(|layer_index| {
let mut tiles = vec![
Tile {
active: false,
glyph: Glyph { glyph_id: 0, palette_id: layer_index as u8 + 1 },
flip_x: false,
flip_y: false,
};
64 * 32
];
for step in 0..8 {
let x = 4 + step * 7 + layer_index * 2;
let y = 2 + step * 3 + layer_index * 2;
let index = y * 64 + x;
tiles[index] = Tile {
active: true,
glyph: Glyph { glyph_id: 0, palette_id: layer_index as u8 + 1 },
flip_x: false,
flip_y: false,
};
}
SceneLayer {
active: true,
glyph_bank_id: 0,
tile_size: TileSize::Size8,
parallax_factor: match layer_index {
0 => ParallaxFactor { x: 1.0, y: 1.0 },
1 => ParallaxFactor { x: 0.75, y: 0.75 },
2 => ParallaxFactor { x: 0.5, y: 0.5 },
_ => ParallaxFactor { x: 0.25, y: 0.25 },
},
tilemap: TileMap { width: 64, height: 32, tiles },
}
});
// Keep the farthest layer a bit sparser so the diagonal remains visually readable.
for step in 0..4 {
let x = 10 + step * 12;
let y = 4 + step * 5;
let index = y * 64 + x;
layers[3].tilemap.tiles[index].active = false;
}
SceneBank { layers }
}
fn expected_scene_decoded_size(scene: &SceneBank) -> usize {
scene
.layers
.iter()
.map(|layer| {
SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1 + layer.tilemap.tiles.len() * size_of::<Tile>()
})
.sum()
}
fn encode_scene_payload(scene: &SceneBank) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&SCENE_PAYLOAD_MAGIC_V1);
data.extend_from_slice(&SCENE_PAYLOAD_VERSION_V1.to_le_bytes());
data.extend_from_slice(&(SCENE_LAYER_COUNT_V1 as u16).to_le_bytes());
data.extend_from_slice(&0_u32.to_le_bytes());
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.push(layer.tile_size as u8);
data.push(0);
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());
data.extend_from_slice(&(layer.tilemap.height as u32).to_le_bytes());
data.extend_from_slice(&(layer.tilemap.tiles.len() as u32).to_le_bytes());
data.extend_from_slice(&0_u32.to_le_bytes());
for tile in &layer.tilemap.tiles {
let mut tile_flags = 0_u8;
if tile.active {
tile_flags |= 0b0000_0001;
}
if tile.flip_x {
tile_flags |= 0b0000_0010;
}
if tile.flip_y {
tile_flags |= 0b0000_0100;
}
data.push(tile_flags);
data.push(tile.glyph.palette_id);
data.extend_from_slice(&tile.glyph.glyph_id.to_le_bytes());
}
}
data
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn assets_pack_contains_preloaded_glyph_and_scene_assets() {
let bytes = build_assets_pack().expect("assets pack");
let prelude =
AssetsPackPrelude::from_bytes(&bytes[..ASSETS_PA_PRELUDE_SIZE]).expect("prelude");
assert_eq!(prelude.magic, ASSETS_PA_MAGIC);
assert_eq!(prelude.schema_version, ASSETS_PA_SCHEMA_VERSION);
let header_start = ASSETS_PA_PRELUDE_SIZE;
let header_end = header_start + prelude.header_len as usize;
let header: AssetsPackHeader =
serde_json::from_slice(&bytes[header_start..header_end]).expect("header");
assert_eq!(header.asset_table.len(), 2);
assert_eq!(header.preload.len(), 2);
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].slot, 0);
assert_eq!(header.preload[1].slot, 0);
assert_eq!(header.asset_table[0].offset, 0);
assert_eq!(header.asset_table[1].offset, header.asset_table[0].size);
assert_eq!(
bytes.len(),
prelude.payload_offset as usize
+ header.asset_table[0].size as usize
+ header.asset_table[1].size as usize
);
}
}

View File

@ -0,0 +1,29 @@
{"type":"meta","next_id":{"DSC":29,"AGD":29,"DEC":17,"PLN":30,"LSN":31,"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"}]}
{"type":"discussion","id":"DSC-0022","status":"done","ticket":"tile-bank-vs-glyph-bank-domain-naming","title":"Glyph Bank Domain Naming Contract","created_at":"2026-04-09","updated_at":"2026-04-10","tags":["gfx","runtime","naming","domain-model"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0025","file":"lessons/DSC-0022-glyph-bank-domain-naming/LSN-0025-rename-artifact-by-meaning-not-by-token.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0001","status":"done","ticket":"legacy-runtime-learn-import","title":"Import legacy runtime learn into discussion lessons","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["migration","tech-debt"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0001-prometeu-learn-index.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0002","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0002-historical-asset-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0003","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0003-historical-audio-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0004","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0004-historical-cartridge-boot-protocol-and-manifest-authority.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0005","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0005-historical-game-memcard-slots-surface-and-semantics.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0006","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0006-historical-gfx-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0007","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0007-historical-retired-fault-and-input-decisions.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0008","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0008-historical-vm-core-and-assets.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0009","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0009-mental-model-asset-management.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0010","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0010-mental-model-audio.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0011","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0011-mental-model-gfx.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0012","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0012-mental-model-input.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0013","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0013-mental-model-observability-and-debugging.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0014","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0014-mental-model-portability-and-cross-platform.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0015","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0015-mental-model-save-memory-and-memcard.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0016","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0016-mental-model-status-first-and-fault-thinking.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0017","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0017-mental-model-time-and-cycles.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0018","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0018-mental-model-touch.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
{"type":"discussion","id":"DSC-0002","status":"open","ticket":"runtime-edge-test-plan","title":"Agenda - Runtime Edge Test Plan","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0001","file":"workflow/agendas/AGD-0001-runtime-edge-test-plan.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0003","status":"open","ticket":"packed-cartridge-loader-pmc","title":"Agenda - Packed Cartridge Loader PMC","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0002","file":"workflow/agendas/AGD-0002-packed-cartridge-loader-pmc.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0004","status":"open","ticket":"system-run-cart","title":"Agenda - System Run Cart","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0003","file":"workflow/agendas/AGD-0003-system-run-cart.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0005","status":"open","ticket":"system-fault-semantics-and-control-surface","title":"Agenda - System Fault Semantics and Control Surface","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0004","file":"workflow/agendas/AGD-0004-system-fault-semantics-and-control-surface.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0006","status":"open","ticket":"vm-owned-random-service","title":"Agenda - VM-Owned Random Service","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0005","file":"workflow/agendas/AGD-0005-vm-owned-random-service.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0007","status":"open","ticket":"app-home-filesystem-surface-and-semantics","title":"Agenda - App Home Filesystem Surface and Semantics","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0006","file":"workflow/agendas/AGD-0006-app-home-filesystem-surface-and-semantics.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0008","status":"done","ticket":"perf-runtime-telemetry-hot-path","title":"Agenda - [PERF] Runtime Telemetry Hot Path","created_at":"2026-03-27","updated_at":"2026-04-10","tags":[],"agendas":[{"id":"AGD-0007","file":"workflow/agendas/AGD-0007-perf-runtime-telemetry-hot-path.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-10"}],"decisions":[{"id":"DEC-0005","file":"workflow/decisions/DEC-0005-perf-push-based-telemetry-model.md","status":"accepted","created_at":"2026-04-10","updated_at":"2026-04-10"}],"plans":[{"id":"PLN-0005","file":"workflow/plans/PLN-0005-perf-push-based-telemetry-implementation.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"lessons":[{"id":"LSN-0026","file":"lessons/DSC-0008-perf-runtime-telemetry-hot-path/LSN-0026-push-based-telemetry-model.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0009","status":"open","ticket":"perf-async-background-work-lanes-for-assets-and-fs","title":"Agenda - [PERF] Async Background Work Lanes for Assets and FS","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0008","file":"workflow/agendas/AGD-0008-perf-async-background-work-lanes-for-assets-and-fs.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0010","status":"open","ticket":"perf-host-desktop-frame-pacing-and-presentation","title":"Agenda - [PERF] Host Desktop Frame Pacing and Presentation","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0009","file":"workflow/agendas/AGD-0009-perf-host-desktop-frame-pacing-and-presentation.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0011","status":"open","ticket":"perf-gfx-render-pipeline-and-dirty-regions","title":"Agenda - [PERF] GFX Render Pipeline and Dirty Regions","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0010","file":"workflow/agendas/AGD-0010-perf-gfx-render-pipeline-and-dirty-regions.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0012","status":"open","ticket":"perf-runtime-introspection-syscalls","title":"Agenda - [PERF] Runtime Introspection Syscalls","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0011","file":"workflow/agendas/AGD-0011-perf-runtime-introspection-syscalls.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0013","status":"done","ticket":"perf-host-debug-overlay-isolation","title":"Agenda - [PERF] Host Debug Overlay Isolation","created_at":"2026-03-27","updated_at":"2026-04-10","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"lessons/DSC-0013-perf-host-debug-overlay-isolation/LSN-0027-host-debug-overlay-isolation.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0024","status":"done","ticket":"generic-memory-bank-slot-contract","title":"Agenda - Generic Memory Bank Slot Contract","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["runtime","asset","memory-bank","slots","host"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0029","file":"lessons/DSC-0024-generic-memory-bank-slot-contract/LSN-0029-slot-first-bank-telemetry-belongs-in-asset-manager.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0025","status":"done","ticket":"scene-bank-and-viewport-cache-refactor","title":"Scene Bank and Viewport Cache Refactor","created_at":"2026-04-11","updated_at":"2026-04-14","tags":["gfx","tilemap","runtime","render"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0030","file":"lessons/DSC-0025-scene-bank-and-viewport-cache-refactor/LSN-0030-canonical-scene-cache-and-resolver-split.md","status":"done","created_at":"2026-04-14","updated_at":"2026-04-14"}]}
{"type":"discussion","id":"DSC-0026","status":"open","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-15","tags":["gfx","runtime","render","camera","scene"],"agendas":[{"id":"AGD-0026","file":"workflow/agendas/AGD-0026-render-all-scene-cache-and-camera-integration.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15"}],"decisions":[{"id":"DEC-0014","file":"workflow/decisions/DEC-0014-frame-composer-render-integration.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15","ref_agenda":"AGD-0026"}],"plans":[{"id":"PLN-0017","file":"workflow/plans/PLN-0017-frame-composer-core-and-hardware-ownership.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0014"]},{"id":"PLN-0018","file":"workflow/plans/PLN-0018-sprite-controller-and-frame-emission-model.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-14","ref_decisions":["DEC-0014"]},{"id":"PLN-0019","file":"workflow/plans/PLN-0019-scene-binding-camera-and-scene-status.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0014"]},{"id":"PLN-0020","file":"workflow/plans/PLN-0020-cache-refresh-and-render-frame-path.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0014"]},{"id":"PLN-0021","file":"workflow/plans/PLN-0021-service-retirement-callsite-migration-and-regression.md","status":"accepted","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0014"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0027","status":"open","ticket":"frame-composer-public-syscall-surface","title":"Agenda - FrameComposer Public Syscall Surface","created_at":"2026-04-17","updated_at":"2026-04-17","tags":["gfx","runtime","syscall","abi","frame-composer","scene","camera","sprites"],"agendas":[{"id":"AGD-0027","file":"workflow/agendas/AGD-0027-frame-composer-public-syscall-surface.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17"}],"decisions":[{"id":"DEC-0015","file":"workflow/decisions/DEC-0015-frame-composer-public-syscall-surface.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_agenda":"AGD-0027"}],"plans":[{"id":"PLN-0022","file":"workflow/plans/PLN-0022-composer-syscall-domain-and-spec-propagation.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0023","file":"workflow/plans/PLN-0023-composer-runtime-dispatch-and-legacy-removal.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0024","file":"workflow/plans/PLN-0024-composer-cartridge-tooling-and-regression-migration.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0025","file":"workflow/plans/PLN-0025-final-ci-validation-and-polish.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0028","status":"open","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":[{"id":"AGD-0028","file":"workflow/agendas/AGD-0028-deferred-overlay-and-primitive-composition.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18"}],"decisions":[{"id":"DEC-0016","file":"workflow/decisions/DEC-0016-deferred-gfx-overlay-outside-frame-composer.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_agenda":"AGD-0028"}],"plans":[{"id":"PLN-0026","file":"workflow/plans/PLN-0026-gfx-overlay-contract-and-spec-propagation.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0027","file":"workflow/plans/PLN-0027-deferred-gfx-overlay-subsystem.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0028","file":"workflow/plans/PLN-0028-runtime-frame-end-overlay-integration-and-parity.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0029","file":"workflow/plans/PLN-0029-final-overlay-ci-validation-and-polish.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0014","status":"open","ticket":"perf-vm-allocation-and-copy-pressure","title":"Agenda - [PERF] VM Allocation and Copy Pressure","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0013","file":"workflow/agendas/AGD-0013-perf-vm-allocation-and-copy-pressure.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0015","status":"open","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0014","file":"workflow/agendas/AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"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-0017","status":"done","ticket":"asset-entry-metadata-normalization-contract","title":"Asset Entry Metadata Normalization Contract","created_at":"2026-03-27","updated_at":"2026-04-09","tags":[],"agendas":[{"id":"AGD-0016","file":"workflow/agendas/AGD-0016-asset-entry-metadata-normalization-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-09"}],"decisions":[{"id":"DEC-0004","file":"workflow/decisions/DEC-0004-asset-entry-metadata-normalization-contract.md","status":"accepted","created_at":"2026-04-09","updated_at":"2026-04-09"}],"plans":[],"lessons":[{"id":"LSN-0023","file":"lessons/DSC-0017-asset-metadata-normalization/LSN-0023-typed-asset-metadata-helpers.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}
{"type":"discussion","id":"DSC-0018","status":"done","ticket":"asset-load-asset-id-int-contract","title":"Asset Load Asset ID Int Contract","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["asset","runtime","abi"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0019","file":"lessons/DSC-0018-asset-load-asset-id-int-contract/LSN-0019-asset-load-id-abi-convergence.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
{"type":"discussion","id":"DSC-0019","status":"done","ticket":"jenkinsfile-correction","title":"Jenkinsfile Correction and Relocation","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["ci","jenkins"],"agendas":[{"id":"AGD-0017","file":"workflow/agendas/AGD-0017-jenkinsfile-correction.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0002","file":"workflow/decisions/DEC-0002-jenkinsfile-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0002","file":"workflow/plans/PLN-0002-jenkinsfile-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0020","file":"lessons/DSC-0019-jenkins-ci-standardization/LSN-0020-jenkins-standard-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}

View File

@ -0,0 +1,29 @@
{"type":"meta","next_id":{"DSC":29,"AGD":29,"DEC":17,"PLN":30,"LSN":32,"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"}]}
{"type":"discussion","id":"DSC-0022","status":"done","ticket":"tile-bank-vs-glyph-bank-domain-naming","title":"Glyph Bank Domain Naming Contract","created_at":"2026-04-09","updated_at":"2026-04-10","tags":["gfx","runtime","naming","domain-model"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0025","file":"lessons/DSC-0022-glyph-bank-domain-naming/LSN-0025-rename-artifact-by-meaning-not-by-token.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0001","status":"done","ticket":"legacy-runtime-learn-import","title":"Import legacy runtime learn into discussion lessons","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["migration","tech-debt"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0001-prometeu-learn-index.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0002","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0002-historical-asset-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0003","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0003-historical-audio-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0004","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0004-historical-cartridge-boot-protocol-and-manifest-authority.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0005","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0005-historical-game-memcard-slots-surface-and-semantics.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0006","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0006-historical-gfx-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0007","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0007-historical-retired-fault-and-input-decisions.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0008","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0008-historical-vm-core-and-assets.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0009","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0009-mental-model-asset-management.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0010","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0010-mental-model-audio.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0011","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0011-mental-model-gfx.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0012","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0012-mental-model-input.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0013","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0013-mental-model-observability-and-debugging.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0014","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0014-mental-model-portability-and-cross-platform.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0015","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0015-mental-model-save-memory-and-memcard.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0016","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0016-mental-model-status-first-and-fault-thinking.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0017","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0017-mental-model-time-and-cycles.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0018","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0018-mental-model-touch.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
{"type":"discussion","id":"DSC-0002","status":"open","ticket":"runtime-edge-test-plan","title":"Agenda - Runtime Edge Test Plan","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0001","file":"workflow/agendas/AGD-0001-runtime-edge-test-plan.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0003","status":"open","ticket":"packed-cartridge-loader-pmc","title":"Agenda - Packed Cartridge Loader PMC","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0002","file":"workflow/agendas/AGD-0002-packed-cartridge-loader-pmc.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0004","status":"open","ticket":"system-run-cart","title":"Agenda - System Run Cart","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0003","file":"workflow/agendas/AGD-0003-system-run-cart.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0005","status":"open","ticket":"system-fault-semantics-and-control-surface","title":"Agenda - System Fault Semantics and Control Surface","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0004","file":"workflow/agendas/AGD-0004-system-fault-semantics-and-control-surface.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0006","status":"open","ticket":"vm-owned-random-service","title":"Agenda - VM-Owned Random Service","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0005","file":"workflow/agendas/AGD-0005-vm-owned-random-service.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0007","status":"open","ticket":"app-home-filesystem-surface-and-semantics","title":"Agenda - App Home Filesystem Surface and Semantics","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0006","file":"workflow/agendas/AGD-0006-app-home-filesystem-surface-and-semantics.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0008","status":"done","ticket":"perf-runtime-telemetry-hot-path","title":"Agenda - [PERF] Runtime Telemetry Hot Path","created_at":"2026-03-27","updated_at":"2026-04-10","tags":[],"agendas":[{"id":"AGD-0007","file":"workflow/agendas/AGD-0007-perf-runtime-telemetry-hot-path.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-10"}],"decisions":[{"id":"DEC-0005","file":"workflow/decisions/DEC-0005-perf-push-based-telemetry-model.md","status":"accepted","created_at":"2026-04-10","updated_at":"2026-04-10"}],"plans":[{"id":"PLN-0005","file":"workflow/plans/PLN-0005-perf-push-based-telemetry-implementation.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"lessons":[{"id":"LSN-0026","file":"lessons/DSC-0008-perf-runtime-telemetry-hot-path/LSN-0026-push-based-telemetry-model.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0009","status":"open","ticket":"perf-async-background-work-lanes-for-assets-and-fs","title":"Agenda - [PERF] Async Background Work Lanes for Assets and FS","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0008","file":"workflow/agendas/AGD-0008-perf-async-background-work-lanes-for-assets-and-fs.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0010","status":"open","ticket":"perf-host-desktop-frame-pacing-and-presentation","title":"Agenda - [PERF] Host Desktop Frame Pacing and Presentation","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0009","file":"workflow/agendas/AGD-0009-perf-host-desktop-frame-pacing-and-presentation.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0011","status":"open","ticket":"perf-gfx-render-pipeline-and-dirty-regions","title":"Agenda - [PERF] GFX Render Pipeline and Dirty Regions","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0010","file":"workflow/agendas/AGD-0010-perf-gfx-render-pipeline-and-dirty-regions.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0012","status":"open","ticket":"perf-runtime-introspection-syscalls","title":"Agenda - [PERF] Runtime Introspection Syscalls","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0011","file":"workflow/agendas/AGD-0011-perf-runtime-introspection-syscalls.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0013","status":"done","ticket":"perf-host-debug-overlay-isolation","title":"Agenda - [PERF] Host Debug Overlay Isolation","created_at":"2026-03-27","updated_at":"2026-04-10","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"lessons/DSC-0013-perf-host-debug-overlay-isolation/LSN-0027-host-debug-overlay-isolation.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0024","status":"done","ticket":"generic-memory-bank-slot-contract","title":"Agenda - Generic Memory Bank Slot Contract","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["runtime","asset","memory-bank","slots","host"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0029","file":"lessons/DSC-0024-generic-memory-bank-slot-contract/LSN-0029-slot-first-bank-telemetry-belongs-in-asset-manager.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0025","status":"done","ticket":"scene-bank-and-viewport-cache-refactor","title":"Scene Bank and Viewport Cache Refactor","created_at":"2026-04-11","updated_at":"2026-04-14","tags":["gfx","tilemap","runtime","render"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0030","file":"lessons/DSC-0025-scene-bank-and-viewport-cache-refactor/LSN-0030-canonical-scene-cache-and-resolver-split.md","status":"done","created_at":"2026-04-14","updated_at":"2026-04-14"}]}
{"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":"open","ticket":"frame-composer-public-syscall-surface","title":"Agenda - FrameComposer Public Syscall Surface","created_at":"2026-04-17","updated_at":"2026-04-17","tags":["gfx","runtime","syscall","abi","frame-composer","scene","camera","sprites"],"agendas":[{"id":"AGD-0027","file":"workflow/agendas/AGD-0027-frame-composer-public-syscall-surface.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17"}],"decisions":[{"id":"DEC-0015","file":"workflow/decisions/DEC-0015-frame-composer-public-syscall-surface.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_agenda":"AGD-0027"}],"plans":[{"id":"PLN-0022","file":"workflow/plans/PLN-0022-composer-syscall-domain-and-spec-propagation.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0023","file":"workflow/plans/PLN-0023-composer-runtime-dispatch-and-legacy-removal.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0024","file":"workflow/plans/PLN-0024-composer-cartridge-tooling-and-regression-migration.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]},{"id":"PLN-0025","file":"workflow/plans/PLN-0025-final-ci-validation-and-polish.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0015"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0028","status":"open","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":[{"id":"AGD-0028","file":"workflow/agendas/AGD-0028-deferred-overlay-and-primitive-composition.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18"}],"decisions":[{"id":"DEC-0016","file":"workflow/decisions/DEC-0016-deferred-gfx-overlay-outside-frame-composer.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_agenda":"AGD-0028"}],"plans":[{"id":"PLN-0026","file":"workflow/plans/PLN-0026-gfx-overlay-contract-and-spec-propagation.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0027","file":"workflow/plans/PLN-0027-deferred-gfx-overlay-subsystem.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0028","file":"workflow/plans/PLN-0028-runtime-frame-end-overlay-integration-and-parity.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]},{"id":"PLN-0029","file":"workflow/plans/PLN-0029-final-overlay-ci-validation-and-polish.md","status":"accepted","created_at":"2026-04-18","updated_at":"2026-04-18","ref_decisions":["DEC-0016"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0014","status":"open","ticket":"perf-vm-allocation-and-copy-pressure","title":"Agenda - [PERF] VM Allocation and Copy Pressure","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0013","file":"workflow/agendas/AGD-0013-perf-vm-allocation-and-copy-pressure.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0015","status":"open","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0014","file":"workflow/agendas/AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"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-0017","status":"done","ticket":"asset-entry-metadata-normalization-contract","title":"Asset Entry Metadata Normalization Contract","created_at":"2026-03-27","updated_at":"2026-04-09","tags":[],"agendas":[{"id":"AGD-0016","file":"workflow/agendas/AGD-0016-asset-entry-metadata-normalization-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-09"}],"decisions":[{"id":"DEC-0004","file":"workflow/decisions/DEC-0004-asset-entry-metadata-normalization-contract.md","status":"accepted","created_at":"2026-04-09","updated_at":"2026-04-09"}],"plans":[],"lessons":[{"id":"LSN-0023","file":"lessons/DSC-0017-asset-metadata-normalization/LSN-0023-typed-asset-metadata-helpers.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}
{"type":"discussion","id":"DSC-0018","status":"done","ticket":"asset-load-asset-id-int-contract","title":"Asset Load Asset ID Int Contract","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["asset","runtime","abi"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0019","file":"lessons/DSC-0018-asset-load-asset-id-int-contract/LSN-0019-asset-load-id-abi-convergence.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
{"type":"discussion","id":"DSC-0019","status":"done","ticket":"jenkinsfile-correction","title":"Jenkinsfile Correction and Relocation","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["ci","jenkins"],"agendas":[{"id":"AGD-0017","file":"workflow/agendas/AGD-0017-jenkinsfile-correction.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0002","file":"workflow/decisions/DEC-0002-jenkinsfile-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0002","file":"workflow/plans/PLN-0002-jenkinsfile-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0020","file":"lessons/DSC-0019-jenkins-ci-standardization/LSN-0020-jenkins-standard-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}

View File

@ -1,4 +1,4 @@
{"type":"meta","next_id":{"DSC":26,"AGD":26,"DEC":14,"PLN":17,"LSN":31,"CLSN":1}} {"type":"meta","next_id":{"DSC":29,"AGD":29,"DEC":17,"PLN":30,"LSN":34,"CLSN":1}}
{"type":"discussion","id":"DSC-0023","status":"done","ticket":"perf-full-migration-to-atomic-telemetry","title":"Agenda - [PERF] Full Migration to Atomic Telemetry","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["perf","runtime","telemetry"],"agendas":[{"id":"AGD-0021","file":"workflow/agendas/AGD-0021-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"decisions":[{"id":"DEC-0008","file":"workflow/decisions/DEC-0008-full-migration-to-atomic-telemetry.md","status":"accepted","created_at":"2026-04-10","updated_at":"2026-04-10"}],"plans":[{"id":"PLN-0007","file":"workflow/plans/PLN-0007-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"lessons":[{"id":"LSN-0028","file":"lessons/DSC-0023-perf-full-migration-to-atomic-telemetry/LSN-0028-converging-to-single-atomic-telemetry-source.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} {"type":"discussion","id":"DSC-0023","status":"done","ticket":"perf-full-migration-to-atomic-telemetry","title":"Agenda - [PERF] Full Migration to Atomic Telemetry","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["perf","runtime","telemetry"],"agendas":[{"id":"AGD-0021","file":"workflow/agendas/AGD-0021-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"decisions":[{"id":"DEC-0008","file":"workflow/decisions/DEC-0008-full-migration-to-atomic-telemetry.md","status":"accepted","created_at":"2026-04-10","updated_at":"2026-04-10"}],"plans":[{"id":"PLN-0007","file":"workflow/plans/PLN-0007-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"lessons":[{"id":"LSN-0028","file":"lessons/DSC-0023-perf-full-migration-to-atomic-telemetry/LSN-0028-converging-to-single-atomic-telemetry-source.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0020","status":"done","ticket":"jenkins-gitea-integration","title":"Jenkins Gitea Integration and Relocation","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["ci","jenkins","gitea"],"agendas":[{"id":"AGD-0018","file":"workflow/agendas/AGD-0018-jenkins-gitea-integration-and-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0003","file":"workflow/decisions/DEC-0003-jenkins-gitea-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0003","file":"workflow/plans/PLN-0003-jenkins-gitea-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0021","file":"lessons/DSC-0020-jenkins-gitea-integration/LSN-0021-jenkins-gitea-integration.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]} {"type":"discussion","id":"DSC-0020","status":"done","ticket":"jenkins-gitea-integration","title":"Jenkins Gitea Integration and Relocation","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["ci","jenkins","gitea"],"agendas":[{"id":"AGD-0018","file":"workflow/agendas/AGD-0018-jenkins-gitea-integration-and-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0003","file":"workflow/decisions/DEC-0003-jenkins-gitea-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0003","file":"workflow/plans/PLN-0003-jenkins-gitea-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0021","file":"lessons/DSC-0020-jenkins-gitea-integration/LSN-0021-jenkins-gitea-integration.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0021","status":"done","ticket":"asset-entry-codec-enum-with-metadata","title":"Asset Entry Codec Enum Contract","created_at":"2026-04-09","updated_at":"2026-04-09","tags":["asset","runtime","codec","metadata"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0024","file":"lessons/DSC-0021-asset-entry-codec-enum-contract/LSN-0024-string-on-the-wire-enum-in-runtime.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]} {"type":"discussion","id":"DSC-0021","status":"done","ticket":"asset-entry-codec-enum-with-metadata","title":"Asset Entry Codec Enum Contract","created_at":"2026-04-09","updated_at":"2026-04-09","tags":["asset","runtime","codec","metadata"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0024","file":"lessons/DSC-0021-asset-entry-codec-enum-contract/LSN-0024-string-on-the-wire-enum-in-runtime.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}
@ -18,6 +18,9 @@
{"type":"discussion","id":"DSC-0013","status":"done","ticket":"perf-host-debug-overlay-isolation","title":"Agenda - [PERF] Host Debug Overlay Isolation","created_at":"2026-03-27","updated_at":"2026-04-10","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"lessons/DSC-0013-perf-host-debug-overlay-isolation/LSN-0027-host-debug-overlay-isolation.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} {"type":"discussion","id":"DSC-0013","status":"done","ticket":"perf-host-debug-overlay-isolation","title":"Agenda - [PERF] Host Debug Overlay Isolation","created_at":"2026-03-27","updated_at":"2026-04-10","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"lessons/DSC-0013-perf-host-debug-overlay-isolation/LSN-0027-host-debug-overlay-isolation.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0024","status":"done","ticket":"generic-memory-bank-slot-contract","title":"Agenda - Generic Memory Bank Slot Contract","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["runtime","asset","memory-bank","slots","host"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0029","file":"lessons/DSC-0024-generic-memory-bank-slot-contract/LSN-0029-slot-first-bank-telemetry-belongs-in-asset-manager.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} {"type":"discussion","id":"DSC-0024","status":"done","ticket":"generic-memory-bank-slot-contract","title":"Agenda - Generic Memory Bank Slot Contract","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["runtime","asset","memory-bank","slots","host"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0029","file":"lessons/DSC-0024-generic-memory-bank-slot-contract/LSN-0029-slot-first-bank-telemetry-belongs-in-asset-manager.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0025","status":"done","ticket":"scene-bank-and-viewport-cache-refactor","title":"Scene Bank and Viewport Cache Refactor","created_at":"2026-04-11","updated_at":"2026-04-14","tags":["gfx","tilemap","runtime","render"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0030","file":"lessons/DSC-0025-scene-bank-and-viewport-cache-refactor/LSN-0030-canonical-scene-cache-and-resolver-split.md","status":"done","created_at":"2026-04-14","updated_at":"2026-04-14"}]} {"type":"discussion","id":"DSC-0025","status":"done","ticket":"scene-bank-and-viewport-cache-refactor","title":"Scene Bank and Viewport Cache Refactor","created_at":"2026-04-11","updated_at":"2026-04-14","tags":["gfx","tilemap","runtime","render"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0030","file":"lessons/DSC-0025-scene-bank-and-viewport-cache-refactor/LSN-0030-canonical-scene-cache-and-resolver-split.md","status":"done","created_at":"2026-04-14","updated_at":"2026-04-14"}]}
{"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-0014","status":"open","ticket":"perf-vm-allocation-and-copy-pressure","title":"Agenda - [PERF] VM Allocation and Copy Pressure","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0013","file":"workflow/agendas/AGD-0013-perf-vm-allocation-and-copy-pressure.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0014","status":"open","ticket":"perf-vm-allocation-and-copy-pressure","title":"Agenda - [PERF] VM Allocation and Copy Pressure","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0013","file":"workflow/agendas/AGD-0013-perf-vm-allocation-and-copy-pressure.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0015","status":"open","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0014","file":"workflow/agendas/AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0015","status":"open","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0014","file":"workflow/agendas/AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0016","status":"done","ticket":"tilemap-empty-cell-vs-tile-id-zero","title":"Tilemap Empty Cell vs Tile ID Zero","created_at":"2026-03-27","updated_at":"2026-04-09","tags":[],"agendas":[{"id":"AGD-0015","file":"workflow/agendas/AGD-0015-tilemap-empty-cell-vs-tile-id-zero.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-09"}],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0022","file":"lessons/DSC-0016-tilemap-empty-cell-semantics/LSN-0022-tilemap-empty-cell-convergence.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]} {"type":"discussion","id":"DSC-0016","status":"done","ticket":"tilemap-empty-cell-vs-tile-id-zero","title":"Tilemap Empty Cell vs Tile ID Zero","created_at":"2026-03-27","updated_at":"2026-04-09","tags":[],"agendas":[{"id":"AGD-0015","file":"workflow/agendas/AGD-0015-tilemap-empty-cell-vs-tile-id-zero.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-09"}],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0022","file":"lessons/DSC-0016-tilemap-empty-cell-semantics/LSN-0022-tilemap-empty-cell-convergence.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}

View File

@ -0,0 +1,74 @@
---
id: LSN-0031
ticket: render-all-scene-cache-and-camera-integration
title: Frame Composition Belongs Above the Render Backend
created: 2026-04-18
tags: [gfx, runtime, render, camera, scene, sprites, frame-composer]
---
## Context
`DSC-0025` split canonical scene ownership from viewport caching and resolver policy, but the runtime still treated `Gfx.render_all()` as the operational frame entrypoint. That left scene binding, camera state, cache refresh, and sprite submission spread across the wrong layer.
`DSC-0026` completed the integration by making `FrameComposer` the owner of frame orchestration and reducing `Gfx` to a backend that consumes already prepared render state.
## Key Decisions
### Frame Orchestration Must Not Live in the Backend
**What:**
`FrameComposer.render_frame()` became the canonical frame service, while `Gfx.render_all()` was retired from the runtime-facing flow.
**Why:**
The backend should execute composition, not decide scene binding, camera policy, cache refresh, or sprite lifecycle. Keeping those responsibilities above `Gfx` preserves a cleaner ownership model and avoids re-entangling policy with raster code.
**Trade-offs:**
This adds an explicit orchestration layer between runtime callsites and the renderer, but the resulting boundaries are easier to evolve and test.
### Scene Binding, Camera, Cache, and Sprites Form One Operational Unit
**What:**
`FrameComposer` owns:
- active scene binding by bank id and shared scene reference;
- camera coordinates in top-left world pixel space;
- `SceneViewportCache` and `SceneViewportResolver`;
- a frame-emission `SpriteController`.
**Why:**
These concerns all define what a frame is. Splitting them across multiple owners would recreate stale-state bugs and make no-scene behavior ambiguous.
**Trade-offs:**
The composer becomes a richer subsystem, but it carries policy in one place instead of leaking it into unrelated APIs.
### The World Path Must Stay Tile-Size Agnostic
**What:**
The integrated frame path derives cache sizing, resolver math, and world-copy behavior from per-layer scene metadata instead of a hard-coded `16x16` assumption.
**Why:**
The scene contract already allows canonical `8x8`, `16x16`, and `32x32` tile sizes. The frame service has to consume that contract faithfully or it becomes a hidden compatibility break.
**Trade-offs:**
The integration and tests need to exercise more than the legacy default path, but the renderer no longer bakes in a false invariant.
## Patterns and Algorithms
- Put frame policy in a dedicated orchestration layer and keep the renderer backend-oriented.
- Treat scene binding, camera state, cache lifetime, and sprite submission as one cohesive frame model.
- Refresh cache state inside the orchestrator before composition instead of letting the renderer discover refresh policy.
- Prefer frame-emission sprite submission with internal ordering over caller-owned sprite slots.
- Keep the no-scene path valid so world composition remains optional, not mandatory.
## Pitfalls
- Leaving `render_all()` alive as a canonical path creates a fragile dual-service model.
- Letting `Gfx` own cache refresh semantics collapses the boundary between policy and execution.
- Requiring a scene for every frame quietly breaks sprite-only or fade-only operation.
- Testing only `16x16` scenes hides regressions against valid `8x8` or `32x32` content.
## Takeaways
- Frame composition belongs in a subsystem that owns policy, not in the backend that draws pixels.
- Scene binding, camera, cache, resolver, and sprite submission should converge under one frame owner.
- No-scene rendering is part of the contract and should stay valid throughout integration work.
- Tile-size assumptions must be derived from canonical scene metadata, never from renderer habit.

View File

@ -0,0 +1,56 @@
---
id: LSN-0032
ticket: frame-composer-public-syscall-surface
title: Public ABI Must Follow the Canonical Service Boundary
created: 2026-04-18
tags: [gfx, runtime, syscall, abi, frame-composer, scene, camera, sprites]
---
## Context
`DSC-0026` finished the internal migration to `FrameComposer` as the canonical frame-orchestration owner, but the public VM-facing ABI still exposed part of that behavior through legacy `gfx`-domain calls. That left the codebase with a mismatch between the real runtime ownership model and the surface visible to cartridges, tooling, and syscall declarations.
`DSC-0027` closed that gap by introducing the `composer.*` public domain and removing the old public sprite path.
## Key Decisions
### Public Syscalls Must Expose the Real Owner
**What:**
Scene binding, camera control, and sprite emission now live under `composer.*`, and the legacy public `gfx.set_sprite` path is gone.
**Why:**
Once `FrameComposer` became the canonical orchestration service, keeping public orchestration under `gfx.*` would preserve the wrong mental model and encourage callers to treat the render backend as the owner of frame policy.
**Trade-offs:**
This forces migration across ABI declarations, runtime dispatch, tests, and tooling, but it removes the long-term cost of a misleading public boundary.
### Domain-Specific Status Types Preserve Architectural Meaning
**What:**
Mutating public composer operations return `ComposerOpStatus` instead of reusing backend-oriented status naming.
**Why:**
Operational outcomes for scene binding or sprite emission are not backend-domain results. Reusing `GfxOpStatus` would blur the boundary that the migration was trying to make explicit.
**Trade-offs:**
This adds one more status family to maintain, but it keeps the public ABI semantically aligned with the actual service contract.
## Patterns and Algorithms
- Promote internal ownership changes into the public ABI as part of the same migration thread.
- Use syscall domains to encode service boundaries, not just namespace aesthetics.
- Remove obsolete public fallbacks completely when they preserve the wrong operational model.
- Keep runtime dispatch, bytecode declarations, and tooling aligned so the public path is exercised end to end.
## Pitfalls
- Leaving a legacy public syscall alive after the internal model changes creates a dual-contract system that is harder to remove later.
- Migrating runtime dispatch without migrating declarations and tooling can leave hidden ABI drift in tests and generators.
- Reusing backend-specific status names in the wrong domain quietly leaks old ownership assumptions into new APIs.
## Takeaways
- The public ABI should mirror the canonical service boundary, not historical implementation leftovers.
- Namespace changes are architectural when they change who is responsible for a behavior.
- Removing a legacy public entrypoint is often safer than preserving a compatibility shim that encodes the wrong model.

View File

@ -0,0 +1,56 @@
---
id: LSN-0033
ticket: deferred-overlay-and-primitive-composition
title: Debug Primitives Should Be a Final Overlay, Not Part of Game Composition
created: 2026-04-18
tags: [gfx, runtime, render, frame-composer, overlay, primitives, hud, debug]
---
## Context
After `FrameComposer.render_frame()` became the canonical game-frame entrypoint, immediate `gfx.*` primitive writes were no longer stable. Scene-backed composition could rebuild the framebuffer after `draw_text(...)` or other debug primitives had already written to it.
`DSC-0028` resolved that conflict by moving `gfx.*` primitives into a deferred overlay/debug stage outside `FrameComposer`, drained only after canonical game composition and fades are complete.
## Key Decisions
### Debug Overlay Must Stay Outside the Canonical Game Pipeline
**What:**
`FrameComposer` keeps ownership of canonical game composition, while debug/text/primitive commands are captured separately and drained later as a final overlay.
**Why:**
Game composition and debug overlay have different purposes. The first must remain canonical and deterministic; the second must remain opportunistic, screen-space, and independent from scene or sprite semantics.
**Trade-offs:**
The renderer needs a second deferred path, but the game pipeline no longer depends on transient debug state.
### Final Visual Ordering Matters More Than Immediate Writes
**What:**
Overlay/debug commands are drained after scene composition, sprite composition, and fades, with parity between scene-bound and no-scene frame paths.
**Why:**
The stable user-visible contract is that debug primitives appear on top. Immediate writes were only an implementation detail, and they stopped preserving that contract once frame composition became deferred and canonical.
**Trade-offs:**
This changes primitive semantics from "write now" to "show at frame end," but it produces the behavior users actually rely on.
## Patterns and Algorithms
- Separate canonical composition state from debug-overlay state even when both reuse the same raster backend.
- Capture primitives as commands first, then drain them at the final stage where visual priority is unambiguous.
- Preserve the same overlay semantics whether a scene is bound or not.
- Keep implementation reuse internal while maintaining a clear semantic boundary in the public model.
## Pitfalls
- Treating debug primitives as part of HUD or scene composition will eventually couple tooling/debug behavior to gameplay pipeline rules.
- Draining overlay before fades or before final frame composition breaks the visible "always on top" contract.
- Reusing `FrameComposer` storage for overlay state collapses the ownership split that prevents these bugs.
## Takeaways
- Immediate framebuffer writes are not a reliable contract once final composition is orchestrated elsewhere.
- Debug primitives work best as a dedicated final overlay layer.
- Ownership separation is what keeps debug behavior stable while the canonical render pipeline evolves.

View File

@ -48,10 +48,12 @@ The GFX maintains two buffers:
Per-frame flow: Per-frame flow:
1. The system draws to the back buffer 1. The system prepares the logical frame
2. Calls `present()` 2. Canonical game composition is rendered into the back buffer
3. Buffers are swapped 3. Deferred final overlay/debug primitives are drained on top of the completed game frame
4. The host displays the front buffer 4. Calls `present()`
5. Buffers are swapped
6. The host displays the front buffer
This guarantees: This guarantees:
@ -183,7 +185,7 @@ Access:
--- ---
## 10. Projection to the Back Buffer ## 10. Canonical Game Projection to the Back Buffer
For each frame: For each frame:
@ -198,6 +200,10 @@ For each frame:
3. Draw HUD layer last 3. Draw HUD layer last
This section describes only the canonical game composition path.
`gfx.*` primitives such as `draw_text`, `draw_line`, and `draw_disc` are not part of this canonical game projection order. In v1 they belong to a deferred final overlay/debug stage that is drained after canonical game composition is complete.
--- ---
## 11. Drawing Order and Priority ## 11. Drawing Order and Priority
@ -214,6 +220,15 @@ Base order:
4. Tile Layer 3 4. Tile Layer 3
5. Sprites (by priority between layers) 5. Sprites (by priority between layers)
6. HUD Layer 6. HUD Layer
7. Scene Fade
8. HUD Fade
9. Deferred `gfx.*` overlay/debug primitives
Normative boundary:
- Items 1 through 8 belong to canonical game-frame composition.
- Item 9 is a separate overlay/debug stage.
- Deferred `gfx.*` primitives MUST NOT be interpreted as scene, sprite, or canonical HUD content.
--- ---
@ -258,8 +273,9 @@ Everything is:
## 14. Where Blend is Applied ## 14. Where Blend is Applied
- Blending occurs during drawing - Blending occurs during drawing
- The result goes directly to the back buffer - For canonical game composition, the result goes to the back buffer during composition
- There is no automatic post-composition - For deferred `gfx.*` overlay/debug primitives, the result is applied during the final overlay/debug drain stage
- There is no automatic GPU-style post-processing pipeline
--- ---
@ -296,6 +312,8 @@ controls:
- **Scene Fade**: affects the entire scene (Tile Layers 03 + Sprites) - **Scene Fade**: affects the entire scene (Tile Layers 03 + Sprites)
- **HUD Fade**: affects only the HUD Layer (always composed last) - **HUD Fade**: affects only the HUD Layer (always composed last)
In v1, deferred `gfx.*` overlay/debug primitives are drained after both fades and therefore are not themselves part of scene or HUD fade application.
The fade is implemented without continuous per-pixel alpha and without floats. The fade is implemented without continuous per-pixel alpha and without floats.
It uses a **discrete integer level** (0..31), which in practice produces an It uses a **discrete integer level** (0..31), which in practice produces an
"almost continuous" visual result in 320×180 pixel art. "almost continuous" visual result in 320×180 pixel art.
@ -536,7 +554,12 @@ The system can measure:
## 19. Syscall Return and Fault Policy ## 19. Syscall Return and Fault Policy
`gfx` follows status-first policy for operations with operational failure modes. Graphics-related public ABI in v1 is split between:
- `gfx.*` for direct drawing/backend-oriented operations;
- `composer.*` for frame orchestration operations.
Only operations with real operational rejection paths return explicit status values.
Fault boundary: Fault boundary:
@ -544,50 +567,62 @@ Fault boundary:
- `status`: operational failure; - `status`: operational failure;
- `Panic`: internal runtime invariant break only. - `Panic`: internal runtime invariant break only.
### 19.1 `gfx.set_sprite` ### 19.1 Return-shape matrix in v1
Return-shape matrix in v1: | Syscall | Return | Policy basis |
| ----------------------- | ------------- | --------------------------------------------------- |
| `gfx.clear` | `void` | no real operational failure path in v1 |
| `gfx.fill_rect` | `void` | no real operational failure path in v1 |
| `gfx.draw_line` | `void` | no real operational failure path in v1 |
| `gfx.draw_circle` | `void` | no real operational failure path in v1 |
| `gfx.draw_disc` | `void` | no real operational failure path in v1 |
| `gfx.draw_square` | `void` | no real operational failure path in v1 |
| `gfx.draw_text` | `void` | no real operational failure path in v1 |
| `gfx.clear_565` | `void` | no real operational failure path in v1 |
| `composer.bind_scene` | `status:int` | explicit orchestration-domain operational result |
| `composer.unbind_scene` | `status:int` | explicit orchestration-domain operational result |
| `composer.set_camera` | `void` | no real operational failure path in v1 |
| `composer.emit_sprite` | `status:int` | explicit orchestration-domain operational rejection |
| Syscall | Return | Policy basis | ### 19.1.a Deferred overlay/debug semantics for `gfx.*`
| ------------------ | ------------- | ---------------------------------------------------- |
| `gfx.clear` | `void` | no real operational failure path in v1 |
| `gfx.fill_rect` | `void` | no real operational failure path in v1 |
| `gfx.draw_line` | `void` | no real operational failure path in v1 |
| `gfx.draw_circle` | `void` | no real operational failure path in v1 |
| `gfx.draw_disc` | `void` | no real operational failure path in v1 |
| `gfx.draw_square` | `void` | no real operational failure path in v1 |
| `gfx.set_sprite` | `status:int` | operational rejection must be explicit |
| `gfx.draw_text` | `void` | no real operational failure path in v1 |
| `gfx.clear_565` | `void` | no real operational failure path in v1 |
Only `gfx.set_sprite` is status-returning in v1. The public `gfx.*` primitive family remains valid in v1, but its stable operational meaning is:
All other `gfx` syscalls remain `void` unless a future domain revision introduces a real operational failure path.
### 19.2 `gfx.set_sprite` - deferred final overlay/debug composition;
- screen-space and pipeline-agnostic relative to `composer.*`;
- outside `FrameComposer`;
- above scene, sprites, and canonical HUD;
- drained after `hud_fade`.
`gfx.set_sprite` returns `status:int`. 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.2 `composer.emit_sprite`
`composer.emit_sprite` returns `status:int`.
ABI: ABI:
1. `bank_id: int` — index of the tile bank 1. `glyph_id: int` — glyph index within the bank
2. `index: int` — sprite index (0..511) 2. `palette_id: int` — palette index
3. `x: int` — x coordinate 3. `x: int` — x coordinate
4. `y: int` — y coordinate 4. `y: int` — y coordinate
5. `tile_id: int` — tile index within the bank 5. `layer: int` — composition layer reference
6. `palette_id: int` — palette index (0..63) 6. `bank_id: int` — glyph bank index
7. `active: bool` — visibility toggle 7. `flip_x: bool` — horizontal flip
8. `flip_x: bool` — horizontal flip 8. `flip_y: bool` — vertical flip
9. `flip_y: bool` — vertical flip 9. `priority: int` — within-layer ordering priority
10. `priority: int` — layer priority (0..4)
Minimum status table: Minimum status table:
- `0` = `OK` - `0` = `OK`
- `2` = `INVALID_SPRITE_INDEX` - `1` = `SCENE_UNAVAILABLE`
- `3` = `INVALID_ARG_RANGE` - `2` = `INVALID_ARG_RANGE`
- `4` = `BANK_INVALID` - `3` = `BANK_INVALID`
- `4` = `LAYER_INVALID`
- `5` = `SPRITE_OVERFLOW`
Operational notes: Operational notes:
- no fallback to default bank when the sprite bank id cannot be resolved; - the canonical public sprite contract is frame-emission based;
- no silent no-op for invalid index/range; - no caller-provided sprite index exists in the v1 canonical ABI;
- `palette_id` and `priority` must be validated against runtime-supported ranges. - no `active` flag exists in the v1 canonical ABI;
- overflow remains non-fatal and must not escalate to trap in v1.

View File

@ -39,6 +39,7 @@ Example:
``` ```
("gfx", "present", 1) ("gfx", "present", 1)
("audio", "play", 2) ("audio", "play", 2)
("composer", "emit_sprite", 1)
``` ```
This identity is: This identity is:
@ -198,6 +199,24 @@ For `asset.load`:
- `slot` is the target slot index; - `slot` is the target slot index;
- bank kind is resolved from `asset_table` by `asset_id`, not supplied by the caller. - bank kind is resolved from `asset_table` by `asset_id`, not supplied by the caller.
### Composition surface (`composer`, v1)
The canonical frame-orchestration public ABI uses module `composer`.
Canonical operations in v1 are:
- `composer.bind_scene(bank_id) -> (status)`
- `composer.unbind_scene() -> (status)`
- `composer.set_camera(x, y) -> void`
- `composer.emit_sprite(glyph_id, palette_id, x, y, layer, bank_id, flip_x, flip_y, priority) -> (status)`
For mutating composer operations:
- `status` is a `ComposerOpStatus` value;
- `bind_scene`, `unbind_scene`, and `emit_sprite` are status-returning;
- `set_camera` remains `void` in v1;
- no caller-provided sprite index or `active` flag is part of the canonical contract.
## 7 Syscalls as Callable Entities (Not First-Class) ## 7 Syscalls as Callable Entities (Not First-Class)
Syscalls behave like call sites, not like first-class guest values. Syscalls behave like call sites, not like first-class guest values.

View File

@ -85,6 +85,9 @@ Example:
- `asset.load` currently resolves with `arg_slots = 2` and `ret_slots = 2`. - `asset.load` currently resolves with `arg_slots = 2` and `ret_slots = 2`.
- The canonical stack contract is `asset_id, slot -> status, handle`. - The canonical stack contract is `asset_id, slot -> status, handle`.
- Callers do not provide an explicit asset kind; the runtime derives it from `asset_table`. - Callers do not provide an explicit asset kind; the runtime derives it from `asset_table`.
- `composer.bind_scene` resolves with `arg_slots = 1` and `ret_slots = 1`.
- The canonical stack contract is `bank_id -> status`.
- `composer.emit_sprite` resolves with `arg_slots = 9` and `ret_slots = 1`.
#### Canonical Intrinsic Registry Artifact #### Canonical Intrinsic Registry Artifact

Binary file not shown.

View File

@ -5,5 +5,5 @@
"title": "Stress Console", "title": "Stress Console",
"app_version": "0.1.0", "app_version": "0.1.0",
"app_mode": "Game", "app_mode": "Game",
"capabilities": ["gfx", "log"] "capabilities": ["gfx", "log", "asset"]
} }