Merge pull request 'Internal Viewport 270p (480x270)' (#22) from dev/internal-viewport-270p into master
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good

Reviewed-on: #22
This commit is contained in:
bquarkz 2026-04-29 07:19:36 +00:00
commit 983e4cfc29
14 changed files with 192 additions and 87 deletions

View File

@ -1,15 +1,12 @@
[workspace]
members = [
"crates/console/prometeu-bytecode",
"crates/console/prometeu-bytecode",
"crates/console/prometeu-drivers",
"crates/console/prometeu-firmware",
"crates/console/prometeu-hal",
"crates/console/prometeu-system",
"crates/console/prometeu-vm",
"crates/host/prometeu-host-desktop-winit",
"crates/tools/prometeu-cli",
"crates/tools/pbxgen-stress",
"crates/dev/prometeu-test-support",

View File

@ -1 +1 @@
0.1.0

View File

@ -340,6 +340,7 @@ impl FrameComposer {
mod tests {
use super::*;
use crate::gfx::Gfx;
use crate::hardware::Hardware;
use crate::memory_banks::{
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller,
};
@ -405,10 +406,14 @@ mod tests {
#[test]
fn frame_composer_starts_unbound_with_empty_owned_state() {
let frame_composer =
FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[]));
let frame_composer = FrameComposer::new(
Hardware::W,
Hardware::H,
Arc::new(MemoryBanks::new()),
make_glyph_slot_index(&[]),
);
assert_eq!(frame_composer.viewport_size(), (320, 180));
assert_eq!(frame_composer.viewport_size(), (Hardware::W, Hardware::H));
assert_eq!(frame_composer.active_scene_id(), None);
assert!(frame_composer.active_scene().is_none());
assert_eq!(frame_composer.scene_status(), SceneStatus::Unbound);
@ -425,7 +430,8 @@ mod tests {
let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(3, Arc::new(make_scene()));
let frame_composer = FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)]));
let frame_composer =
FrameComposer::new(Hardware::W, Hardware::H, banks, make_glyph_slot_index(&[(1, 0)]));
let scene =
frame_composer.scene_bank_slot(3).expect("scene bank slot 3 should be resident");
@ -442,7 +448,7 @@ mod tests {
let expected_scene =
banks.scene_bank_slot(3).expect("scene bank slot 3 should be resident");
let mut frame_composer =
FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)]));
FrameComposer::new(Hardware::W, Hardware::H, banks, make_glyph_slot_index(&[(1, 0)]));
assert!(frame_composer.bind_scene(3));
@ -462,7 +468,7 @@ mod tests {
banks.install_scene_bank(1, Arc::new(make_scene()));
let mut frame_composer =
FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)]));
FrameComposer::new(Hardware::W, Hardware::H, banks, make_glyph_slot_index(&[(1, 0)]));
assert!(frame_composer.bind_scene(1));
frame_composer.unbind_scene();
@ -476,8 +482,12 @@ mod tests {
#[test]
fn set_camera_stores_top_left_pixel_coordinates() {
let mut frame_composer =
FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[]));
let mut frame_composer = FrameComposer::new(
Hardware::W,
Hardware::H,
Arc::new(MemoryBanks::new()),
make_glyph_slot_index(&[]),
);
frame_composer.set_camera(-12, 48);
@ -490,17 +500,21 @@ mod tests {
banks.install_scene_bank(0, Arc::new(make_scene()));
let mut frame_composer =
FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[(1, 0)]));
FrameComposer::new(Hardware::W, Hardware::H, banks, make_glyph_slot_index(&[(1, 0)]));
assert!(frame_composer.bind_scene(0));
let cache = frame_composer.cache().expect("cache should exist for bound scene");
assert_eq!((cache.width(), cache.height()), (45, 27));
assert_eq!((cache.width(), cache.height()), (65, 38));
}
#[test]
fn missing_scene_binding_falls_back_to_no_scene_state() {
let mut frame_composer =
FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[]));
let mut frame_composer = FrameComposer::new(
Hardware::W,
Hardware::H,
Arc::new(MemoryBanks::new()),
make_glyph_slot_index(&[]),
);
assert!(!frame_composer.bind_scene(7));
@ -514,7 +528,8 @@ mod tests {
let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(0, Arc::new(make_scene()));
let mut frame_composer = FrameComposer::new(320, 180, banks, make_glyph_slot_index(&[]));
let mut frame_composer =
FrameComposer::new(Hardware::W, Hardware::H, banks, make_glyph_slot_index(&[]));
let panic = catch_unwind(AssertUnwindSafe(|| frame_composer.bind_scene(0)))
.expect_err("bind_scene should panic when dependency is missing");
@ -649,8 +664,12 @@ mod tests {
#[test]
fn frame_composer_emits_ordered_sprites_for_rendering() {
let mut frame_composer =
FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()), make_glyph_slot_index(&[]));
let mut frame_composer = FrameComposer::new(
Hardware::W,
Hardware::H,
Arc::new(MemoryBanks::new()),
make_glyph_slot_index(&[]),
);
frame_composer.begin_frame();
assert!(frame_composer.emit_sprite(Sprite {

View File

@ -21,7 +21,7 @@ use std::sync::Arc;
/// for easier testing and different host implementations (Desktop, Web, etc.).
///
/// ### Console Specifications:
/// - **Resolution**: 320x180 (16:9 Aspect Ratio).
/// - **Resolution**: 480x270 (16:9 Aspect Ratio).
/// - **Color Depth**: RGB565 (16-bit).
/// - **Audio**: Stereo, Command-based mixing.
/// - **Input**: 12-button Digital Gamepad + Absolute Touch/Mouse.
@ -115,9 +115,9 @@ impl HardwareBridge for Hardware {
impl Hardware {
/// Internal hardware width in pixels.
pub const W: usize = 320;
pub const W: usize = 480;
/// Internal hardware height in pixels.
pub const H: usize = 180;
pub const H: usize = 270;
/// Creates a fresh hardware instance with default settings.
pub fn new() -> Self {

View File

@ -433,20 +433,20 @@ mod tests {
#[test]
fn first_update_initializes_master_and_layer_anchors() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let update = resolver.update(&scene, 0, 0);
assert_eq!(update.master_anchor, TileAnchor { x: 12, y: 8 });
assert_eq!(update.layer_anchors[0], TileAnchor { x: 12, y: 8 });
assert_eq!(update.layer_anchors[1], TileAnchor { x: 12, y: 8 });
assert_eq!(update.master_anchor, TileAnchor { x: 17, y: 10 });
assert_eq!(update.layer_anchors[0], TileAnchor { x: 17, y: 10 });
assert_eq!(update.layer_anchors[1], TileAnchor { x: 17, y: 10 });
assert_eq!(update.refresh_requests.len(), 4);
}
#[test]
fn per_layer_copy_requests_follow_parallax_factor() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let update = resolver.update(&scene, 32, 48);
@ -461,24 +461,24 @@ mod tests {
#[test]
fn hysteresis_prevents_small_back_and_forth_refresh_churn() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let _ = resolver.update(&scene, 0, 0);
let update = resolver.update(&scene, 8, 0);
assert!(update.refresh_requests.is_empty());
assert_eq!(update.master_anchor, TileAnchor { x: 12, y: 8 });
assert_eq!(update.master_anchor, TileAnchor { x: 17, y: 10 });
}
#[test]
fn repeated_high_speed_movement_advances_in_tile_steps() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let _ = resolver.update(&scene, 0, 0);
let update = resolver.update(&scene, 64, 0);
assert!(update.master_anchor.x > 12);
assert!(update.master_anchor.x > 17);
assert!(update.refresh_requests.iter().any(|request| matches!(
request,
CacheRefreshRequest::RefreshColumn { layer_index: 0, .. }
@ -489,18 +489,18 @@ mod tests {
#[test]
fn anchors_clamp_near_scene_edges() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let update = resolver.update(&scene, 10_000, 10_000);
assert_eq!(update.master_anchor, TileAnchor { x: 51, y: 56 });
assert_eq!(update.layer_anchors[0], TileAnchor { x: 51, y: 56 });
assert_eq!(update.master_anchor, TileAnchor { x: 46, y: 53 });
assert_eq!(update.layer_anchors[0], TileAnchor { x: 46, y: 53 });
}
#[test]
fn corner_trigger_converts_to_non_overlapping_region_requests() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let _ = resolver.update(&scene, 0, 0);
let update = resolver.update(&scene, 64, 80);
@ -515,15 +515,15 @@ mod tests {
.collect();
assert_eq!(regions.len(), 2);
assert_eq!(regions[0].x + regions[0].width, 25);
assert_eq!(regions[0].height, 16);
assert_eq!(regions[1].width, 24);
assert_eq!(regions[1].height, 1);
assert_eq!(regions[0].x + regions[0].width, 35);
assert_eq!(regions[0].height, 21);
assert_eq!(regions[1].width, 34);
assert_eq!(regions[1].height, 2);
}
#[test]
fn reset_scene_requests_full_invalidation_for_all_layers() {
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let requests = resolver.reset_scene();

View File

@ -437,6 +437,7 @@ fn glyph_bits(ch: char) -> [u8; 7] {
#[cfg(test)]
mod tests {
use super::*;
use prometeu_drivers::hardware::Hardware;
fn sample_snapshot() -> OverlaySnapshot {
OverlaySnapshot {
@ -474,8 +475,8 @@ mod tests {
#[test]
fn draw_overlay_writes_to_host_rgba_frame() {
let mut frame = vec![0u8; 320 * 180 * 4];
draw_overlay(&mut frame, 320, 180, &sample_snapshot());
let mut frame = vec![0u8; Hardware::W * Hardware::H * 4];
draw_overlay(&mut frame, Hardware::W, Hardware::H, &sample_snapshot());
assert!(frame.iter().any(|&byte| byte != 0));
}

View File

@ -26,6 +26,13 @@ use std::path::PathBuf;
const STRESS_GLYPH_ASSET_ID: AssetId = 100;
const STRESS_SCENE_ASSET_ID: AssetId = 1;
const STRESS_VIEWPORT_W: i32 = 480;
const STRESS_VIEWPORT_H: i32 = 270;
const STRESS_SPRITE_COLS: i32 = 48;
const STRESS_SPRITE_ROWS: i32 = 24;
const STRESS_SPRITE_SPACING_PX: i32 = 10;
const STRESS_SCENE_TILE_W: usize = 96;
const STRESS_SCENE_TILE_H: usize = 64;
fn asm(s: &str) -> Vec<u8> {
assemble(s).expect("assemble")
@ -141,34 +148,38 @@ fn heavy_load(rom: &mut Vec<u8>) {
rom.extend(asm("PUSH_I32 0\nHOSTCALL 0"));
rom.extend(asm(
"GET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 192\nMOD\nGET_GLOBAL 0\nPUSH_I32 76\nMOD\nHOSTCALL 4",
));
let scene_width_px = (STRESS_SCENE_TILE_W * 8) as i32;
let scene_height_px = (STRESS_SCENE_TILE_H * 8) as i32;
let camera_x_span = scene_width_px - STRESS_VIEWPORT_W;
let camera_y_span = scene_height_px - STRESS_VIEWPORT_H;
rom.extend(asm(&format!(
"GET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 {camera_x_span}\nMOD\nGET_GLOBAL 0\nPUSH_I32 {camera_y_span}\nMOD\nHOSTCALL 4"
)));
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"));
rom.extend(asm(&format!("GET_LOCAL 0\nPUSH_I32 {STRESS_SPRITE_ROWS}\nLT")));
let jif_row_end_offset = rom.len() + 2;
rom.extend(asm("JMP_IF_FALSE 0"));
rom.extend(asm("PUSH_I32 0\nSET_LOCAL 1"));
let col_loop_start = rom.len() as u32;
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 32\nLT"));
rom.extend(asm(&format!("GET_LOCAL 1\nPUSH_I32 {STRESS_SPRITE_COLS}\nLT")));
let jif_col_end_offset = rom.len() + 2;
rom.extend(asm("JMP_IF_FALSE 0"));
rom.extend(asm(
rom.extend(asm(&format!(
"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\nPUSH_I32 {STRESS_SPRITE_COLS}\nMUL\nGET_LOCAL 1\nADD\nGET_GLOBAL 0\nADD\nPUSH_I32 15\nMOD\nPUSH_I32 1\nADD\n\
GET_LOCAL 1\nPUSH_I32 {STRESS_SPRITE_SPACING_PX}\nMUL\nGET_GLOBAL 0\nPUSH_I32 10\nMOD\nADD\nPUSH_I32 {STRESS_VIEWPORT_W}\nMOD\n\
GET_LOCAL 0\nPUSH_I32 {STRESS_SPRITE_SPACING_PX}\nMUL\nGET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 10\nMOD\nADD\nPUSH_I32 {STRESS_VIEWPORT_H}\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",
));
HOSTCALL 5\nPOP_N 1"
)));
rom.extend(asm("GET_LOCAL 1\nPUSH_I32 1\nADD\nSET_LOCAL 1"));
let jmp_col_loop_offset = rom.len() + 2;
@ -180,23 +191,23 @@ fn heavy_load(rom: &mut Vec<u8>) {
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\
rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 3\nMUL\nPUSH_I32 380\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\
rom.extend(asm("PUSH_I32 16\n\
GET_GLOBAL 0\nPUSH_I32 2\nMUL\nPUSH_I32 186\nMOD\nPUSH_I32 32\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\
rom.extend(asm("PUSH_I32 336\n\
GET_GLOBAL 0\nPUSH_I32 5\nMUL\nPUSH_I32 194\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\
rom.extend(asm("GET_GLOBAL 0\nPUSH_I32 4\nMUL\nPUSH_I32 240\nMOD\nPUSH_I32 112\nADD\n\
GET_GLOBAL 0\nPUSH_I32 3\nMUL\nPUSH_I32 120\nMOD\nPUSH_I32 88\nADD\n\
PUSH_CONST 3\n\
GET_GLOBAL 0\nPUSH_I32 3001\nMUL\nPUSH_I32 65535\nBIT_AND\n\
HOSTCALL 1"));
@ -326,19 +337,23 @@ fn build_scene_bank() -> SceneBank {
flip_x: false,
flip_y: false,
};
64 * 32
STRESS_SCENE_TILE_W * STRESS_SCENE_TILE_H
];
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,
};
for y in 0..STRESS_SCENE_TILE_H {
for x in 0..STRESS_SCENE_TILE_W {
let diagonal = (x + layer_index * 2) % 11 == (y * 2) % 11;
let stripe = (x / 6 + y / 5 + layer_index) % 5 == 0;
if diagonal || stripe {
let index = y * STRESS_SCENE_TILE_W + 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 {
@ -351,15 +366,15 @@ fn build_scene_bank() -> SceneBank {
2 => ParallaxFactor { x: 0.5, y: 0.5 },
_ => ParallaxFactor { x: 0.25, y: 0.25 },
},
tilemap: TileMap { width: 64, height: 32, tiles },
tilemap: TileMap { width: STRESS_SCENE_TILE_W, height: STRESS_SCENE_TILE_H, 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;
for step in 0..12 {
let x = 10 + step * 7;
let y = 4 + step * 3;
let index = y * STRESS_SCENE_TILE_W + x;
layers[3].tilemap.tiles[index].active = false;
}

View File

@ -1,4 +1,4 @@
{"type":"meta","next_id":{"DSC":30,"AGD":30,"DEC":22,"PLN":43,"LSN":39,"CLSN":1}}
{"type":"meta","next_id":{"DSC":31,"AGD":31,"DEC":23,"PLN":46,"LSN":40,"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"}]}
@ -28,3 +28,4 @@
{"type":"discussion","id":"DSC-0017","status":"done","ticket":"asset-entry-metadata-normalization-contract","title":"Asset Entry Metadata Normalization Contract","created_at":"2026-03-27","updated_at":"2026-04-09","tags":[],"agendas":[{"id":"AGD-0016","file":"workflow/agendas/AGD-0016-asset-entry-metadata-normalization-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-04-09"}],"decisions":[{"id":"DEC-0004","file":"workflow/decisions/DEC-0004-asset-entry-metadata-normalization-contract.md","status":"accepted","created_at":"2026-04-09","updated_at":"2026-04-09"}],"plans":[],"lessons":[{"id":"LSN-0023","file":"lessons/DSC-0017-asset-metadata-normalization/LSN-0023-typed-asset-metadata-helpers.md","status":"done","created_at":"2026-04-09","updated_at":"2026-04-09"}]}
{"type":"discussion","id":"DSC-0018","status":"done","ticket":"asset-load-asset-id-int-contract","title":"Asset Load Asset ID Int Contract","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["asset","runtime","abi"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0019","file":"lessons/DSC-0018-asset-load-asset-id-int-contract/LSN-0019-asset-load-id-abi-convergence.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
{"type":"discussion","id":"DSC-0019","status":"done","ticket":"jenkinsfile-correction","title":"Jenkinsfile Correction and Relocation","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["ci","jenkins"],"agendas":[{"id":"AGD-0017","file":"workflow/agendas/AGD-0017-jenkinsfile-correction.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0002","file":"workflow/decisions/DEC-0002-jenkinsfile-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0002","file":"workflow/plans/PLN-0002-jenkinsfile-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0020","file":"lessons/DSC-0019-jenkins-ci-standardization/LSN-0020-jenkins-standard-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0030","status":"done","ticket":"internal-viewport-270p","title":"Agenda - Internal Viewport 270p (480x270)","created_at":"2026-04-27","updated_at":"2026-04-28","tags":["gfx","runtime","viewport","resolution","frame-composer","host"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0039","file":"lessons/DSC-0030-internal-viewport-270p/LSN-0039-resolution-baseline-changes-are-runtime-wide-contracts.md","status":"done","created_at":"2026-04-28","updated_at":"2026-04-28"}]}

View File

@ -0,0 +1,72 @@
---
id: LSN-0039
ticket: internal-viewport-270p
title: Resolution Baseline Changes Are Runtime-Wide Contracts, Not Host-Only Resizes
created: 2026-04-28
tags: [gfx, runtime, viewport, resolution, frame-composer, host, docs]
---
## Context
`DSC-0030` changed Prometeu's canonical internal viewport from `320x180` to `480x270`. The important outcome was not just a larger host window or sharper scaling. The accepted contract changed what the console shows per frame, which forced coordinated propagation through runtime constants, frame composition, host presentation, pointer mapping, tests, stress fixtures, and published specs.
The work only became truly complete after specs and touch semantics were updated to match executable reality. Until that final publication step, the discussion was still operationally open even though most code paths had already migrated.
## Key Decisions
### Resolution Baseline Is a Console Contract
**What:**
`480x270` became the single canonical runtime framebuffer and viewport baseline for Prometeu.
**Why:**
Treating resolution as a host presentation detail would preserve the old framing and miss the product-level intent. The migration was explicitly about showing more world per frame, so the contract had to move at the runtime level.
**Trade-offs:**
This increases framebuffer, cache, and copy-path cost. It also makes partial migrations dangerous because old assumptions can survive in tests, tools, and docs even after the main runtime constants change.
### Propagation Must Follow Ownership Boundaries
**What:**
The migration propagated through the systems that actually own the contract:
- runtime constants and `FrameComposer` viewport behavior;
- host framebuffer creation and absolute touch mapping;
- tests and stress cartridges that encode visible-domain assumptions;
- public specs for graphics and touch semantics.
**Why:**
Viewport changes cross subsystem boundaries, but each subsystem should update only the part it owns. That keeps the migration coherent without re-opening unrelated architecture.
**Trade-offs:**
The work naturally splits into several execution slices. That adds coordination overhead, but it prevents one oversized patch from mixing runtime behavior, host presentation, and documentation cleanup without a clear boundary.
### Housekeeping Should Wait for the Last Contract Publication Step
**What:**
The discussion was only ready for cleanup after the docs slice closed and no public spec still described `320x180` as canonical.
**Why:**
A finished implementation with stale published contract text is still incomplete from the repository's point of view. Housekeeping should reflect published truth, not just mostly-finished code.
**Trade-offs:**
This keeps discussions open slightly longer, but it avoids archiving a thread before the normative contract is actually consistent everywhere it is published.
## Patterns and Algorithms
- Treat viewport or resolution changes as contract migrations, not cosmetic host tweaks.
- Split propagation by ownership: runtime, host/tests, then specs/docs.
- Rebase stress fixtures and generated cartridges when they encode screen-domain assumptions.
- Use one canonical source of resolution truth in code and make docs follow only after executable migration is complete.
## Pitfalls
- Updating only the host surface can look successful while silently preserving the wrong gameplay framing.
- Test fixtures and generated artifacts often keep old screen bounds longer than production code.
- Documentation can lag behind implementation and keep the discussion operationally open even when code seems done.
## Takeaways
- Internal resolution is part of the console contract and must be migrated runtime-wide.
- A framing change is not equivalent to a presentation-scale change.
- Specs and generated stress assets are part of closing the contract, not optional cleanup.
- Housekeeping should happen only after code, tests, tooling, and published docs agree on the same baseline.

View File

@ -23,7 +23,7 @@ It is an explicit 2D graphics device based on:
### Base resolution
- **320 × 180 pixels**
- **480 × 270 pixels**
- aspect ratio close to 16:9
- scalable by the host (nearest-neighbor)
@ -142,9 +142,9 @@ The cache is a window of tiles around the camera.
Example:
- Screen: 320×180
- 16×16 tiles → 20×12 visible
- Cache: 22×14 (1-tile margin)
- Screen: 480×270
- 16×16 tiles → approximately 30×17 visible
- Current runtime cache sizing with 16×16 layers: 35×21 tiles
It stores tiles already resolved from the tilemap.
@ -316,7 +316,7 @@ In v1, deferred `gfx.*` overlay/debug primitives are drained after both fades an
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.
"almost continuous" visual result in Prometeu's `480x270` pixel-art framebuffer.
---

View File

@ -20,11 +20,11 @@ Core contract:
## 2 Coordinate Space
- TOUCH coordinates use **the same space as the framebuffer**
- Resolution: **320×180**
- Resolution: **480×270**
- Origin: top-left corner `(0,0)`
- Ranges:
- `x ∈ [0, 319]`
- `y ∈ [0, 179]`
- `x ∈ [0, 479]`
- `y ∈ [0, 269]`
TOUCH is **absolute**:

View File

@ -1,5 +1,5 @@
#!/bin/bash
# Get version from git tag or fallback to Cargo.toml version
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || grep -m 1 '^version =' crates/prometeu/Cargo.toml | cut -d '"' -f 2 || echo "0.1.0")
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || grep -m 1 '^version =' crates/tools/prometeu-cli/Cargo.toml | cut -d '"' -f 2 || echo "0.1.0")
echo "$VERSION" > VERSION.txt
echo "Generated VERSION.txt with version $VERSION"