Compare commits

...

18 Commits

Author SHA1 Message Date
76254928e6
stress test cart fixes
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/pr-master This commit looks good
2026-04-18 16:24:20 +01:00
1c74631a5e
implements PLN-0029 2026-04-18 09:57:49 +01:00
aaed1e95dd
implements PLN-0028 2026-04-18 09:56:50 +01:00
2865cb3803
implements PLN-0027 2026-04-18 09:55:15 +01:00
4f52a65169
implements PLN-0026 2026-04-18 09:51:30 +01:00
b0b8bb9028
primitives pipeline adjustments 2026-04-18 09:50:13 +01:00
4a5210f347
implements PLN-0025 2026-04-17 19:45:03 +01:00
3fef407efc
implements PLN-0024 2026-04-17 17:56:37 +01:00
cc700c6cf8
implements PLN-0023 2026-04-17 17:55:04 +01:00
dd90ff812c
implements PLN-0022 2026-04-17 17:49:18 +01:00
240fe65da7
frame composer - abi adjustments 2026-04-17 17:43:25 +01:00
f4260d0cf4
adjustments over frame composer contract - agnostic tile size 2026-04-17 16:36:12 +01:00
a1bd60671b
implements PLN-0021 2026-04-17 13:32:11 +01:00
5ef43045bc
implements PLN-0020 2026-04-17 13:28:34 +01:00
3931e86b41
implements PLN-0019 2026-04-17 13:25:40 +01:00
5a0476e8b0
implements PLN-0018 2026-04-17 13:24:25 +01:00
ed05f337ce
implements PLN-0017 2026-04-17 13:19:03 +01:00
94c80e61ba
adjustments over frame composer contract - agnostic tile size 2026-04-17 13:09:27 +01:00
50 changed files with 3716 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.
Self::draw_bucket_on_buffer(
&mut self.back,
self.w,
self.h,
&self.priority_buckets[0],
&self.sprites,
&*self.glyph_banks,
);
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,
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::draw_bucket_on_buffer(
&mut self.back,
self.w,
self.h,
&self.priority_buckets[0],
&self.sprites,
&*self.glyph_banks,
);
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.layer_buckets[layer_index],
&self.sprites,
&*self.glyph_banks,
);
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,
entry,
&bank,
request.tile_size,
&mut target,
CachedTileDraw {
x: screen_tile_x,
y: screen_tile_y,
entry,
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

@ -1,4 +1,4 @@
{"type":"meta","next_id":{"DSC":27,"AGD":27,"DEC":15,"PLN":22,"LSN":31,"CLSN":1}}
{"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"}]}
@ -19,6 +19,8 @@
{"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"}]}

View File

@ -0,0 +1,214 @@
---
id: AGD-0027
ticket: frame-composer-public-syscall-surface
title: Agenda - FrameComposer Public Syscall Surface
status: accepted
created: 2026-04-17
updated: 2026-04-17
tags: [gfx, runtime, syscall, abi, frame-composer, scene, camera, sprites]
---
## Contexto
`DEC-0014` e os planos `PLN-0017` a `PLN-0021` fecharam a migração interna do pipeline de frame para `FrameComposer`:
- `FrameComposer` virou o orquestrador canônico do frame;
- `Hardware` passou a agregá-lo ao lado de `Gfx`;
- scene, camera, cache, resolver e sprite emission migraram para ownership interno dele;
- o frame loop do runtime passou a renderizar via `FrameComposer.render_frame()`.
Isso resolveu a base operacional interna, mas não fechou a superfície pública equivalente para a VM. A ABI pública ainda expõe apenas o contrato legado de `gfx.set_sprite(...)`, enquanto `bind_scene(...)` e `set_camera(...)` existem apenas como APIs internas do driver.
Na prática, hoje temos uma assimetria:
- a base canônica do frame está em `FrameComposer`;
- mas a ABI pública ainda não trata `FrameComposer` como serviço canônico para scene, camera e sprites.
Essa lacuna impede a migração do restante da stack e também impede um stress cartridge que atravesse de verdade o pipeline novo por syscall pública.
## Problema
Precisamos definir a nova superfície pública de syscall para o pipeline canônico de `FrameComposer` sem reabrir a decisão já aceita sobre ownership interno do frame.
O problema concreto não é “adicionar 2 ou 3 syscalls”. Precisamos decidir:
- quais operações de `FrameComposer` viram ABI pública agora;
- se `gfx.set_sprite(...)` continua como shim legado ou perde status canônico;
- qual é o contrato mínimo de scene/camera que a VM pode observar/controlar;
- como nomear e versionar essa superfície pública sem criar um segundo modelo canônico concorrente;
- qual é a estratégia de transição para cartridge, runtime tests e stress tests;
- como propagar essa mudança para a spec canônica e, se necessário, para contratos de ABI e `ISA_CORE`.
## Pontos Criticos
- `DEC-0014` já fechou `FrameComposer` como base canônica interna; esta agenda não deve reabrir isso.
- A ABI pública atual ainda expõe `gfx.set_sprite(...)` com semântica herdada de índice/slot, mesmo que a implementação interna já use frame emission.
- `bind_scene(scene_bank_id)` e `set_camera(x, y)` já existem no driver, mas ainda não existem como syscalls públicas.
- Se a nova ABI expuser demais logo de início, vamos congelar cedo demais detalhes que ainda não provaram valor operacional.
- Se a nova ABI expuser de menos, manteremos um modelo híbrido por tempo demais:
- canônico internamente via `FrameComposer`;
- legado externamente via `Gfx`/`set_sprite`.
- Precisamos decidir se o namespace público continua em `gfx.*` por estabilidade do domínio, ou se devemos introduzir algo como `frame.*`.
- A transição precisa preservar compatibilidade suficiente para não quebrar cartridges e testes existentes antes da migração do restante.
- O contrato de sprite precisa deixar claro se o chamador ainda informa índice, se informa `layer`, e se `active` continua existindo na superfície pública.
- A mudança não pode ficar só em código/runtime; a spec canônica precisa ser atualizada para refletir o novo serviço público.
- Se o contrato público afetar superfícies documentadas de ABI ou o material de `ISA_CORE`, essa propagação precisa ser tratada como parte da mesma thread, não como follow-up solto.
## Opcoes
### Opcao 1 - Expor um núcleo mínimo canônico em `gfx.*`
**Como seria:**
Adicionar apenas a superfície mínima para a VM controlar o pipeline novo:
- `gfx.bind_scene(bank_id)`
- `gfx.unbind_scene()`
- `gfx.set_camera(x, y)`
- `gfx.emit_sprite(...)`
`gfx.set_sprite(...)` permaneceria por um período como shim legado de compatibilidade.
**Vantagens:**
- fecha rapidamente a lacuna operacional;
- habilita stress real do pipeline novo;
- reduz o tempo de convivência entre modelo canônico e legado;
- mantém o domínio público em `gfx`, evitando churn de namespace.
**Desvantagens:**
- introduz ABI nova que precisará de migração coordenada;
- exige definir `emit_sprite(...)` com cuidado para não herdar sem querer o modelo de slot.
### Opcao 2 - Expor scene/camera agora e adiar o contrato novo de sprite
**Como seria:**
Publicar apenas:
- `gfx.bind_scene(bank_id)`
- `gfx.unbind_scene()`
- `gfx.set_camera(x, y)`
Sprites continuariam publicamente via `gfx.set_sprite(...)` até uma segunda fase.
**Vantagens:**
- menor mudança imediata de ABI;
- desbloqueia o stress do world path e da câmera;
- reduz o volume inicial da migração pública.
**Desvantagens:**
- mantém dois modelos públicos de sprite por mais tempo;
- prolonga a semântica de compatibilidade do syscall legado;
- adia exatamente uma das partes centrais da migração para `FrameComposer`.
### Opcao 3 - Criar um novo namespace público separado, como `composer.*`
**Como seria:**
O pipeline novo ganha syscalls em um domínio separado, por exemplo:
- `composer.bind_scene`
- `composer.unbind_scene`
- `composer.set_camera`
- `composer.emit_sprite`
`gfx.*` ficaria como superfície legacy/low-level.
**Vantagens:**
- deixa explícita a mudança de serviço canônico;
- evita sobrecarregar semanticamente `gfx`.
**Desvantagens:**
- adiciona churn conceitual e de nomenclatura;
- fragmenta demais a superfície pública neste momento;
- cria um custo de transição maior sem benefício operacional evidente.
## Sugestao / Recomendacao
Seguir com a **Opcao 3**.
Direção recomendada:
- a superfície pública canônica deve migrar para o domínio `composer.*`;
- `FrameComposer` vira a base canônica também na ABI pública, com namespace próprio em vez de continuar semanticamente preso a `gfx.*`;
- o núcleo mínimo público deve ser:
- `composer.bind_scene(bank_id) -> status`
- `composer.unbind_scene()`
- `composer.set_camera(x, y)`
- `composer.emit_sprite(...) -> status`
- `gfx.set_sprite(...)` deve morrer e ser removido completamente do contrato público.
Para sprites, a recomendação provisória é:
- a nova ABI pública não deve exigir índice explícito;
- `composer.emit_sprite(...)` deve receber o payload completo necessário para o frame:
- `glyph_id`
- `palette_id`
- `x`
- `y`
- `layer`
- `bank_id`
- `flip_x`
- `flip_y`
- `priority`
- a ABI pode futuramente agrupar esse payload se isso melhorar ergonomia, mas o contrato mínimo deve nascer completo;
- `active` não deve continuar no contrato canônico novo;
- overflow continua sendo ignorado com status/telemetria adequada, sem trapar o runtime.
Para scene/camera, a recomendação provisória é:
- manter o contrato mínimo já aceito internamente;
- `bind_scene` por bank id;
- `unbind_scene` explícito;
- `set_camera(x, y)` em pixel space com top-left viewport.
- `bind_scene(...)`, `unbind_scene(...)` e `emit_sprite(...)` devem usar `ComposerOpStatus` como retorno operacional canônico.
## Perguntas em Aberto
- Resolvido:
- o nome público canônico de sprite será `composer.emit_sprite(...)`;
- o syscall novo de sprite nasce completo com `glyph_id`, `palette_id`, `x`, `y`, `layer`, `bank_id`, `flip_x`, `flip_y`, `priority`;
- `gfx.set_sprite(...)` deve morrer e ser removido completamente;
- não haverá leitura de estado nesta primeira fase;
- `bind_scene(...)`, `unbind_scene(...)` e `emit_sprite(...)` usarão `ComposerOpStatus`;
- A ABI nova precisa expor refresh explícito, ou isso deve continuar totalmente interno ao `FrameComposer`?
- Resolvido:
- a ABI nova não deve expor refresh explícito;
- o domínio público canônico será `composer.*`, não `gfx.*`.
## Criterio para Encerrar
Esta agenda pode ser encerrada quando houver acordo explícito sobre:
- a lista mínima de syscalls públicas canônicas do `FrameComposer`;
- o nome canônico da operação pública de sprite;
- a remoção completa de `gfx.set_sprite(...)` do contrato público;
- o formato de retorno/status das novas operações;
- a estratégia de transição necessária para decisão, plano e migração do restante da stack.
## Resolucao em Andamento
Direção atualmente acordada nesta agenda:
- o namespace público canônico será `composer.*`;
- o núcleo mínimo inicial será:
- `composer.bind_scene(bank_id) -> ComposerOpStatus`
- `composer.unbind_scene() -> ComposerOpStatus`
- `composer.set_camera(x, y)`
- `composer.emit_sprite(glyph_id, palette_id, x, y, layer, bank_id, flip_x, flip_y, priority) -> ComposerOpStatus`
- não haverá introspecção pública nesta primeira fase;
- refresh/cache policy continua interno ao `FrameComposer`;
- `gfx.set_sprite(...)` não terá caminho de compatibilidade e deve ser removido.
## Resolucao
Esta agenda fica aceita com os seguintes pontos fechados:
- o namespace público canônico do serviço será `composer.*`;
- a superfície mínima inicial será:
- `composer.bind_scene(bank_id) -> ComposerOpStatus`
- `composer.unbind_scene() -> ComposerOpStatus`
- `composer.set_camera(x, y)`
- `composer.emit_sprite(glyph_id, palette_id, x, y, layer, bank_id, flip_x, flip_y, priority) -> ComposerOpStatus`
- não haverá introspecção pública nesta primeira fase;
- não haverá refresh/cache policy público;
- `gfx.set_sprite(...)` deve ser removido completamente, sem shim de compatibilidade;
- a transição deve introduzir `composer.*` e remover `gfx.set_sprite(...)` na mesma thread de migração, com atualização coordenada de bytecode, cartridges, tests e runtime;
- a mesma thread deve atualizar a spec canônica do assunto e propagar a mudança para contratos de ABI e `ISA_CORE` quando essas superfícies forem impactadas pelo novo serviço público.

View File

@ -0,0 +1,140 @@
---
id: AGD-0028
ticket: deferred-overlay-and-primitive-composition
title: Deferred Overlay and Primitive Composition over FrameComposer
status: accepted
created: 2026-04-18
updated: 2026-04-18
resolved: 2026-04-18
decision: DEC-0016
tags: [gfx, runtime, render, frame-composer, overlay, primitives, hud]
---
## Contexto
`FrameComposer.render_frame()` hoje recompõe o `back` no fim da logical frame. Quando há scene bound, o caminho `render_scene_from_cache(...)` limpa o buffer e desenha scene + sprites, o que apaga qualquer primitive ou `draw_text(...)` emitido antes via `gfx`.
Isso expôs um conflito de modelo:
- `composer.*` já é o caminho canônico de orquestração de frame;
- `gfx.draw_text(...)` e demais primitives ainda escrevem diretamente no `back`;
- o runtime só chama `render_frame()` no final do frame, então a escrita imediata em `back` deixou de ser semanticamente estável.
- As primitives de `gfx` não são o mecanismo desejado para composição de jogos com scene/tile/sprite; elas existem principalmente como debug, instrumentação visual e artefatos rápidos.
Conteúdo relevante migrado de [AGD-0010](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/discussion/workflow/agendas/AGD-0010-perf-gfx-render-pipeline-and-dirty-regions.md):
- a arquitetura aceita continua sendo de framebuffer destrutivo em memória, não scene graph ou renderer tipo GPU;
- otimizações em primitives devem preservar a semântica observável, mesmo quando ganharem fast paths internos;
- existe preocupação explícita com custo por classe de primitive e com orçamento de memória no alvo handheld;
- caminhos de spans/linhas/clears são desejáveis como aceleração interna, mas sem reabrir o modelo operacional do pipeline do jogo.
## Problema
Precisamos decidir qual é o modelo canônico para primitives e texto no pipeline pós-`FrameComposer`.
Sem isso:
- texto e primitives continuam com comportamento dependente da ordem interna do renderer;
- o stress test e qualquer cartridge que combine `composer.*` com `gfx.*` terão resultado inconsistente;
- fica indefinido se primitives pertencem ao mundo, ao HUD, ou a um overlay final.
## Pontos Criticos
- `draw_text(...)` e primitives screen-space não podem depender de escrita imediata em `back`.
- Para esta thread, primitives de `gfx` devem permanecer agnósticas ao pipeline canônico de render do jogo e não devem ser mescladas semanticamente com tiles/sprites.
- A ordem de composição precisa ser explícita e estável: `scene -> sprites -> HUD -> primitives/debug overlay`, ou outra ordem formal equivalente.
- Precisamos decidir se o contrato público de `gfx.*` muda semanticamente sem mudar ABI, ou se parte dessa superfície migra para `composer.*`.
- A solução deve preservar o caminho sem scene bound.
- A implementação deve evitar contaminar a infraestrutura de `gfx` responsável por scene, sprites e HUD com estado misto de overlay/debug; se necessário, o overlay deve ter fila/fase própria.
- melhorias internas de primitive path devem continuar permitidas, desde que não mudem a semântica de overlay final e não exijam buffers extras incompatíveis com o orçamento de memória aceito.
## Opcoes
### Opcao 1 - Manter escrita direta em `back`
- **Abordagem:** manter `gfx.draw_text(...)` e primitives rasterizando imediatamente.
- **Pro:** zero mudança estrutural agora.
- **Contra:** o modelo continua quebrado sempre que `render_frame()` recompõe o buffer depois.
- **Tradeoff:** só funciona de forma confiável fora do caminho canônico do `FrameComposer`.
### Opcao 2 - Fila única de draw commands pós-scene/pós-sprite
- **Abordagem:** transformar texto e primitives em comandos diferidos, drenados depois de `scene + sprites`.
- **Pro:** resolve o problema imediato de overlay/HUD e estabiliza o stress test.
- **Contra:** mistura HUD e primitives/debug sob o mesmo conceito, reduzindo clareza contratual mesmo quando a ordem prática for a mesma.
- **Tradeoff:** simples para V1, mas semanticamente mais fraco do que separar overlay de jogo e overlay de debug.
### Opcao 3 - Separar HUD diferido de primitives/debug overlay final
- **Abordagem:** tratar `gfx.draw_text(...)` e demais primitives de `gfx` como overlay/debug final, separado da composição canônica de jogo (`scene + sprites + HUD`).
- **Pro:** casa com a intenção declarada para `gfx.*`: debug, artefato rápido e instrumentação visual acima do frame do jogo.
- **Contra:** exige modelar explicitamente uma fase extra no pipeline.
- **Tradeoff:** aumenta a clareza contratual e evita mesclar primitives com o domínio de jogo.
### Opcao 4 - Manter HUD e primitives no mesmo estágio final, mas com categorias separadas
- **Abordagem:** drenar HUD e primitives ambos no fim do frame, porém com filas/categorias distintas e ordem formal `HUD -> primitives`.
- **Pro:** preserva implementação próxima entre caminhos similares, mantendo contrato separado.
- **Contra:** é mais custoso que a opção 3 sem entregar muito valor adicional imediato.
- **Tradeoff:** bom se já houver expectativa de HUD canônico separado no curtíssimo prazo.
## Sugestao / Recomendacao
Seguir com a **Opcao 3**.
Minha recomendação é:
- retirar a escrita direta em `back` como contrato operacional para `gfx.draw_text(...)` e demais primitives de `gfx`;
- introduzir uma fila diferida canônica de primitives/debug overlay drenada no fim do frame;
- tratar `gfx.*` primitive/text como superfície agnóstica ao pipeline de jogo e explicitamente acima da composição canônica;
- não misturar semanticamente primitives com scene/tile/sprite/HUD.
- evitar compartilhar indevidamente o mesmo mecanismo operacional de composição entre overlay/debug e os caminhos de scene/sprite/HUD, mesmo quando o backend de rasterização reutilizado for o mesmo.
Ordem recomendada para o frame canônico:
1. limpar/compor scene;
2. compor sprites;
3. compor HUD canônico, se existir;
4. aplicar `scene_fade`;
5. aplicar `hud_fade`;
6. drenar primitives/debug overlay de `gfx.*`.
## Perguntas em Aberto
- `draw_text(...)` e as demais primitives de `gfx` entram todas na mesma família de overlay final já na V1, ou começamos só com `draw_text(...)`?
- `render_no_scene_frame()` deve usar a mesma fila diferida para manter semântica idêntica com e sem scene?
- HUD canônico precisa existir explicitamente nesta mesma thread, ou pode continuar implícito/externo enquanto as primitives já migram para overlay final?
- quais fast paths internos de primitives continuam desejáveis nessa nova fase, por exemplo spans horizontais/verticais, fills e clears, sem misturar isso com a composição do jogo?
- o overlay/debug final precisa de dirtying próprio por classe de primitive ou isso pode ficar fora da primeira migração?
## Criterio para Encerrar
Esta agenda pode ser encerrada quando tivermos uma resposta explícita para:
- o destino semântico de `draw_text(...)`;
- se haverá uma fila própria para primitives/debug overlay e qual a relação dela com HUD;
- a ordem canônica de composição do frame;
- o escopo exato da primeira migração implementável sem reabrir o restante do pipeline.
## Resolucao Parcial
Direção já aceita nesta agenda:
- primitives e `draw_text(...)` de `gfx.*` devem ser tratadas como overlay/debug final;
- esse overlay deve ser drenado **depois** de `hud_fade`;
- scene, sprites e HUD canônico não devem ser semanticamente misturados com o overlay/debug;
- a implementação deve preservar separação operacional suficiente para que o `gfx` usado pelo pipeline do jogo não passe a depender do estado transitório de primitives/debug;
- otimizações de primitive path discutidas na `AGD-0010` continuam válidas, mas passam a operar dentro do domínio de overlay/debug final, não como parte da composição canônica de scene/sprite/HUD.
## Resolucao
Esta agenda fica aceita com os seguintes pontos fechados:
- `gfx.draw_text(...)` e as demais primitives públicas de `gfx.*` pertencem à mesma família V1 de overlay/debug final;
- esse overlay/debug fica **fora** do `FrameComposer`;
- `FrameComposer` continua restrito à composição canônica do jogo (`scene`, `sprites` e HUD canônico quando existir);
- o overlay/debug deve ser drenado depois de `hud_fade`;
- o caminho sem scene bound deve observar a mesma semântica final de overlay/debug;
- HUD canônico explícito não faz parte desta thread e pode permanecer implícito/externo por enquanto;
- fast paths internos de primitives continuam permitidos, desde que preservem a semântica observável do overlay/debug final;
- dirtying granular ou otimizações finas por classe de primitive não fazem parte da primeira migração normativa desta thread.

View File

@ -0,0 +1,166 @@
---
id: DEC-0015
ticket: frame-composer-public-syscall-surface
title: FrameComposer Public Syscall Surface
status: accepted
created: 2026-04-17
accepted: 2026-04-17
agenda: AGD-0027
plans: [PLN-0022, PLN-0023, PLN-0024, PLN-0025]
tags: [gfx, runtime, syscall, abi, frame-composer, scene, camera, sprites]
---
## Status
Accepted.
## Contexto
`DEC-0014` locked `FrameComposer` as the canonical internal frame orchestration service and `PLN-0017` through `PLN-0021` completed that internal migration path. `Hardware` now owns `FrameComposer`, the runtime renders through `FrameComposer.render_frame()`, and scene/camera/cache/resolver/sprite ownership no longer belongs canonically to `Gfx`.
That migration did not define the equivalent public syscall contract for VM code. The public ABI still exposed legacy `gfx`-domain sprite control while the canonical scene/camera operations existed only as internal driver APIs.
This decision closes that public ABI gap without reopening the already accepted internal ownership model.
## Decisao
The canonical public syscall surface for frame orchestration SHALL move to the `composer.*` namespace.
Normatively:
- The canonical public service domain for `FrameComposer` operations SHALL be `composer`.
- The initial canonical syscall set SHALL be:
- `composer.bind_scene(bank_id) -> ComposerOpStatus`
- `composer.unbind_scene() -> ComposerOpStatus`
- `composer.set_camera(x, y)`
- `composer.emit_sprite(glyph_id, palette_id, x, y, layer, bank_id, flip_x, flip_y, priority) -> ComposerOpStatus`
- `composer.emit_sprite(...)` SHALL be the canonical public sprite submission path.
- `composer.emit_sprite(...)` MUST NOT require a caller-provided sprite index.
- `composer.emit_sprite(...)` MUST carry `layer` and `priority`.
- `composer.emit_sprite(...)` MUST NOT expose `active` as part of the canonical contract.
- `composer.bind_scene(...)`, `composer.unbind_scene()`, and `composer.emit_sprite(...)` SHALL return `ComposerOpStatus`.
- `composer.set_camera(x, y)` SHALL keep the minimal V1 camera contract already accepted by `DEC-0014`:
- `x` and `y` are `i32` pixel coordinates;
- they represent the top-left viewport origin in world space.
- The public ABI MUST NOT expose cache refresh policy or explicit refresh controls.
- The public ABI MUST NOT expose scene/camera introspection in this first phase.
- `gfx.set_sprite(...)` MUST be removed completely from the public contract.
- No compatibility shim for `gfx.set_sprite(...)` SHALL remain as part of the canonical migration target.
- Introduction of `composer.*` and removal of `gfx.set_sprite(...)` SHALL be executed in the same migration thread.
## Rationale
The public ABI must reflect the accepted ownership model rather than preserve a misleading legacy shape.
Keeping the canonical public surface under `gfx.*` would continue to tie orchestration semantics to the wrong service boundary. The new namespace makes the ownership change explicit:
- `Gfx` is the visual backend;
- `FrameComposer` is the frame orchestration service.
Removing `gfx.set_sprite(...)` completely avoids prolonging a dual public sprite model. A compatibility shim would preserve legacy slot/index semantics in the public contract after those semantics had already ceased to be canonical internally.
Returning `ComposerOpStatus` for operational mutating calls preserves status-first behavior while keeping the public contract aligned with the new service boundary. Reusing `GfxOpStatus` would leak backend-domain semantics into orchestration-domain syscalls after that separation had already been made explicit.
Deferring introspection and explicit refresh controls keeps the first public ABI focused on control, not diagnostics or internal policy leakage.
## Invariantes / Contrato
### 1. Namespace
- Public frame-orchestration syscalls MUST live under `composer.*`.
- `composer.*` SHALL be treated as the canonical public orchestration surface.
- `gfx.*` SHALL NOT remain the canonical public orchestration namespace for scene/camera/sprite submission.
### 2. Scene Control
- `composer.bind_scene(bank_id)` MUST bind by scene bank id.
- Binding semantics MUST remain aligned with `DEC-0014`:
- scene resolution through the scene bank pool;
- explicit bind/unbind lifecycle;
- no implicit per-frame rebinding.
- `composer.unbind_scene()` MUST leave no-scene rendering valid.
- `ComposerOpStatus` SHALL be the canonical operational status family for composer-domain mutating syscalls.
### 3. Camera
- `composer.set_camera(x, y)` MUST remain the minimal V1 camera API.
- Camera follow, smoothing, shake, transitions, and readback are OUT OF SCOPE for this decision.
### 4. Sprite Submission
- `composer.emit_sprite(...)` MUST be frame-emission based.
- The caller MUST NOT provide sprite slot/index information.
- The public payload MUST include:
- `glyph_id`
- `palette_id`
- `x`
- `y`
- `layer`
- `bank_id`
- `flip_x`
- `flip_y`
- `priority`
- The canonical public sprite contract MUST NOT include `active`.
- Overflow behavior SHALL remain aligned with `DEC-0014`:
- excess sprites are ignored;
- overflow is not a hard VM fault in V1.
### 5. Non-Goals for V1 Public ABI
- No public refresh/invalidate syscalls.
- No public cache inspection syscalls.
- No public `scene_status()` syscall.
- No public `get_camera()` syscall.
### 6. Migration Contract
- Migration MUST update:
- syscall registry and ABI resolution;
- runtime dispatch;
- bytecode/cartridge declarations;
- tests;
- stress cartridges and related tooling where applicable.
- Migration MUST NOT leave `gfx.set_sprite(...)` as a supported public fallback after the new contract lands.
## Impactos
### HAL
- The syscall enum, registry, metadata, and resolver will need a new `composer` domain surface.
- `gfx.set_sprite(...)` must be removed from the public ABI contract.
- A new `ComposerOpStatus` contract will need to be introduced for composer-domain operational returns.
### Runtime / VM
- Runtime dispatch must route public scene/camera/sprite orchestration through `FrameComposer`.
- Existing bytecode declarations and cartridges that rely on `gfx.set_sprite(...)` will need coordinated migration.
### Spec / ABI / ISA_CORE
- The canonical spec for the public VM-facing graphics/composition surface must be updated to reflect `composer.*`.
- ABI-facing documentation and contracts must be updated wherever syscall domain, names, arguments, or return semantics are specified.
- `ISA_CORE` must be updated if and where it normatively references the public syscall surface affected by this decision.
### Drivers / Hardware
- `FrameComposer` already has the required internal base; execution work will focus on public ABI exposure rather than internal ownership redesign.
### Tooling / Stress
- Stress cartridges and bytecode generators can only exercise the canonical frame path publicly after `composer.*` exists.
## Referencias
- [AGD-0027-frame-composer-public-syscall-surface.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/discussion/workflow/agendas/AGD-0027-frame-composer-public-syscall-surface.md)
- [DEC-0014-frame-composer-render-integration.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/discussion/workflow/decisions/DEC-0014-frame-composer-render-integration.md)
## Propagacao Necessaria
- A new implementation plan MUST be created from this decision before code changes.
- The plan MUST cover ABI introduction, legacy syscall removal, cartridge/test migration, regression coverage, and canonical spec propagation.
- The plan MUST explicitly assess and update ABI and `ISA_CORE` artifacts where this decision changes documented public behavior.
- Stress tooling SHOULD be updated as part of the migration thread so the public ABI can exercise the canonical frame path end-to-end.
## Revision Log
- 2026-04-17: Initial accepted decision from `AGD-0027`.

View File

@ -0,0 +1,150 @@
---
id: DEC-0016
ticket: deferred-overlay-and-primitive-composition
title: Deferred GFX Overlay Outside FrameComposer
status: accepted
created: 2026-04-18
accepted: 2026-04-18
agenda: AGD-0028
plans: [PLN-0026, PLN-0027, PLN-0028, PLN-0029]
tags: [gfx, runtime, render, frame-composer, overlay, primitives, hud]
---
## Status
Accepted.
## Contexto
`DEC-0014` and `DEC-0015` established `FrameComposer` as the canonical orchestration path for game-frame composition and exposed that orchestration publicly through `composer.*`.
That migration left `gfx.draw_text(...)` and other `gfx` primitives with their historical immediate-write behavior against the working framebuffer. Once the runtime moved to end-of-frame composition through `FrameComposer.render_frame()`, those immediate writes became unstable: scene-backed frame composition can rebuild the backbuffer after primitive calls have already touched it.
The resulting conflict is not about whether primitives should remain available. It is about their semantic place in the pipeline. The accepted direction of this thread is that `gfx` primitives are not part of the canonical game composition model. They are primarily for debug, quick visual instrumentation, and rapid artifacts, and they must remain agnostic to scene/tile/sprite/HUD composition.
Relevant performance context migrated from `AGD-0010` also remains in force:
- the renderer continues to be a destructive software framebuffer model, not a retained scene graph or GPU-style renderer;
- internal primitive fast paths remain desirable;
- memory growth must remain constrained for the handheld target;
- optimization of primitive execution must not alter observable semantics.
## Decisao
`gfx.*` primitives and text SHALL move to a deferred final overlay model that lives outside `FrameComposer`.
Normatively:
- `FrameComposer` SHALL remain responsible only for canonical game-frame composition:
- scene composition;
- sprite composition;
- canonical HUD composition when such a HUD stage exists.
- `FrameComposer` MUST NOT become the owner of debug/primitive overlay state.
- Public `gfx.*` primitives, including `gfx.draw_text(...)`, SHALL belong to a V1 `gfx` overlay/debug family.
- That overlay/debug family SHALL be deferred rather than written immediately as the stable operational contract.
- The deferred overlay/debug stage SHALL be drained after `hud_fade`.
- The deferred overlay/debug stage SHALL be above scene, sprites, and canonical HUD in final visual order.
- The no-scene path MUST preserve the same final overlay/debug semantics.
- `gfx.*` primitives MUST remain semantically separate from scene/tile/sprite/HUD composition.
- The implementation MUST preserve operational separation sufficient to prevent the canonical game pipeline from depending on transient primitive/debug state.
## Rationale
This decision keeps the architectural boundary clean.
`FrameComposer` exists to own the canonical game frame. Debug primitives do not belong to that contract. Pulling them into `FrameComposer` would make the orchestration service responsible for a second semantic domain with different goals:
- game composition must be deterministic and canonical;
- primitive/text overlay must be opportunistic, screen-space, and pipeline-agnostic.
Keeping overlay/debug outside `FrameComposer` also aligns with the stated product intent: these primitives are useful helpers, but they are not meant to become a second composition language for games.
Draining them after `hud_fade` preserves the user-visible requirement that debug/overlay content stay truly on top and legible. This is more faithful to the accepted intent than treating primitives as part of HUD or world composition.
Finally, separating semantic ownership still leaves room for implementation reuse. Raster backends, span paths, and buffer-writing helpers may still be shared internally, provided the public operational model remains separate.
## Invariantes / Contrato
### 1. Ownership Boundary
- `FrameComposer` MUST own only canonical game-frame composition.
- Primitive/debug overlay state MUST live outside `FrameComposer`.
- The canonical game pipeline MUST NOT depend on primitive/debug overlay state for correctness.
### 2. Overlay Semantics
- `gfx.draw_text(...)` and sibling `gfx` primitives SHALL be treated as deferred final overlay/debug operations.
- Immediate direct writes to `back` MUST NOT remain the stable operational contract for these primitives.
- Final overlay/debug output MUST appear after:
- scene composition;
- sprite composition;
- canonical HUD composition, if present;
- `scene_fade`;
- `hud_fade`.
### 3. Separation from Game Composition
- Primitive/debug overlay MUST NOT be reinterpreted as scene content.
- Primitive/debug overlay MUST NOT be reinterpreted as sprite content.
- Primitive/debug overlay MUST NOT be the vehicle for canonical HUD composition.
- The public `gfx.*` primitive surface SHALL remain pipeline-agnostic relative to `composer.*`.
### 4. Consistency Across Frame Paths
- The scene-bound path and no-scene path MUST expose the same final overlay/debug behavior.
- Users MUST NOT need to know whether a scene is bound for `gfx.*` primitives to appear as final overlay/debug content.
### 5. Internal Optimization Contract
- Internal fast paths for lines, spans, fills, clears, or similar primitive operations MAY be introduced.
- Such fast paths MUST preserve the observable deferred overlay/debug semantics.
- This decision DOES NOT require fine-grained dirtying or per-primitive-class invalidation in the first migration.
## Impactos
### Runtime / Drivers
- The runtime frame-end sequence must gain a distinct overlay/debug drain stage outside `FrameComposer`.
- `gfx.draw_text(...)` and peer primitives can no longer rely on stable immediate framebuffer writes once this migration lands.
### GFX Backend
- `Gfx` will need an explicit deferred overlay/debug command path or equivalent subsystem boundary.
- Shared raster helpers remain allowed, but the overlay/debug phase must stay semantically distinct from scene/sprite/HUD composition.
### FrameComposer
- `FrameComposer` must remain free of primitive/debug overlay ownership.
- Any future HUD integration must not collapse that boundary.
### Spec / Docs
- The canonical graphics/runtime spec must describe `gfx.*` primitives as deferred final overlay/debug operations rather than stable immediate backbuffer writes.
- Documentation that describes frame ordering must show overlay/debug after `hud_fade`.
### Performance Follow-up
- `AGD-0010` remains the home for broader renderer performance work, dirtying strategy, and low-level primitive optimization policy.
- Primitive optimization carried out under that thread must respect the normative separation established here.
## Referencias
- [AGD-0028-deferred-overlay-and-primitive-composition.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/discussion/workflow/agendas/AGD-0028-deferred-overlay-and-primitive-composition.md)
- [AGD-0010-perf-gfx-render-pipeline-and-dirty-regions.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/discussion/workflow/agendas/AGD-0010-perf-gfx-render-pipeline-and-dirty-regions.md)
- [DEC-0014-frame-composer-render-integration.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/discussion/workflow/decisions/DEC-0014-frame-composer-render-integration.md)
- [DEC-0015-frame-composer-public-syscall-surface.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/discussion/workflow/decisions/DEC-0015-frame-composer-public-syscall-surface.md)
## Propagacao Necessaria
- A new implementation plan MUST be created before code changes.
- That plan MUST cover:
- deferred overlay/debug ownership outside `FrameComposer`;
- runtime frame-end ordering changes;
- no-scene path parity;
- spec/documentation updates for `gfx.*` primitive semantics.
- The implementation plan MUST NOT reopen the ownership boundary accepted here.
## Revision Log
- 2026-04-18: Initial accepted decision from `AGD-0028`.
- 2026-04-18: Linked implementation plan family `PLN-0026` through `PLN-0029`.

View File

@ -26,6 +26,7 @@ Introduce `FrameComposer` as a first-class hardware-side subsystem and move cano
- optional cache;
- optional resolver;
- owned `SpriteController`.
- Preserve scene-layer metadata naming aligned with the world path contract, including `parallax_factor` as the canonical per-layer camera multiplier field.
- Aggregate `FrameComposer` inside `Hardware`.
- Expose the minimum driver-facing surface required for subsequent plans.

View File

@ -16,6 +16,7 @@ Implement the `FrameComposer` scene-binding contract, minimal camera state, and
`DEC-0014` locks scene activation around `bind_scene(scene_bank_id)` with `SceneBankPoolAccess`, pointer-based access only, and `scene_bank_id + Arc<SceneBank>` retained inside `FrameComposer`.
The same decision also requires `FrameComposer` to remain tile-size agnostic and to preserve canonical per-layer `tile_size`, including `8x8`.
For the scene-layer motion contract, this plan treats `parallax_factor` as the canonical field name for the per-layer camera multiplier.
## Scope
@ -92,6 +93,7 @@ Align cache/resolver lifetime with the active scene contract.
- On unbind:
- discard cache/resolver and invalidate the world path.
- Any initialization must derive layer math from the bound scene tile sizes instead of assuming `16x16`.
- Any layer-camera math or related contract references must use `parallax_factor` terminology rather than generic `motion` naming.
**File(s):**
- `crates/console/prometeu-drivers/src/frame_composer.rs`
@ -104,6 +106,7 @@ Align cache/resolver lifetime with the active scene contract.
- scene status reflects no-scene and active-scene states.
- camera coordinates are stored as top-left pixel-space values.
- bind/unbind remains valid for scenes whose layers use `8x8` tiles.
- scene binding and camera-facing contracts preserve `parallax_factor` as the canonical layer field name.
### Integration Tests
- `FrameComposer` can resolve a scene from the pool and survive no-scene operation.
@ -119,6 +122,7 @@ Align cache/resolver lifetime with the active scene contract.
- [ ] Camera contract is implemented as `i32` top-left viewport coordinates.
- [ ] Cache/resolver lifetime follows scene bind/unbind.
- [ ] Scene bind/cache/resolver setup preserves canonical per-layer tile sizes, including `8x8`.
- [ ] Scene-layer camera multiplier naming is aligned on `parallax_factor`.
## Dependencies

View File

@ -16,6 +16,7 @@ Connect `FrameComposer` to `SceneViewportResolver`, apply cache refreshes inside
`DEC-0014` requires that cache refresh policy remain inside `FrameComposer` and that `FrameComposer.render_frame()` become the canonical frame entry while `Gfx` remains only the low-level execution backend.
`DEC-0014` also requires the world path to remain tile-size agnostic, with explicit support for `8x8`, `16x16`, and `32x32` scene-layer tile sizes.
For per-layer camera scaling, this plan treats `parallax_factor` as the canonical scene-layer field name.
## Scope
@ -43,6 +44,7 @@ Move cache-refresh orchestration fully into `FrameComposer`.
- apply them to `SceneViewportCache`
- Keep `Gfx` unaware of refresh semantics.
- Ensure resolver and refresh math follow the bound layer `tile_size` values rather than any fixed `16x16` default.
- Ensure per-layer camera math is expressed through `parallax_factor` naming in the resolver/cache path.
**File(s):**
- `crates/console/prometeu-drivers/src/frame_composer.rs`
@ -103,6 +105,7 @@ Protect the two canonical frame modes.
- `render_frame()` with a scene applies resolver refreshes before composition.
- cache refresh requests are applied by `FrameComposer`, not `Gfx`.
- `render_frame()` with an `8x8` scene uses resolver/cache math derived from layer tile size rather than a `16x16` assumption.
- Resolver/cache-facing tests use `parallax_factor` terminology for per-layer camera scaling.
### Integration Tests
- scene bind + camera set + sprite emission + `render_frame()` produces the expected composed frame.
@ -119,6 +122,7 @@ Protect the two canonical frame modes.
- [ ] No-scene `sprites + fades` behavior remains valid.
- [ ] `Gfx` remains backend-only for this path.
- [ ] The world path is explicitly covered for `8x8` scenes without `16x16`-specific assumptions.
- [ ] Resolver/cache/frame-path terminology is aligned on `parallax_factor` for scene-layer camera scaling.
## Dependencies

View File

@ -0,0 +1,122 @@
---
id: PLN-0022
ticket: frame-composer-public-syscall-surface
title: Plan - Composer Syscall Domain and Spec Propagation
status: accepted
created: 2026-04-17
completed:
tags: [gfx, runtime, syscall, abi, spec, isa-core, frame-composer]
---
## Objective
Introduce the canonical `composer.*` syscall domain, define `ComposerOpStatus`, and propagate the new public contract through the canonical spec, ABI documentation, and `ISA_CORE` artifacts where affected.
## Background
`DEC-0015` locks the public orchestration surface on `composer.*`, requires `ComposerOpStatus` for mutating composer-domain calls, and requires propagation beyond code into canonical spec, ABI-facing documentation, and `ISA_CORE` where the public syscall surface is described normatively.
## Scope
### Included
- add the `composer` syscall domain and ids
- define `ComposerOpStatus`
- remove `gfx.set_sprite(...)` from the public ABI contract
- update canonical spec documentation for the new public surface
- update ABI-facing documentation and `ISA_CORE` wherever the public syscall contract is described
### Excluded
- runtime dispatch implementation
- cartridge and stress program migration
- final repository-wide CI execution
## Execution Steps
### Step 1 - Define the public `composer` syscall contract
**What:**
Add the new canonical public syscall surface to the HAL syscall contract.
**How:**
- Extend the syscall enum, registry, metadata, and resolver with a new `composer` domain.
- Allocate explicit syscall ids for:
- `composer.bind_scene`
- `composer.unbind_scene`
- `composer.set_camera`
- `composer.emit_sprite`
- Remove `gfx.set_sprite` from the public syscall contract and registry.
- Keep syscall metadata explicit for arg/ret slots and capability requirements.
**File(s):**
- `crates/console/prometeu-hal/src/syscalls.rs`
- `crates/console/prometeu-hal/src/syscalls/domains/*`
- `crates/console/prometeu-hal/src/syscalls/registry.rs`
- `crates/console/prometeu-hal/src/syscalls/resolver.rs`
### Step 2 - Introduce `ComposerOpStatus`
**What:**
Create the status family for composer-domain mutating operations.
**How:**
- Define a `ComposerOpStatus` type in HAL with explicit operational states needed by:
- scene binding
- scene unbinding
- sprite emission
- Ensure the enum is semantically composer-domain specific rather than a rename wrapper around `GfxOpStatus`.
- Update public API references so composer syscalls return `ComposerOpStatus` where required by `DEC-0015`.
**File(s):**
- `crates/console/prometeu-hal/src/*`
- any shared status exports used by runtime/VM code
### Step 3 - Propagate the contract into spec, ABI docs, and `ISA_CORE`
**What:**
Update normative documentation so the public contract no longer describes legacy `gfx.set_sprite`.
**How:**
- Identify canonical spec files that describe VM graphics/composition syscalls.
- Replace public references to legacy sprite orchestration with `composer.*`.
- Update ABI-facing docs to pin:
- namespace
- names
- arg order
- return semantics
- Update `ISA_CORE` if and where it references the affected syscall surface.
- Keep published spec content in English per repository policy.
**File(s):**
- canonical spec location(s)
- ABI contract documentation
- `ISA_CORE` artifact(s) if affected
## Test Requirements
### Unit Tests
- syscall registry tests pin the new `composer.*` entries and reject removed legacy identities
- `ComposerOpStatus` values are pinned where public return semantics are asserted
### Integration Tests
- declared syscall resolution accepts `composer.*` declarations and rejects removed `gfx.set_sprite`
### Manual Verification
- inspect canonical spec, ABI docs, and `ISA_CORE` references to confirm the public contract matches `DEC-0015`
## Acceptance Criteria
- [ ] The public syscall registry exposes `composer.bind_scene`, `composer.unbind_scene`, `composer.set_camera`, and `composer.emit_sprite`.
- [ ] `ComposerOpStatus` exists as the canonical status family for composer-domain mutating syscalls.
- [ ] `gfx.set_sprite` is removed from the public ABI contract.
- [ ] Canonical spec documentation is updated to describe `composer.*`.
- [ ] ABI-facing docs and `ISA_CORE` are updated wherever the affected public surface is documented.
## Dependencies
- Source decision: `DEC-0015`
## Risks
- Missing a normative doc location would leave the code and published contract divergent.
- Reusing `GfxOpStatus` semantics by accident would weaken the service-boundary separation required by `DEC-0015`.
- Removing the legacy syscall contract incompletely could leave resolver or ABI ambiguity behind.

View File

@ -0,0 +1,112 @@
---
id: PLN-0023
ticket: frame-composer-public-syscall-surface
title: Plan - Composer Runtime Dispatch and Legacy Removal
status: accepted
created: 2026-04-17
completed:
tags: [runtime, syscall, frame-composer, dispatch, migration]
---
## Objective
Route the new public `composer.*` syscalls through `FrameComposer`, remove legacy `gfx.set_sprite` handling, and align runtime-side operational behavior with `DEC-0015`.
## Background
`DEC-0015` closes the public contract around `composer.*` and requires that `gfx.set_sprite` be removed completely rather than kept as a compatibility shim. The internal `FrameComposer` ownership model already exists from `DEC-0014` and plans `PLN-0017` through `PLN-0021`.
## Scope
### Included
- runtime syscall dispatch for `composer.*`
- operational mapping from syscall args to `FrameComposer`
- removal of legacy `gfx.set_sprite` runtime handling
- runtime-facing tests for composer-domain behavior
### Excluded
- spec and ABI doc propagation
- cartridge/tooling migration
- final `make ci` closure
## Execution Steps
### Step 1 - Add runtime dispatch for `composer.*`
**What:**
Teach VM runtime dispatch to call `FrameComposer` through the new public contract.
**How:**
- Add dispatch arms for:
- `composer.bind_scene`
- `composer.unbind_scene`
- `composer.set_camera`
- `composer.emit_sprite`
- Parse arguments exactly as pinned by the HAL metadata.
- Return `ComposerOpStatus` for mutating composer-domain syscalls.
**File(s):**
- `crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs`
- any adjacent runtime helpers
### Step 2 - Map operational outcomes cleanly onto `ComposerOpStatus`
**What:**
Make runtime failures and normal outcomes reflect the new composer-domain status model.
**How:**
- Bind runtime-side operational checks to status outcomes such as:
- scene bank unavailable
- bank invalid
- argument range invalid
- layer invalid
- sprite overflow if surfaced operationally
- Keep non-fatal overflow behavior aligned with `DEC-0015`.
**File(s):**
- `crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs`
- `crates/console/prometeu-hal/src/*` as needed for shared status meaning
### Step 3 - Remove legacy `gfx.set_sprite` runtime support
**What:**
Delete the old public runtime path for slot-style sprite submission.
**How:**
- Remove dispatch support for `gfx.set_sprite`.
- Remove runtime assumptions about `active`, caller-provided indices, and legacy sprite ABI shape.
- Keep no private compatibility hook behind the public API.
**File(s):**
- `crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs`
- adjacent tests and public syscall references
## Test Requirements
### Unit Tests
- runtime dispatch returns `ComposerOpStatus` for bind, unbind, and emit operations
- `composer.set_camera` stores the minimal V1 camera coordinates correctly
### Integration Tests
- a VM/runtime test can bind a scene, set camera, emit a sprite, reach `FRAME_SYNC`, and render through the canonical frame path
- public runtime behavior rejects removed `gfx.set_sprite` declarations/calls
### Manual Verification
- inspect dispatch code to confirm all public orchestration now routes through `FrameComposer` rather than a legacy `gfx` sprite syscall path
## Acceptance Criteria
- [ ] Runtime dispatch supports all canonical `composer.*` syscalls.
- [ ] Mutating composer-domain calls return `ComposerOpStatus`.
- [ ] `gfx.set_sprite` is removed from runtime public handling.
- [ ] Runtime tests cover scene bind, camera set, sprite emit, and frame rendering through the public path.
## Dependencies
- Depends on `PLN-0022`
- Source decision: `DEC-0015`
## Risks
- Removing legacy handling before all runtime references are migrated can strand tests or bytecode fixtures.
- Poor `ComposerOpStatus` mapping could collapse useful operational distinctions into generic failures.

View File

@ -0,0 +1,107 @@
---
id: PLN-0024
ticket: frame-composer-public-syscall-surface
title: Plan - Composer Cartridge, Tooling, and Regression Migration
status: accepted
created: 2026-04-17
completed:
tags: [runtime, bytecode, tooling, stress, regression, frame-composer]
---
## Objective
Migrate bytecode declarations, cartridges, stress tooling, and regression coverage from legacy public sprite orchestration to the canonical `composer.*` surface.
## Background
`DEC-0015` requires the new public composer-domain ABI to land without leaving `gfx.set_sprite` as a fallback. That means the migration must cover the generated bytecode, test cartridges, and stress tooling that still assume the old public contract.
## Scope
### Included
- bytecode declaration updates for `composer.*`
- cartridge and stress generator migration
- regression coverage for the public composer-domain path
- removal of legacy syscall usage from test and tooling surfaces
### Excluded
- canonical spec propagation
- runtime dispatch implementation
- final repository-wide CI closure
## Execution Steps
### Step 1 - Migrate declared syscall users and fixtures
**What:**
Update code and fixtures that declare public syscalls so they target `composer.*`.
**How:**
- Replace legacy public sprite syscall declarations with composer-domain declarations.
- Update ABI expectations in bytecode-related tests and fixtures.
- Ensure removal of `gfx.set_sprite` is reflected in any declaration validation snapshots.
**File(s):**
- bytecode tests and fixtures
- syscall declaration users across runtime and tools
### Step 2 - Migrate stress and cartridge tooling
**What:**
Make the stress cartridge and related generators exercise the canonical public frame path.
**How:**
- Update `pbxgen-stress` and any cartridge generators to declare and call `composer.*`.
- Replace legacy sprite-path usage with `composer.emit_sprite`.
- Add scene bind and camera usage where needed so the stress path reaches the real canonical pipeline.
**File(s):**
- `crates/tools/pbxgen-stress/src/*`
- `test-cartridges/stress-console/*`
- related scripts such as `scripts/run-stress.sh`
### Step 3 - Expand regression coverage around the public path
**What:**
Lock the new public orchestration contract with regression tests.
**How:**
- Add tests that cover:
- composer-domain declaration resolution
- public bind/unbind/camera/emit behavior
- scene rendering through the public path
- stress/tooling integration using `composer.*`
- Ensure no regression fixture still relies on removed `gfx.set_sprite`.
**File(s):**
- runtime tests
- HAL syscall tests
- tooling tests where available
## Test Requirements
### Unit Tests
- bytecode and syscall declaration tests pin `composer.*` names and slot counts
### Integration Tests
- stress or cartridge-facing tests exercise scene bind, camera set, and sprite emit through `composer.*`
- regression fixtures fail if `gfx.set_sprite` is reintroduced
### Manual Verification
- inspect generated stress cartridge declarations and program behavior to confirm the public path is truly composer-domain based
## Acceptance Criteria
- [ ] Bytecode declarations and fixtures use `composer.*` instead of legacy public sprite orchestration.
- [ ] Stress tooling and test cartridges exercise the canonical public `FrameComposer` path.
- [ ] Regression coverage protects against fallback to `gfx.set_sprite`.
## Dependencies
- Depends on `PLN-0022` and `PLN-0023`
- Source decision: `DEC-0015`
## Risks
- Partial cartridge/tooling migration could leave the repository with hidden legacy public ABI usage.
- Stress tooling may appear to pass while still missing scene/camera coverage if it only migrates sprite calls.

View File

@ -0,0 +1,96 @@
---
id: PLN-0025
ticket: frame-composer-public-syscall-surface
title: Plan - Final CI Validation and Polish
status: accepted
created: 2026-04-17
completed:
tags: [ci, validation, regression, polish]
---
## Objective
Run the final repository validation path, including `make ci`, and perform the last compatibility, formatting, lint, and regression fixes required to close the composer-domain migration cleanly.
## Background
`DEC-0015` requires a coordinated migration across ABI, runtime, tooling, cartridges, spec, and documentation. After the implementation plans land, the repository still needs a final closure pass so no residual breakage survives in formatting, linting, tests, generated artifacts, or CI expectations.
## Scope
### Included
- final repository validation with `make ci`
- fixups required by formatting, lint, tests, snapshots, or generated artifacts
- final consistency pass across migrated files
### Excluded
- introducing new contract changes beyond `DEC-0015`
- reopening ABI or service-boundary decisions
## Execution Steps
### Step 1 - Run the final validation entrypoint
**What:**
Execute the repositorys final CI validation path.
**How:**
- Run `make ci` after `PLN-0022`, `PLN-0023`, and `PLN-0024` are complete.
- Capture failures from formatting, lint, tests, coverage setup, generation steps, or artifact drift.
**File(s):**
- repository-wide validation entrypoints
### Step 2 - Apply closure fixes without reopening scope
**What:**
Resolve residual breakage surfaced by final validation.
**How:**
- Fix formatting and lint issues.
- Update snapshots or generated artifacts only where the migrated public contract requires it.
- Repair any remaining tests or documentation references that fail under `make ci`.
- Do not widen scope beyond the accepted composer-domain migration.
**File(s):**
- any files directly implicated by final validation failures
### Step 3 - Confirm final repository consistency
**What:**
Leave the migration in a stable publishable state.
**How:**
- Re-run `make ci` until it passes cleanly.
- Verify no legacy public `gfx.set_sprite` usage remains in code, tests, tooling, or docs.
- Confirm the worktree reflects only intended migration changes.
**File(s):**
- repository-wide
## Test Requirements
### Unit Tests
- whatever unit coverage is exercised by `make ci` must remain green
### Integration Tests
- repository integration coverage under `make ci` must pass after the migration
### Manual Verification
- inspect the tree for residual `gfx.set_sprite` references and incomplete composer-domain propagation
## Acceptance Criteria
- [ ] `make ci` passes after the composer-domain migration family lands.
- [ ] Final fixups do not reopen contract scope beyond `DEC-0015`.
- [ ] No residual public `gfx.set_sprite` usage remains in the repository.
## Dependencies
- Depends on `PLN-0022`, `PLN-0023`, and `PLN-0024`
- Source decision: `DEC-0015`
## Risks
- If this final closure pass is skipped, small residual regressions can survive across formatting, lint, or generated artifacts even when the core implementation is correct.
- Late fixes can accidentally widen scope unless kept strictly bounded to validation fallout.

View File

@ -0,0 +1,93 @@
---
id: PLN-0026
ticket: deferred-overlay-and-primitive-composition
title: Plan - GFX Overlay Contract and Spec Propagation
status: accepted
created: 2026-04-18
completed:
tags: [gfx, runtime, spec, overlay, primitives, hud]
---
## Objective
Propagate `DEC-0016` into the canonical specs and internal contracts so `gfx.*` primitives are defined as deferred final overlay/debug operations outside `FrameComposer`.
## Background
`DEC-0016` locks a new semantic boundary:
- `FrameComposer` remains the owner of canonical game-frame composition;
- `gfx.*` primitives and `draw_text(...)` become deferred final overlay/debug operations;
- that overlay lives outside `FrameComposer` and is drained after `hud_fade`.
Execution must start by updating the normative contract before implementation changes spread through runtime and drivers.
## Scope
### Included
- update canonical runtime/gfx spec text to describe deferred overlay semantics
- update any ABI-facing or developer-facing docs that still imply direct stable writes to `back`
- align local contract comments and module docs where they currently imply immediate-write semantics as the stable model
### Excluded
- implementation of the overlay subsystem
- runtime frame-end integration
- final repository-wide CI
## Execution Steps
### Step 1 - Update canonical graphics/runtime documentation
**What:**
Publish the new semantic contract for `gfx.*` primitives.
**How:**
- Update the canonical runtime/gfx spec so `gfx.draw_text(...)` and peer primitives are described as deferred final overlay/debug operations.
- State explicitly that primitives are not part of canonical scene/sprite/HUD composition.
- State the ordering rule that overlay/debug is drained after `hud_fade`.
- Ensure the no-scene and scene-bound paths are described consistently.
**File(s):**
- canonical runtime/gfx spec files under `docs/specs/runtime/`
### Step 2 - Align implementation-facing contract text
**What:**
Remove stale implementation comments that imply immediate stable writes to the framebuffer.
**How:**
- Inspect module-level comments and trait docs in `hal`, `drivers`, and runtime code for language that now contradicts `DEC-0016`.
- Update only the contract-bearing comments and docs that materially affect maintenance and implementation clarity.
**File(s):**
- `crates/console/prometeu-hal/src/gfx_bridge.rs`
- `crates/console/prometeu-drivers/src/gfx.rs`
- `crates/console/prometeu-drivers/src/frame_composer.rs`
- runtime-adjacent modules where frame ordering is described
## Test Requirements
### Unit Tests
- none required for pure doc propagation
### Integration Tests
- none required for pure doc propagation
### Manual Verification
- inspect the updated spec and local contract comments to confirm they no longer describe primitives as stable direct writes to `back`
## Acceptance Criteria
- [ ] Canonical spec text describes `gfx.*` primitives as deferred final overlay/debug operations.
- [ ] The spec states that overlay/debug is outside `FrameComposer`.
- [ ] The spec states that overlay/debug is drained after `hud_fade`.
- [ ] Local implementation-facing contract comments no longer imply immediate-write semantics as the stable model.
## Dependencies
- Source decision: `DEC-0016`
## Risks
- Missing a normative doc location would leave code and published contract divergent.
- Over-editing local comments could unintentionally restate design choices outside the scope of `DEC-0016`.

View File

@ -0,0 +1,104 @@
---
id: PLN-0027
ticket: deferred-overlay-and-primitive-composition
title: Plan - Deferred GFX Overlay Subsystem
status: accepted
created: 2026-04-18
completed:
tags: [gfx, runtime, overlay, primitives, text, drivers]
---
## Objective
Introduce a dedicated deferred overlay/debug subsystem for `gfx.*` primitives outside `FrameComposer`, with command capture for `draw_text(...)` and the primitive family selected for the first migration.
## Background
`DEC-0016` requires primitive/text overlay ownership to remain outside `FrameComposer` while still allowing shared raster helpers and low-level optimizations internally. The new subsystem must preserve semantic separation from scene/sprite/HUD composition.
## Scope
### Included
- introduce an overlay/debug command queue or equivalent subsystem outside `FrameComposer`
- route `gfx.draw_text(...)` into deferred command capture instead of stable direct framebuffer writes
- route the chosen V1 primitive family into the same deferred overlay/debug path
- keep raster helper reuse allowed without merging semantic ownership
### Excluded
- runtime frame-end sequencing
- no-scene/scene parity tests at the runtime level
- final repository-wide CI
## Execution Steps
### Step 1 - Define overlay/debug state ownership in drivers
**What:**
Create the subsystem that owns deferred `gfx.*` overlay/debug commands.
**How:**
- Add a dedicated owner adjacent to `Gfx`/`Hardware`, but not inside `FrameComposer`.
- Define the minimal command model required for V1 operations.
- Keep the subsystem screen-space and explicitly pipeline-agnostic relative to `composer.*`.
**File(s):**
- `crates/console/prometeu-drivers/src/*`
- `crates/console/prometeu-hal/src/*` if bridge traits need extension
### Step 2 - Route text and selected primitives into deferred capture
**What:**
Stop treating text/primitives as stable direct writes.
**How:**
- Change `gfx.draw_text(...)` to enqueue deferred overlay/debug work.
- Migrate the selected V1 primitive set into the same deferred path.
- Keep any remaining unmigrated primitives either explicitly out of scope or routed consistently if they are already part of the accepted V1 set.
- Preserve internal raster helper reuse where useful.
**File(s):**
- `crates/console/prometeu-drivers/src/gfx.rs`
- runtime dispatch call sites that submit `gfx.*` primitives
### Step 3 - Add local driver-level tests for deferred capture semantics
**What:**
Prove that overlay/debug commands are captured separately from game composition state.
**How:**
- Add tests that assert text/primitives do not need direct stable writes to `back` to survive until overlay drain.
- Add tests that assert the overlay owner is independent from `FrameComposer` state.
**File(s):**
- `crates/console/prometeu-drivers/src/gfx.rs`
- new or existing driver test modules
## Test Requirements
### Unit Tests
- command capture tests for `draw_text(...)`
- tests for each migrated V1 primitive class
- tests proving overlay/debug state is owned outside `FrameComposer`
### Integration Tests
- none in this plan; runtime-level ordering is covered by the next plan
### Manual Verification
- inspect driver ownership boundaries to confirm `FrameComposer` does not gain overlay/debug state
## Acceptance Criteria
- [ ] A dedicated deferred overlay/debug subsystem exists outside `FrameComposer`.
- [ ] `gfx.draw_text(...)` is captured as deferred overlay/debug work.
- [ ] The selected V1 primitive family is captured through the same subsystem.
- [ ] Driver-level tests prove overlay/debug state is operationally separate from canonical game composition state.
## Dependencies
- Source decision: `DEC-0016`
- Prefer to execute after `PLN-0026`
## Risks
- Accidentally reusing `FrameComposer` storage or state would violate the accepted ownership boundary.
- Migrating only part of the primitive family without explicit scoping could create inconsistent semantics across `gfx.*`.

View File

@ -0,0 +1,106 @@
---
id: PLN-0028
ticket: deferred-overlay-and-primitive-composition
title: Plan - Runtime Frame-End Overlay Integration and Parity
status: accepted
created: 2026-04-18
completed:
tags: [runtime, overlay, frame-composer, no-scene, regression, stress]
---
## Objective
Integrate deferred overlay/debug draining into the runtime frame-end sequence so scene-bound and no-scene frames both present the same final `gfx.*` primitive behavior after `hud_fade`.
## Background
After `PLN-0027`, the overlay/debug subsystem will exist but still needs to be drained in the correct place relative to `FrameComposer.render_frame()`, fades, and present/present-adjacent behavior. This plan closes the observable runtime semantics required by `DEC-0016`.
## Scope
### Included
- runtime frame-end ordering changes
- scene-bound and no-scene parity
- regression coverage for overlay visibility above the canonical game frame
- stress-cartridge adjustments if needed to prove text/primitives now survive frame composition
### Excluded
- broad renderer optimization work
- final repository-wide CI
## Execution Steps
### Step 1 - Insert overlay/debug drain into the frame-end path
**What:**
Drain deferred overlay/debug after canonical game composition is complete.
**How:**
- Update the runtime frame-end path so overlay/debug drain occurs after:
- `FrameComposer.render_frame()`
- `scene_fade`
- `hud_fade`
- Ensure the same ordering is respected in the no-scene path.
**File(s):**
- `crates/console/prometeu-system/src/virtual_machine_runtime/tick.rs`
- `crates/console/prometeu-drivers/src/hardware.rs`
- `crates/console/prometeu-drivers/src/gfx.rs`
- any bridge traits needed by the runtime/hardware path
### Step 2 - Add runtime and driver regressions for final visual ordering
**What:**
Lock the new visible behavior.
**How:**
- Add tests proving `gfx.draw_text(...)` remains visible after scene-backed frame composition.
- Add tests proving the same behavior with no scene bound.
- Add tests proving overlay/debug sits above the canonical game frame rather than being erased by it.
**File(s):**
- `crates/console/prometeu-system/src/virtual_machine_runtime/tests.rs`
- driver-level render tests where helpful
### Step 3 - Update stress/integration fixtures if needed
**What:**
Restore or improve stress scenarios that rely on visible text/primitives.
**How:**
- Update `pbxgen-stress` or related stress fixtures so text/primitives are once again a valid visible overlay signal.
- Keep the stress focused on the new model rather than reintroducing obsolete immediate-write assumptions.
**File(s):**
- `crates/tools/pbxgen-stress/src/lib.rs`
- `test-cartridges/stress-console/*`
## Test Requirements
### Unit Tests
- local ordering tests where runtime integration depends on helper sequencing
### Integration Tests
- runtime tests for scene-bound overlay/debug visibility
- runtime tests for no-scene parity
- stress/tooling validation that text or primitives are visible again as final overlay/debug
### Manual Verification
- run the stress path and visually confirm overlay/debug survives on top of scene/sprites after frame composition
## Acceptance Criteria
- [ ] The runtime drains deferred overlay/debug after canonical game composition and after `hud_fade`.
- [ ] Scene-bound and no-scene paths expose the same overlay/debug semantics.
- [ ] Regression tests prove `draw_text(...)` is no longer erased by scene-backed frame composition.
- [ ] Stress/integration fixtures reflect the new final-overlay semantics where applicable.
## Dependencies
- Source decision: `DEC-0016`
- Depends on `PLN-0027`
## Risks
- If fades are still applied after overlay/debug drain, the visible contract will contradict `DEC-0016`.
- Incomplete parity between scene-bound and no-scene paths would leave runtime behavior mode-dependent.

View File

@ -0,0 +1,82 @@
---
id: PLN-0029
ticket: deferred-overlay-and-primitive-composition
title: Plan - Final Overlay CI Validation and Polish
status: accepted
created: 2026-04-18
completed:
tags: [ci, overlay, runtime, gfx, validation]
---
## Objective
Run the final repository validation path for the deferred overlay/debug migration and perform the last compatibility, formatting, lint, and regression fixes required to close the thread cleanly.
## Background
`DEC-0016` changes visible runtime semantics and touches both specs and code paths around frame composition. A dedicated final-validation plan is needed so the implementation family can close on a clean CI signal rather than leaving integration fallout for later.
## Scope
### Included
- full-tree formatting, lint, and test validation
- stress-path smoke validation after overlay integration
- final cleanup fixes required to satisfy CI
### Excluded
- new feature work outside the accepted overlay/debug migration
## Execution Steps
### Step 1 - Run focused validation before full CI
**What:**
Catch local fallout in the touched areas before the full repository pass.
**How:**
- Run targeted tests for drivers, runtime, and `pbxgen-stress`.
- Inspect touched files for stale immediate-write assumptions or missed contract updates.
**File(s):**
- touched files from `PLN-0026` through `PLN-0028`
### Step 2 - Run final repository CI
**What:**
Validate the migration end to end.
**How:**
- Run the repository validation path, including `make ci`.
- Fix any final formatting, lint, test, or generated-fixture fallout caused by the overlay/debug migration.
- Do not widen scope beyond the accepted thread.
**File(s):**
- repository-wide
## Test Requirements
### Unit Tests
- all relevant crate unit tests pass after the migration
### Integration Tests
- runtime and stress/integration tests pass after the migration
- `make ci` passes
### Manual Verification
- inspect the tree for residual direct-write assumptions or incomplete overlay propagation
## Acceptance Criteria
- [ ] Targeted validation passes for the touched drivers/runtime/stress areas.
- [ ] `make ci` passes after the deferred overlay/debug migration family lands.
- [ ] No residual contract mismatch remains between spec text and code behavior in the touched thread.
## Dependencies
- Source decision: `DEC-0016`
- Depends on `PLN-0026`, `PLN-0027`, and `PLN-0028`
## Risks
- Final CI may surface unrelated renderer assumptions that still expect immediate-write semantics.
- Generated cartridge fixtures may drift if regeneration is forgotten during earlier plans.

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`
### 19.1 Return-shape matrix in v1
Return-shape matrix in v1:
| Syscall | Return | Policy basis |
| ----------------------- | ------------- | --------------------------------------------------- |
| `gfx.clear` | `void` | no real operational failure path in v1 |
| `gfx.fill_rect` | `void` | no real operational failure path in v1 |
| `gfx.draw_line` | `void` | no real operational failure path in v1 |
| `gfx.draw_circle` | `void` | no real operational failure path in v1 |
| `gfx.draw_disc` | `void` | no real operational failure path in v1 |
| `gfx.draw_square` | `void` | no real operational failure path in v1 |
| `gfx.draw_text` | `void` | no real operational failure path in v1 |
| `gfx.clear_565` | `void` | no real operational failure path in v1 |
| `composer.bind_scene` | `status:int` | explicit orchestration-domain operational result |
| `composer.unbind_scene` | `status:int` | explicit orchestration-domain operational result |
| `composer.set_camera` | `void` | no real operational failure path in v1 |
| `composer.emit_sprite` | `status:int` | explicit orchestration-domain operational rejection |
| Syscall | Return | Policy basis |
| ------------------ | ------------- | ---------------------------------------------------- |
| `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 |
### 19.1.a Deferred overlay/debug semantics for `gfx.*`
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.
The public `gfx.*` primitive family remains valid in v1, but its stable operational meaning is:
### 19.2 `gfx.set_sprite`
- deferred final overlay/debug composition;
- screen-space and pipeline-agnostic relative to `composer.*`;
- outside `FrameComposer`;
- above scene, sprites, and canonical HUD;
- drained after `hud_fade`.
`gfx.set_sprite` returns `status:int`.
This means callers MUST NOT rely on stable immediate writes to the working back buffer as the public contract for `gfx.draw_text(...)` or sibling primitives.
### 19.2 `composer.emit_sprite`
`composer.emit_sprite` returns `status:int`.
ABI:
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"]
}