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
This commit is contained in:
bquarkz 2026-04-18 16:20:49 +00:00
commit 509b669db5
40 changed files with 2460 additions and 379 deletions

2
Cargo.lock generated
View File

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

View File

@ -14,7 +14,7 @@ use prometeu_hal::glyph::Glyph;
use prometeu_hal::glyph_bank::{GlyphBank, TileSize};
use prometeu_hal::sample::Sample;
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::tile::Tile;
use prometeu_hal::tilemap::TileMap;
@ -748,7 +748,7 @@ impl AssetManager {
active: false,
glyph_bank_id: 0,
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() },
});
let mut layers = layers;
@ -770,20 +770,20 @@ impl AssetManager {
32 => TileSize::Size32,
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 + 5],
buffer[offset + 6],
buffer[offset + 7],
]);
let motion_factor_y = f32::from_le_bytes([
let parallax_factor_y = f32::from_le_bytes([
buffer[offset + 8],
buffer[offset + 9],
buffer[offset + 10],
buffer[offset + 11],
]);
if !motion_factor_x.is_finite() || !motion_factor_y.is_finite() {
return Err("Invalid SCENE motion_factor".to_string());
if !parallax_factor_x.is_finite() || !parallax_factor_y.is_finite() {
return Err("Invalid SCENE parallax_factor".to_string());
}
let width = u32::from_le_bytes([
@ -847,7 +847,7 @@ impl AssetManager {
active: (flags & 0b0000_0001) != 0,
glyph_bank_id,
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 },
};
}
@ -1105,7 +1105,7 @@ mod tests {
SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1,
};
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::tilemap::TileMap;
@ -1144,11 +1144,11 @@ mod tests {
fn test_scene() -> SceneBank {
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,
glyph_bank_id,
tile_size,
motion_factor: MotionFactor { x: motion_x, y: motion_y },
parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
tilemap: TileMap {
width: 2,
height: 2,
@ -1227,8 +1227,8 @@ mod tests {
data.push(layer.glyph_bank_id);
data.push(layer.tile_size as u8);
data.push(0);
data.extend_from_slice(&layer.motion_factor.x.to_le_bytes());
data.extend_from_slice(&layer.motion_factor.y.to_le_bytes());
data.extend_from_slice(&layer.parallax_factor.x.to_le_bytes());
data.extend_from_slice(&layer.parallax_factor.y.to_le_bytes());
data.extend_from_slice(&(layer.tilemap.width as u32).to_le_bytes());
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());
@ -1359,7 +1359,7 @@ mod tests {
let decoded = AssetManager::decode_scene_bank_from_buffer(&entry, &data).expect("scene");
assert_eq!(decoded.layers[1].glyph_bank_id, 1);
assert_eq!(decoded.layers[1].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[0].tilemap.tiles[1].flip_x, 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 prometeu_hal::GfxBridge;
use prometeu_hal::color::Color;
@ -31,22 +32,11 @@ pub enum BlendMode {
/// PROMETEU Graphics Subsystem (GFX).
///
/// Models a specialized graphics chip with a fixed resolution, double buffering,
/// and a multi-layered tile/sprite architecture.
///
/// The GFX system works by composing several "layers" into a single 16-bit
/// RGB565 framebuffer. It supports hardware-accelerated primitives (lines, rects)
/// 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.
/// `Gfx` owns the framebuffer backend and the canonical game-frame raster path
/// consumed by `FrameComposer`. That canonical path covers scene composition,
/// sprite composition, and fades. Public `gfx.*` primitives remain valid, but
/// they do not define the canonical game composition contract; they belong to a
/// separate final overlay/debug stage.
pub struct Gfx {
/// Width of the internal framebuffer in pixels.
w: usize,
@ -54,11 +44,16 @@ pub struct Gfx {
h: usize,
/// Front buffer: the "VRAM" currently being displayed by the Host window.
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>,
/// Shared access to graphical memory banks (tiles and palettes).
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).
pub sprites: [Sprite; 512],
@ -71,12 +66,29 @@ pub struct Gfx {
/// Target color for the HUD fade effect.
pub hud_fade_color: Color,
/// Internal cache used to sort sprites into priority groups to optimize rendering.
priority_buckets: [Vec<usize>; 5],
/// Internal sprite count for the current frame state.
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];
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]
fn glyph_for_char(c: char) -> &'static [u8; 5] {
match c.to_ascii_uppercase() {
@ -207,12 +219,15 @@ impl GfxBridge for Gfx {
fn present(&mut self) {
self.present()
}
fn render_all(&mut self) {
self.render_all()
fn render_no_scene_frame(&mut self) {
self.render_no_scene_frame()
}
fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) {
self.render_scene_from_cache(cache, update)
}
fn 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) {
self.draw_text(x, y, text, color)
}
@ -224,6 +239,7 @@ impl GfxBridge for Gfx {
&self.sprites[index]
}
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]
}
@ -262,6 +278,7 @@ impl Gfx {
glyph: EMPTY_GLYPH,
x: 0,
y: 0,
layer: 0,
bank_id: 0,
active: false,
flip_x: false,
@ -276,13 +293,15 @@ impl Gfx {
front: vec![0; len],
back: vec![0; len],
glyph_banks,
overlay: DeferredGfxOverlay::default(),
is_draining_overlay: false,
sprites: [EMPTY_SPRITE; 512],
sprite_count: 0,
scene_fade_level: 31,
scene_fade_color: Color::BLACK,
hud_fade_level: 31,
hud_fade_color: Color::BLACK,
priority_buckets: [
Vec::with_capacity(128),
layer_buckets: [
Vec::with_capacity(128),
Vec::with_capacity(128),
Vec::with_capacity(128),
@ -295,6 +314,42 @@ impl Gfx {
(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).
pub fn front_buffer(&self) -> &[u16] {
&self.front
@ -314,6 +369,10 @@ impl Gfx {
color: Color,
mode: BlendMode,
) {
if !self.is_draining_overlay {
self.overlay.push(OverlayCommand::FillRectBlend { x, y, w, h, color, mode });
return;
}
if color == Color::COLOR_KEY {
return;
}
@ -355,6 +414,10 @@ impl Gfx {
/// 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) {
if !self.is_draining_overlay {
self.overlay.push(OverlayCommand::DrawLine { x0, y0, x1, y1, color });
return;
}
if color == Color::COLOR_KEY {
return;
}
@ -387,6 +450,10 @@ impl Gfx {
/// Draws a circle outline using Midpoint Circle Algorithm.
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 {
return;
}
@ -455,6 +522,10 @@ impl Gfx {
/// 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) {
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.draw_circle(x, y, r, border_color);
}
@ -484,6 +555,10 @@ impl Gfx {
border_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.draw_rect(x, y, w, h, border_color);
}
@ -530,23 +605,33 @@ impl Gfx {
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.
///
/// This method composes the final frame by rasterizing layers and sprites in the
/// correct priority order into the back buffer.
/// Follows the hardware model where layers and sprites are composed every frame.
pub fn render_all(&mut self) {
self.populate_priority_buckets();
// 1. Priority 0 sprites: drawn at the very back, behind everything else.
pub fn render_no_scene_frame(&mut self) {
self.populate_layer_buckets();
for bucket in &self.layer_buckets {
Self::draw_bucket_on_buffer(
&mut self.back,
self.w,
self.h,
&self.priority_buckets[0],
bucket,
&self.sprites,
&*self.glyph_banks,
);
}
// 2. Scene-only fallback path: sprites and fades still work even before a
// cache-backed world composition request is issued for the frame.
@ -563,18 +648,17 @@ impl Gfx {
/// plus sprite state and fade controls.
pub fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) {
self.back.fill(Color::BLACK.raw());
self.populate_priority_buckets();
self.populate_layer_buckets();
for layer_index in 0..cache.layers.len() {
Self::draw_bucket_on_buffer(
&mut self.back,
self.w,
self.h,
&self.priority_buckets[0],
&self.layer_buckets[layer_index],
&self.sprites,
&*self.glyph_banks,
);
for layer_index in 0..cache.layers.len() {
Self::draw_cache_layer_to_buffer(
&mut self.back,
self.w,
@ -583,31 +667,26 @@ impl Gfx {
&update.copy_requests[layer_index],
&*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.hud_fade_level, self.hud_fade_color);
}
fn populate_priority_buckets(&mut self) {
for bucket in self.priority_buckets.iter_mut() {
fn populate_layer_buckets(&mut self) {
for bucket in self.layer_buckets.iter_mut() {
bucket.clear();
}
for (idx, sprite) in self.sprites.iter().enumerate() {
if sprite.active && sprite.priority < 5 {
self.priority_buckets[sprite.priority as usize].push(idx);
for (idx, sprite) in self.sprites.iter().take(self.sprite_count).enumerate() {
if sprite.active && (sprite.layer as usize) < self.layer_buckets.len() {
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(
@ -618,6 +697,7 @@ impl Gfx {
request: &LayerCopyRequest,
glyph_banks: &dyn GlyphBankPoolAccess,
) {
let mut target = RenderTarget { back, screen_w, screen_h };
let layer_cache = &cache.layers[request.layer_index];
if !layer_cache.valid {
return;
@ -646,52 +726,43 @@ impl Gfx {
}
Self::draw_cached_tile_pixels(
back,
screen_w,
screen_h,
screen_tile_x,
screen_tile_y,
&mut target,
CachedTileDraw {
x: screen_tile_x,
y: screen_tile_y,
entry,
&bank,
request.tile_size,
bank: &bank,
tile_size: request.tile_size,
},
);
}
}
}
fn draw_cached_tile_pixels(
back: &mut [u16],
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;
fn draw_cached_tile_pixels(target: &mut RenderTarget<'_>, tile: CachedTileDraw<'_>) {
let size = tile.tile_size as usize;
for local_y in 0..size {
let world_y = y + local_y as i32;
if world_y < 0 || world_y >= screen_h as i32 {
let world_y = tile.y + local_y as i32;
if world_y < 0 || world_y >= target.screen_h as i32 {
continue;
}
for local_x in 0..size {
let world_x = x + local_x as i32;
if world_x < 0 || world_x >= screen_w as i32 {
let world_x = tile.x + local_x as i32;
if world_x < 0 || world_x >= target.screen_w as i32 {
continue;
}
let fetch_x = if 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 px_index = bank.get_pixel_index(entry.glyph_id, fetch_x, fetch_y);
let fetch_x = if tile.entry.flip_x() { size - 1 - local_x } else { local_x };
let fetch_y = if tile.entry.flip_y() { size - 1 - local_y } else { local_y };
let px_index = tile.bank.get_pixel_index(tile.entry.glyph_id, fetch_x, fetch_y);
if px_index == 0 {
continue;
}
let color = bank.resolve_color(entry.palette_id, px_index);
back[world_y as usize * screen_w + world_x as usize] = color.raw();
let color = tile.bank.resolve_color(tile.entry.palette_id, px_index);
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) {
if !self.is_draining_overlay {
self.overlay.push(OverlayCommand::DrawText { x, y, text: text.to_string(), color });
return;
}
let mut cx = x;
for c in text.chars() {
self.draw_char(cx, y, c, color);
@ -819,10 +894,11 @@ impl Gfx {
#[cfg(test)]
mod tests {
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::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_resolver::SceneViewportResolver;
use prometeu_hal::tile::Tile;
@ -853,7 +929,7 @@ mod tests {
active: true,
glyph_bank_id,
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,
@ -875,7 +951,7 @@ mod tests {
active: false,
glyph_bank_id,
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] },
}
}
@ -918,9 +994,12 @@ mod tests {
fn test_draw_line() {
let banks = Arc::new(MemoryBanks::new());
let mut gfx = Gfx::new(10, 10, banks);
gfx.begin_overlay_frame();
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[9 * 10 + 9], Color::WHITE.0);
assert_eq!(gfx.overlay().command_count(), 0);
}
#[test]
@ -946,11 +1025,54 @@ mod tests {
fn test_draw_square() {
let banks = Arc::new(MemoryBanks::new());
let mut gfx = Gfx::new(10, 10, banks);
gfx.begin_overlay_frame();
gfx.draw_square(2, 2, 6, 6, Color::WHITE, Color::BLACK);
gfx.drain_overlay_debug();
// Border
assert_eq!(gfx.back[2 * 10 + 2], Color::WHITE.0);
// Fill
assert_eq!(gfx.back[3 * 10 + 3], Color::BLACK.0);
assert_eq!(gfx.overlay().command_count(), 0);
}
#[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]
@ -1004,6 +1126,7 @@ mod tests {
glyph: Glyph { glyph_id: 0, palette_id: 4 },
x: 0,
y: 0,
layer: 0,
bank_id: 0,
active: true,
flip_x: false,
@ -1014,17 +1137,57 @@ mod tests {
glyph: Glyph { glyph_id: 0, palette_id: 4 },
x: 0,
y: 0,
layer: 2,
bank_id: 0,
active: true,
flip_x: false,
flip_y: false,
priority: 2,
};
gfx.sprite_count = 2;
gfx.render_scene_from_cache(&cache, &update);
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.

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::audio::Audio;
use crate::frame_composer::FrameComposer;
use crate::gfx::Gfx;
use crate::memory_banks::{
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller,
SoundBankPoolAccess, SoundBankPoolInstaller,
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess,
SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller,
};
use crate::pad::Pad;
use crate::touch::Touch;
use prometeu_hal::cartridge::AssetsPayloadSource;
use prometeu_hal::sprite::Sprite;
use prometeu_hal::{AssetBridge, AudioBridge, GfxBridge, HardwareBridge, PadBridge, TouchBridge};
use std::sync::Arc;
@ -26,6 +28,8 @@ use std::sync::Arc;
pub struct Hardware {
/// The Graphics Processing Unit (GPU). Handles drawing primitives, sprites, and tilemaps.
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.
pub audio: Audio,
/// 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 {
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 {
&self.gfx
}
@ -98,6 +132,11 @@ impl Hardware {
Self::H,
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>),
pad: Pad::default(),
touch: Touch::default(),
@ -122,7 +161,7 @@ mod tests {
use prometeu_hal::glyph::Glyph;
use prometeu_hal::glyph_bank::{GlyphBank, TileSize};
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_resolver::SceneViewportResolver;
use prometeu_hal::tile::Tile;
@ -142,7 +181,7 @@ mod tests {
active: true,
glyph_bank_id: 0,
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: 4,
height: 4,
@ -182,4 +221,20 @@ mod tests {
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 audio;
mod frame_composer;
mod gfx;
mod gfx_overlay;
pub mod hardware;
mod memory_banks;
mod pad;
@ -8,7 +10,9 @@ mod touch;
pub use crate::asset::AssetManager;
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_overlay::{DeferredGfxOverlay, OverlayCommand};
pub use crate::memory_banks::{
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess,
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_vertical_line(&mut self, x: i32, y0: i32, y1: i32, color: Color);
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 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_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::gfx_bridge::GfxBridge;
use crate::pad_bridge::PadBridge;
use crate::sprite::Sprite;
use crate::touch_bridge::TouchBridge;
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_mut(&mut self) -> &mut dyn GfxBridge;

View File

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

View File

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

View File

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

View File

@ -270,7 +270,7 @@ mod tests {
use super::*;
use crate::glyph::Glyph;
use crate::glyph_bank::TileSize;
use crate::scene_layer::MotionFactor;
use crate::scene_layer::ParallaxFactor;
use crate::tile::Tile;
use crate::tilemap::TileMap;
@ -295,7 +295,7 @@ mod tests {
active: true,
glyph_bank_id,
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 },
}
}
@ -325,6 +325,34 @@ mod tests {
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]
fn cache_entry_fields_are_derived_from_scene_tiles() {
let scene = make_scene();
@ -415,6 +443,113 @@ mod tests {
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]
fn materialization_populates_all_four_layers() {
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 = &scene.layers[i];
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_y_px = ((camera_y_px as f32) * layer.motion_factor.y).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.parallax_factor.y).floor() as i32;
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;
(
@ -388,14 +388,14 @@ mod tests {
use super::*;
use crate::glyph::Glyph;
use crate::glyph_bank::TileSize;
use crate::scene_layer::{MotionFactor, SceneLayer};
use crate::scene_layer::{ParallaxFactor, SceneLayer};
use crate::tile::Tile;
use crate::tilemap::TileMap;
fn make_layer(
tile_size: TileSize,
motion_x: f32,
motion_y: f32,
parallax_x: f32,
parallax_y: f32,
width: usize,
height: usize,
) -> SceneLayer {
@ -413,7 +413,7 @@ mod tests {
active: true,
glyph_bank_id: 1,
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 },
}
}
@ -443,7 +443,7 @@ mod tests {
}
#[test]
fn per_layer_copy_requests_follow_motion_factor() {
fn per_layer_copy_requests_follow_parallax_factor() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);

View File

@ -5,6 +5,7 @@ pub struct Sprite {
pub glyph: Glyph,
pub x: i32,
pub y: i32,
pub layer: u8,
pub bank_id: u8,
pub active: 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:
/// - **0x0xxx**: System & OS Control
/// - **0x1xxx**: Graphics (GFX)
/// - **0x11xx**: Frame Composer orchestration
/// - **0x2xxx**: Reserved for legacy input syscalls (disabled for v1 VM-owned input)
/// - **0x3xxx**: Audio (PCM & Mixing)
/// - **0x4xxx**: Filesystem (Sandboxed I/O)
@ -35,9 +36,12 @@ pub enum Syscall {
GfxDrawCircle = 0x1004,
GfxDrawDisc = 0x1005,
GfxDrawSquare = 0x1006,
GfxSetSprite = 0x1007,
GfxDrawText = 0x1008,
GfxClear565 = 0x1010,
ComposerBindScene = 0x1101,
ComposerUnbindScene = 0x1102,
ComposerSetCamera = 0x1103,
ComposerEmitSprite = 0x1104,
AudioPlaySample = 0x3001,
AudioPlay = 0x3002,
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)
.caps(caps::GFX)
.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")
.args(4)
.caps(caps::GFX)

View File

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

View File

@ -20,9 +20,12 @@ impl Syscall {
0x1004 => Some(Self::GfxDrawCircle),
0x1005 => Some(Self::GfxDrawDisc),
0x1006 => Some(Self::GfxDrawSquare),
0x1007 => Some(Self::GfxSetSprite),
0x1008 => Some(Self::GfxDrawText),
0x1010 => Some(Self::GfxClear565),
0x1101 => Some(Self::ComposerBindScene),
0x1102 => Some(Self::ComposerUnbindScene),
0x1103 => Some(Self::ComposerSetCamera),
0x1104 => Some(Self::ComposerEmitSprite),
0x3001 => Some(Self::AudioPlaySample),
0x3002 => Some(Self::AudioPlay),
0x4001 => Some(Self::FsOpen),
@ -68,9 +71,12 @@ impl Syscall {
Self::GfxDrawCircle => "GfxDrawCircle",
Self::GfxDrawDisc => "GfxDrawDisc",
Self::GfxDrawSquare => "GfxDrawSquare",
Self::GfxSetSprite => "GfxSetSprite",
Self::GfxDrawText => "GfxDrawText",
Self::GfxClear565 => "GfxClear565",
Self::ComposerBindScene => "ComposerBindScene",
Self::ComposerUnbindScene => "ComposerUnbindScene",
Self::ComposerSetCamera => "ComposerSetCamera",
Self::ComposerEmitSprite => "ComposerEmitSprite",
Self::AudioPlaySample => "AudioPlaySample",
Self::AudioPlay => "AudioPlay",
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]
fn resolver_enforces_capabilities() {
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.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);
assert_eq!(draw_text.arg_slots, 4);
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.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);
assert_eq!(audio_play_sample.arg_slots, 5);
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() {
let declared = vec![
prometeu_bytecode::SyscallDecl {
module: "gfx".into(),
name: "set_sprite".into(),
module: "composer".into(),
name: "bind_scene".into(),
version: 1,
arg_slots: 10,
arg_slots: 1,
ret_slots: 0,
},
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() {
let declared = vec![
prometeu_bytecode::SyscallDecl {
module: "gfx".into(),
name: "set_sprite".into(),
module: "composer".into(),
name: "bind_scene".into(),
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,
},
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[0].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[4].meta.ret_slots, 2);
assert_eq!(resolved[5].meta.ret_slots, 1);
}
#[test]

View File

@ -10,8 +10,8 @@ use prometeu_hal::sprite::Sprite;
use prometeu_hal::syscalls::Syscall;
use prometeu_hal::vm_fault::VmFault;
use prometeu_hal::{
AudioOpStatus, GfxOpStatus, HostContext, HostReturn, NativeInterface, SyscallId, expect_bool,
expect_int,
AudioOpStatus, ComposerOpStatus, HostContext, HostReturn, NativeInterface, SyscallId,
expect_bool, expect_int,
};
use std::sync::atomic::Ordering;
@ -56,6 +56,23 @@ impl VirtualMachineRuntime {
pub(crate) fn get_color(&self, value: i64) -> Color {
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 {
@ -135,46 +152,6 @@ impl NativeInterface for VirtualMachineRuntime {
hw.gfx_mut().draw_square(x, y, w, h, border_color, fill_color);
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 => {
let x = expect_int(args, 0)? 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));
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 => {
let sample_id_raw = expect_int(args, 0)?;
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::model::{BytecodeModule, ConstantPoolEntry, FunctionMeta, SyscallDecl};
use prometeu_drivers::hardware::Hardware;
use prometeu_drivers::{GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller};
use prometeu_hal::AudioOpStatus;
use prometeu_hal::GfxOpStatus;
use prometeu_hal::ComposerOpStatus;
use prometeu_hal::InputSignals;
use prometeu_hal::asset::{
AssetCodec, AssetEntry, AssetLoadError, AssetOpStatus, BankType, LoadStatus,
};
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::tile::Tile;
use prometeu_hal::tilemap::TileMap;
use prometeu_vm::VmInitError;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
#[derive(Default)]
@ -129,6 +137,40 @@ fn test_glyph_asset_data() -> Vec<u8> {
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]
fn initialize_vm_applies_cartridge_capabilities_before_loader_resolution() {
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 { .. })));
}
#[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]
fn initialize_vm_success_clears_previous_crash_report() {
let mut runtime = VirtualMachineRuntime::new(None);
@ -364,22 +574,19 @@ fn initialize_vm_failure_clears_previous_identity_and_handles() {
}
#[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 vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let signals = InputSignals::default();
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\nHALT",
)
.expect("assemble");
let code = assemble("PUSH_I32 99\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
code,
vec![SyscallDecl {
module: "gfx".into(),
name: "set_sprite".into(),
module: "composer".into(),
name: "bind_scene".into(),
version: 1,
arg_slots: 10,
arg_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);
assert!(report.is_none(), "operational error must not crash");
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]
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 vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let signals = InputSignals::default();
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");
let program = serialized_single_function_module(
code,
vec![SyscallDecl {
module: "gfx".into(),
name: "set_sprite".into(),
module: "composer".into(),
name: "emit_sprite".into(),
version: 1,
arg_slots: 10,
arg_slots: 9,
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");
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_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]
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 vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let signals = InputSignals::default();
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");
let program = serialized_single_function_module(
code,
vec![SyscallDecl {
module: "gfx".into(),
name: "set_sprite".into(),
module: "composer".into(),
name: "emit_sprite".into(),
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,
}],
);
@ -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");
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_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]
@ -816,13 +1058,13 @@ fn tick_asset_cancel_invalid_transition_returns_status_not_crash() {
}
#[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 vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let signals = InputSignals::default();
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 999\nPUSH_I32 0\nHOSTCALL 2\n\
HALT"
@ -832,10 +1074,10 @@ fn tick_status_first_surface_smoke_across_gfx_audio_and_asset() {
code,
vec![
SyscallDecl {
module: "gfx".into(),
name: "set_sprite".into(),
module: "composer".into(),
name: "emit_sprite".into(),
version: 1,
arg_slots: 10,
arg_slots: 9,
ret_slots: 1,
},
SyscallDecl {
@ -866,28 +1108,28 @@ fn tick_status_first_surface_smoke_across_gfx_audio_and_asset() {
Value::Int64(0),
Value::Int64(AssetLoadError::AssetNotFound as i64),
Value::Int64(AudioOpStatus::BankInvalid as i64),
Value::Int64(GfxOpStatus::BankInvalid as i64),
Value::Int64(ComposerOpStatus::BankInvalid as i64),
]
);
}
#[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 vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let signals = InputSignals::default();
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");
let program = serialized_single_function_module(
code,
vec![SyscallDecl {
module: "gfx".into(),
name: "set_sprite".into(),
module: "composer".into(),
name: "emit_sprite".into(),
version: 1,
arg_slots: 10,
arg_slots: 9,
ret_slots: 1,
}],
);

View File

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

View File

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

View File

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

View File

@ -3,7 +3,25 @@ use prometeu_bytecode::assembler::assemble;
use prometeu_bytecode::model::{
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::mem::size_of;
use std::path::PathBuf;
fn asm(s: &str) -> Vec<u8> {
@ -20,13 +38,6 @@ pub fn generate() -> Result<()> {
arg_slots: 1,
ret_slots: 0,
},
SyscallDecl {
module: "gfx".into(),
name: "draw_disc".into(),
version: 1,
arg_slots: 5,
ret_slots: 0,
},
SyscallDecl {
module: "gfx".into(),
name: "draw_text".into(),
@ -42,10 +53,24 @@ pub fn generate() -> Result<()> {
ret_slots: 0,
},
SyscallDecl {
module: "gfx".into(),
name: "set_sprite".into(),
module: "composer".into(),
name: "bind_scene".into(),
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,
},
];
@ -59,7 +84,7 @@ pub fn generate() -> Result<()> {
param_slots: 0,
local_slots: 2,
return_slots: 0,
max_stack_slots: 16,
max_stack_slots: 32,
}];
let module = BytecodeModule {
@ -67,7 +92,8 @@ pub fn generate() -> Result<()> {
const_pool: vec![
ConstantPoolEntry::String("stress".into()),
ConstantPoolEntry::String("frame".into()),
ConstantPoolEntry::String("missing_glyph_bank".into()),
ConstantPoolEntry::String("overlay".into()),
ConstantPoolEntry::String("composer".into()),
],
functions,
code: rom,
@ -89,129 +115,332 @@ pub fn generate() -> Result<()> {
out_dir.push("stress-console");
fs::create_dir_all(&out_dir)?;
fs::write(out_dir.join("program.pbx"), bytes)?;
let assets_pa_path = out_dir.join("assets.pa");
if assets_pa_path.exists() {
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")?;
fs::write(out_dir.join("assets.pa"), build_assets_pack()?)?;
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")?;
Ok(())
}
#[allow(dead_code)]
fn heavy_load(rom: &mut Vec<u8>) {
// Single function 0: main
// Everything runs here — no coroutines, no SPAWN, no YIELD.
//
// Global 0 = t (frame counter)
// Local 0 = scratch
// 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)
// Global 0 = frame counter
// Global 1 = scene bound flag
// Local 0 = sprite row
// Local 1 = sprite col
// --- 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"));
// --- 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"));
// --- call status-first syscall path once per frame and drop status ---
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 1"));
let disc_loop_start = rom.len() as u32;
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 500\nLT"));
let jif_disc_end_offset = rom.len() + 2;
rom.extend(asm("PUSH_I32 0\nSET_LOCAL 0"));
let row_loop_start = rom.len() as u32;
rom.extend(asm("GET_LOCAL 0\nPUSH_I32 16\nLT"));
let jif_row_end_offset = rom.len() + 2;
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"));
let text_loop_start = rom.len() as u32;
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 20\nLT"));
let jif_text_end_offset = rom.len() + 2;
let col_loop_start = rom.len() as u32;
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 32\nLT"));
let jif_col_end_offset = rom.len() + 2;
rom.extend(asm("JMP_IF_FALSE 0"));
// x = (t * 3 + i * 40) % 320
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"));
let jmp_text_loop_offset = rom.len() + 2;
let jmp_col_loop_offset = rom.len() + 2;
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"));
let jif_log_offset = rom.len() + 2;
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;
// --- end of function ---
rom.extend(asm("FRAME_SYNC\nRET"));
// --- Patch jump targets ---
let patch = |buf: &mut Vec<u8>, imm_offset: usize, target: u32| {
buf[imm_offset..imm_offset + 4].copy_from_slice(&target.to_le_bytes());
};
patch(rom, jif_disc_end_offset, disc_loop_end);
patch(rom, jmp_disc_loop_offset, disc_loop_start);
patch(rom, jif_text_end_offset, text_loop_end);
patch(rom, jif_text_alt_offset, text_alt_target);
patch(rom, jmp_text_join_offset, text_join_target);
patch(rom, jmp_text_loop_offset, text_loop_start);
patch(rom, jif_bind_done_offset, bind_done_target);
patch(rom, jif_row_end_offset, row_loop_end);
patch(rom, jif_col_end_offset, col_loop_end);
patch(rom, jmp_col_loop_offset, col_loop_start);
patch(rom, jmp_row_loop_offset, row_loop_start);
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-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"}]}
@ -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-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":"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-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"}]}

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:
1. The system draws to the back buffer
2. Calls `present()`
3. Buffers are swapped
4. The host displays the front buffer
1. The system prepares the logical frame
2. Canonical game composition is rendered into the back buffer
3. Deferred final overlay/debug primitives are drained on top of the completed game frame
4. Calls `present()`
5. Buffers are swapped
6. The host displays the front buffer
This guarantees:
@ -183,7 +185,7 @@ Access:
---
## 10. Projection to the Back Buffer
## 10. Canonical Game Projection to the Back Buffer
For each frame:
@ -198,6 +200,10 @@ For each frame:
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
@ -214,6 +220,15 @@ Base order:
4. Tile Layer 3
5. Sprites (by priority between layers)
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
- Blending occurs during drawing
- The result goes directly to the back buffer
- There is no automatic post-composition
- For canonical game composition, the result goes to the back buffer during 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)
- **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.
It uses a **discrete integer level** (0..31), which in practice produces an
"almost continuous" visual result in 320×180 pixel art.
@ -536,7 +554,12 @@ The system can measure:
## 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:
@ -544,50 +567,62 @@ Fault boundary:
- `status`: operational failure;
- `Panic`: internal runtime invariant break only.
### 19.1 `gfx.set_sprite`
Return-shape matrix in v1:
### 19.1 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.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 |
| `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 |
Only `gfx.set_sprite` is status-returning in v1.
All other `gfx` syscalls remain `void` unless a future domain revision introduces a real operational failure path.
### 19.1.a Deferred overlay/debug semantics for `gfx.*`
### 19.2 `gfx.set_sprite`
The public `gfx.*` primitive family remains valid in v1, but its stable operational meaning is:
`gfx.set_sprite` returns `status:int`.
- 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`.
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:
1. `bank_id: int` — index of the tile bank
2. `index: int` — sprite index (0..511)
1. `glyph_id: int` — glyph index within the bank
2. `palette_id: int` — palette index
3. `x: int` — x coordinate
4. `y: int` — y coordinate
5. `tile_id: int` — tile index within the bank
6. `palette_id: int` — palette index (0..63)
7. `active: bool` — visibility toggle
8. `flip_x: bool` — horizontal flip
9. `flip_y: bool` — vertical flip
10. `priority: int` — layer priority (0..4)
5. `layer: int` — composition layer reference
6. `bank_id: int` — glyph bank index
7. `flip_x: bool` — horizontal flip
8. `flip_y: bool` — vertical flip
9. `priority: int` — within-layer ordering priority
Minimum status table:
- `0` = `OK`
- `2` = `INVALID_SPRITE_INDEX`
- `3` = `INVALID_ARG_RANGE`
- `4` = `BANK_INVALID`
- `1` = `SCENE_UNAVAILABLE`
- `2` = `INVALID_ARG_RANGE`
- `3` = `BANK_INVALID`
- `4` = `LAYER_INVALID`
- `5` = `SPRITE_OVERFLOW`
Operational notes:
- no fallback to default bank when the sprite bank id cannot be resolved;
- no silent no-op for invalid index/range;
- `palette_id` and `priority` must be validated against runtime-supported ranges.
- the canonical public sprite contract is frame-emission based;
- no caller-provided sprite index exists in the v1 canonical ABI;
- 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)
("audio", "play", 2)
("composer", "emit_sprite", 1)
```
This identity is:
@ -198,6 +199,24 @@ For `asset.load`:
- `slot` is the target slot index;
- 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)
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`.
- 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`.
- `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

Binary file not shown.

View File

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