Merge pull request 'dev/new-scene-plt' (#15) from dev/new-scene-plt into master
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good
All checks were successful
Intrepid/Prometeu/Runtime/pipeline/head This commit looks good
Reviewed-on: #15
This commit is contained in:
commit
738976352c
@ -1,15 +1,23 @@
|
||||
#![allow(clippy::collapsible_if)]
|
||||
use crate::memory_banks::{GlyphBankPoolInstaller, SoundBankPoolInstaller};
|
||||
use crate::memory_banks::{GlyphBankPoolInstaller, SceneBankPoolInstaller, SoundBankPoolInstaller};
|
||||
use prometeu_hal::AssetBridge;
|
||||
use prometeu_hal::asset::{
|
||||
AssetCodec, AssetEntry, AssetId, AssetLoadError, AssetOpStatus, BankTelemetry, BankType,
|
||||
HandleId, LoadStatus, PreloadEntry, SlotRef, SlotStats,
|
||||
HandleId, LoadStatus, PreloadEntry, SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1,
|
||||
SCENE_HEADER_BYTES_V1, SCENE_LAYER_COUNT_V1, SCENE_LAYER_HEADER_BYTES_V1,
|
||||
SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1, SCENE_TILE_RECORD_BYTES_V1, SlotRef,
|
||||
SlotStats,
|
||||
};
|
||||
use prometeu_hal::cartridge::AssetsPayloadSource;
|
||||
use prometeu_hal::color::Color;
|
||||
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::sound_bank::SoundBank;
|
||||
use prometeu_hal::tile::Tile;
|
||||
use prometeu_hal::tilemap::TileMap;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@ -151,15 +159,19 @@ pub struct AssetManager {
|
||||
/// Narrow hardware interfaces
|
||||
gfx_installer: Arc<dyn GlyphBankPoolInstaller>,
|
||||
sound_installer: Arc<dyn SoundBankPoolInstaller>,
|
||||
scene_installer: Arc<dyn SceneBankPoolInstaller>,
|
||||
|
||||
/// Track what is installed in each hardware slot (for stats/info).
|
||||
gfx_slots: Arc<RwLock<[Option<AssetId>; 16]>>,
|
||||
sound_slots: Arc<RwLock<[Option<AssetId>; 16]>>,
|
||||
scene_slots: Arc<RwLock<[Option<AssetId>; 16]>>,
|
||||
|
||||
/// Residency policy for GFX glyph banks.
|
||||
gfx_policy: BankPolicy<GlyphBank>,
|
||||
/// Residency policy for sound banks.
|
||||
sound_policy: BankPolicy<SoundBank>,
|
||||
/// Residency policy for scene banks.
|
||||
scene_policy: BankPolicy<SceneBank>,
|
||||
|
||||
// Commits that are ready to be applied at the next frame boundary.
|
||||
pending_commits: Mutex<Vec<HandleId>>,
|
||||
@ -275,6 +287,7 @@ impl AssetManager {
|
||||
match (entry.bank_type, entry.codec) {
|
||||
(BankType::GLYPH, AssetCodec::None) => Ok(AssetOpMode::StageInMemory),
|
||||
(BankType::SOUNDS, AssetCodec::None) => Ok(AssetOpMode::DirectFromSlice),
|
||||
(BankType::SCENE, AssetCodec::None) => Ok(AssetOpMode::DirectFromSlice),
|
||||
}
|
||||
}
|
||||
|
||||
@ -283,6 +296,7 @@ impl AssetManager {
|
||||
assets_data: AssetsPayloadSource,
|
||||
gfx_installer: Arc<dyn GlyphBankPoolInstaller>,
|
||||
sound_installer: Arc<dyn SoundBankPoolInstaller>,
|
||||
scene_installer: Arc<dyn SceneBankPoolInstaller>,
|
||||
) -> Self {
|
||||
let mut asset_map = HashMap::new();
|
||||
for entry in assets {
|
||||
@ -293,10 +307,13 @@ impl AssetManager {
|
||||
assets: Arc::new(RwLock::new(asset_map)),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
gfx_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
|
||||
sound_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
|
||||
scene_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
|
||||
gfx_policy: BankPolicy::new(),
|
||||
sound_policy: BankPolicy::new(),
|
||||
scene_policy: BankPolicy::new(),
|
||||
handles: Arc::new(RwLock::new(HashMap::new())),
|
||||
next_handle_id: Mutex::new(1),
|
||||
assets_data: Arc::new(RwLock::new(assets_data)),
|
||||
@ -345,17 +362,7 @@ impl AssetManager {
|
||||
if slot_index < slots.len() {
|
||||
slots[slot_index] = Some(entry.asset_id);
|
||||
}
|
||||
// println!(
|
||||
// "[AssetManager] Preloaded tile asset '{}' (id: {}) into slot {}",
|
||||
// entry.asset_name, entry.asset_id, slot_index
|
||||
// );
|
||||
}
|
||||
// else {
|
||||
// eprintln!(
|
||||
// "[AssetManager] Failed to preload tile asset '{}'",
|
||||
// entry.asset_name
|
||||
// );
|
||||
// }
|
||||
}
|
||||
BankType::SOUNDS => {
|
||||
if let Ok(bank) =
|
||||
@ -372,23 +379,27 @@ impl AssetManager {
|
||||
if slot_index < slots.len() {
|
||||
slots[slot_index] = Some(entry.asset_id);
|
||||
}
|
||||
// println!(
|
||||
// "[AssetManager] Preloaded sound asset '{}' (id: {}) into slot {}",
|
||||
// entry.asset_name, entry.asset_id, slot_index
|
||||
// );
|
||||
}
|
||||
// else {
|
||||
// eprintln!(
|
||||
// "[AssetManager] Failed to preload sound asset '{}'",
|
||||
// entry.asset_name
|
||||
// );
|
||||
// }
|
||||
}
|
||||
BankType::SCENE => {
|
||||
if let Ok(bank) =
|
||||
Self::perform_load_scene_bank(&entry, self.assets_data.clone())
|
||||
{
|
||||
let bank_arc = Arc::new(bank);
|
||||
self.scene_policy.put_resident(
|
||||
entry.asset_id,
|
||||
Arc::clone(&bank_arc),
|
||||
entry.decoded_size as usize,
|
||||
);
|
||||
self.scene_installer.install_scene_bank(slot_index, bank_arc);
|
||||
let mut slots = self.scene_slots.write().unwrap();
|
||||
if slot_index < slots.len() {
|
||||
slots[slot_index] = Some(entry.asset_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// else {
|
||||
// eprintln!("[AssetManager] Preload failed: asset id '{}' not found in table", item.asset_id);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@ -403,6 +414,7 @@ impl AssetManager {
|
||||
let slot = match entry.bank_type {
|
||||
BankType::GLYPH => SlotRef::gfx(slot_index),
|
||||
BankType::SOUNDS => SlotRef::audio(slot_index),
|
||||
BankType::SCENE => SlotRef::scene(slot_index),
|
||||
};
|
||||
|
||||
let mut next_id = self.next_handle_id.lock().unwrap();
|
||||
@ -427,6 +439,14 @@ impl AssetManager {
|
||||
false
|
||||
}
|
||||
}
|
||||
BankType::SCENE => {
|
||||
if let Some(bank) = self.scene_policy.get_resident(asset_id) {
|
||||
self.scene_policy.stage(handle_id, bank, entry.decoded_size as usize);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if already_resident {
|
||||
@ -450,6 +470,7 @@ impl AssetManager {
|
||||
// Capture policies for the worker thread
|
||||
let gfx_policy = self.gfx_policy.clone();
|
||||
let sound_policy = self.sound_policy.clone();
|
||||
let scene_policy = self.scene_policy.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
// Update status to LOADING
|
||||
@ -510,6 +531,34 @@ impl AssetManager {
|
||||
entry_clone.decoded_size as usize,
|
||||
);
|
||||
|
||||
let mut handles_map = handles.write().unwrap();
|
||||
if let Some(h) = handles_map.get_mut(&handle_id) {
|
||||
if h.status == LoadStatus::LOADING {
|
||||
h.status = LoadStatus::READY;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut handles_map = handles.write().unwrap();
|
||||
if let Some(h) = handles_map.get_mut(&handle_id) {
|
||||
h.status = LoadStatus::ERROR;
|
||||
}
|
||||
}
|
||||
}
|
||||
BankType::SCENE => {
|
||||
let result = Self::perform_load_scene_bank(&entry_clone, assets_data);
|
||||
if let Ok(scenebank) = result {
|
||||
let bank_arc = Arc::new(scenebank);
|
||||
let resident_arc = scene_policy.put_resident(
|
||||
asset_id,
|
||||
bank_arc,
|
||||
entry_clone.decoded_size as usize,
|
||||
);
|
||||
scene_policy.stage(
|
||||
handle_id,
|
||||
resident_arc,
|
||||
entry_clone.decoded_size as usize,
|
||||
);
|
||||
|
||||
let mut handles_map = handles.write().unwrap();
|
||||
if let Some(h) = handles_map.get_mut(&handle_id) {
|
||||
if h.status == LoadStatus::LOADING {
|
||||
@ -642,6 +691,190 @@ impl AssetManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn perform_load_scene_bank(
|
||||
entry: &AssetEntry,
|
||||
assets_data: Arc<RwLock<AssetsPayloadSource>>,
|
||||
) -> Result<SceneBank, String> {
|
||||
let _ = entry.metadata_as_scene_bank()?;
|
||||
let op_mode = Self::op_mode_for(entry)?;
|
||||
let slice = {
|
||||
let assets_data = assets_data.read().unwrap();
|
||||
assets_data
|
||||
.open_slice(entry.offset, entry.size)
|
||||
.map_err(|_| "Asset offset/size out of bounds".to_string())?
|
||||
};
|
||||
|
||||
match op_mode {
|
||||
AssetOpMode::DirectFromSlice => {
|
||||
let mut reader =
|
||||
slice.open_reader().map_err(|_| "Asset payload read failed".to_string())?;
|
||||
Self::decode_scene_bank_from_reader(entry, &mut reader)
|
||||
}
|
||||
AssetOpMode::StageInMemory => {
|
||||
let buffer =
|
||||
slice.read_all().map_err(|_| "Asset payload read failed".to_string())?;
|
||||
Self::decode_scene_bank_from_buffer(entry, &buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_scene_bank_from_buffer(
|
||||
entry: &AssetEntry,
|
||||
buffer: &[u8],
|
||||
) -> Result<SceneBank, String> {
|
||||
let _ = entry.metadata_as_scene_bank()?;
|
||||
|
||||
if buffer.len() < SCENE_HEADER_BYTES_V1 {
|
||||
return Err("Buffer too small for SCENE".to_string());
|
||||
}
|
||||
|
||||
if buffer[0..4] != SCENE_PAYLOAD_MAGIC_V1 {
|
||||
return Err("Invalid SCENE magic".to_string());
|
||||
}
|
||||
|
||||
let version = u16::from_le_bytes([buffer[4], buffer[5]]);
|
||||
if version != SCENE_PAYLOAD_VERSION_V1 {
|
||||
return Err(format!("Unsupported SCENE version: {}", version));
|
||||
}
|
||||
|
||||
let layer_count = u16::from_le_bytes([buffer[6], buffer[7]]) as usize;
|
||||
if layer_count != SCENE_LAYER_COUNT_V1 {
|
||||
return Err(format!("Invalid SCENE layer count: {}", layer_count));
|
||||
}
|
||||
|
||||
let mut offset = SCENE_HEADER_BYTES_V1;
|
||||
let mut decoded_size = 0_usize;
|
||||
let layers = std::array::from_fn(|_| SceneLayer {
|
||||
active: false,
|
||||
glyph_bank_id: 0,
|
||||
tile_size: TileSize::Size8,
|
||||
motion_factor: MotionFactor { x: 1.0, y: 1.0 },
|
||||
tilemap: TileMap { width: 0, height: 0, tiles: Vec::new() },
|
||||
});
|
||||
let mut layers = layers;
|
||||
|
||||
for layer in &mut layers {
|
||||
let header_end = offset
|
||||
.checked_add(SCENE_LAYER_HEADER_BYTES_V1)
|
||||
.ok_or("SCENE layer header offset overflow")?;
|
||||
if header_end > buffer.len() {
|
||||
return Err("Buffer too small for SCENE layer header".to_string());
|
||||
}
|
||||
|
||||
let flags = buffer[offset];
|
||||
let glyph_bank_id = buffer[offset + 1];
|
||||
let tile_size_raw = buffer[offset + 2];
|
||||
let tile_size = match tile_size_raw {
|
||||
8 => TileSize::Size8,
|
||||
16 => TileSize::Size16,
|
||||
32 => TileSize::Size32,
|
||||
other => return Err(format!("Invalid SCENE tile size: {}", other)),
|
||||
};
|
||||
let motion_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([
|
||||
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());
|
||||
}
|
||||
|
||||
let width = u32::from_le_bytes([
|
||||
buffer[offset + 12],
|
||||
buffer[offset + 13],
|
||||
buffer[offset + 14],
|
||||
buffer[offset + 15],
|
||||
]) as usize;
|
||||
let height = u32::from_le_bytes([
|
||||
buffer[offset + 16],
|
||||
buffer[offset + 17],
|
||||
buffer[offset + 18],
|
||||
buffer[offset + 19],
|
||||
]) as usize;
|
||||
let tile_count = u32::from_le_bytes([
|
||||
buffer[offset + 20],
|
||||
buffer[offset + 21],
|
||||
buffer[offset + 22],
|
||||
buffer[offset + 23],
|
||||
]) as usize;
|
||||
|
||||
let expected_tile_count =
|
||||
width.checked_mul(height).ok_or("SCENE tile count overflow")?;
|
||||
if tile_count != expected_tile_count {
|
||||
return Err(format!(
|
||||
"Invalid SCENE tile count for layer: expected {}, got {}",
|
||||
expected_tile_count, tile_count
|
||||
));
|
||||
}
|
||||
|
||||
offset = header_end;
|
||||
|
||||
let tile_bytes = tile_count
|
||||
.checked_mul(SCENE_TILE_RECORD_BYTES_V1)
|
||||
.ok_or("SCENE tile payload overflow")?;
|
||||
let tiles_end = offset.checked_add(tile_bytes).ok_or("SCENE payload overflow")?;
|
||||
if tiles_end > buffer.len() {
|
||||
return Err("Buffer too small for SCENE tile data".to_string());
|
||||
}
|
||||
|
||||
let mut tiles = Vec::with_capacity(tile_count);
|
||||
for _ in 0..tile_count {
|
||||
let tile_flags = buffer[offset];
|
||||
let palette_id = buffer[offset + 1];
|
||||
let glyph_id = u16::from_le_bytes([buffer[offset + 2], buffer[offset + 3]]);
|
||||
tiles.push(Tile {
|
||||
active: (tile_flags & 0b0000_0001) != 0,
|
||||
glyph: Glyph { glyph_id, palette_id },
|
||||
flip_x: (tile_flags & 0b0000_0010) != 0,
|
||||
flip_y: (tile_flags & 0b0000_0100) != 0,
|
||||
});
|
||||
offset += SCENE_TILE_RECORD_BYTES_V1;
|
||||
}
|
||||
|
||||
decoded_size = decoded_size
|
||||
.checked_add(SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1)
|
||||
.and_then(|size| size.checked_add(tile_count * size_of::<Tile>()))
|
||||
.ok_or("SCENE decoded_size overflow")?;
|
||||
|
||||
*layer = SceneLayer {
|
||||
active: (flags & 0b0000_0001) != 0,
|
||||
glyph_bank_id,
|
||||
tile_size,
|
||||
motion_factor: MotionFactor { x: motion_factor_x, y: motion_factor_y },
|
||||
tilemap: TileMap { width, height, tiles },
|
||||
};
|
||||
}
|
||||
|
||||
if offset != buffer.len() {
|
||||
return Err("Trailing bytes in SCENE payload".to_string());
|
||||
}
|
||||
|
||||
if entry.decoded_size != decoded_size as u64 {
|
||||
return Err(format!(
|
||||
"Invalid SCENE decoded_size: expected {}, got {}",
|
||||
decoded_size, entry.decoded_size
|
||||
));
|
||||
}
|
||||
|
||||
Ok(SceneBank { layers })
|
||||
}
|
||||
|
||||
fn decode_scene_bank_from_reader(
|
||||
entry: &AssetEntry,
|
||||
reader: &mut impl Read,
|
||||
) -> Result<SceneBank, String> {
|
||||
let mut raw = Vec::new();
|
||||
reader.read_to_end(&mut raw).map_err(|_| "Asset payload read failed".to_string())?;
|
||||
Self::decode_scene_bank_from_buffer(entry, &raw)
|
||||
}
|
||||
|
||||
fn decode_sound_bank_from_buffer(
|
||||
entry: &AssetEntry,
|
||||
buffer: &[u8],
|
||||
@ -709,6 +942,7 @@ impl AssetManager {
|
||||
}
|
||||
self.gfx_policy.take_staging(handle);
|
||||
self.sound_policy.take_staging(handle);
|
||||
self.scene_policy.take_staging(handle);
|
||||
final_status
|
||||
}
|
||||
|
||||
@ -740,6 +974,16 @@ impl AssetManager {
|
||||
h.status = LoadStatus::COMMITTED;
|
||||
}
|
||||
}
|
||||
BankType::SCENE => {
|
||||
if let Some((bank, _)) = self.scene_policy.take_staging(handle_id) {
|
||||
self.scene_installer.install_scene_bank(h.slot.index, bank);
|
||||
let mut slots = self.scene_slots.write().unwrap();
|
||||
if h.slot.index < slots.len() {
|
||||
slots[h.slot.index] = Some(h._asset_id);
|
||||
}
|
||||
h.status = LoadStatus::COMMITTED;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -747,7 +991,11 @@ impl AssetManager {
|
||||
}
|
||||
|
||||
pub fn bank_telemetry(&self) -> Vec<BankTelemetry> {
|
||||
vec![self.bank_telemetry_for(BankType::GLYPH), self.bank_telemetry_for(BankType::SOUNDS)]
|
||||
vec![
|
||||
self.bank_telemetry_for(BankType::GLYPH),
|
||||
self.bank_telemetry_for(BankType::SOUNDS),
|
||||
self.bank_telemetry_for(BankType::SCENE),
|
||||
]
|
||||
}
|
||||
|
||||
fn bank_telemetry_for(&self, kind: BankType) -> BankTelemetry {
|
||||
@ -758,6 +1006,9 @@ impl AssetManager {
|
||||
BankType::SOUNDS => {
|
||||
self.sound_slots.read().unwrap().iter().filter(|slot| slot.is_some()).count()
|
||||
}
|
||||
BankType::SCENE => {
|
||||
self.scene_slots.read().unwrap().iter().filter(|slot| slot.is_some()).count()
|
||||
}
|
||||
};
|
||||
|
||||
BankTelemetry { bank_type: kind, used_slots, total_slots: 16 }
|
||||
@ -805,6 +1056,27 @@ impl AssetManager {
|
||||
(0, None)
|
||||
};
|
||||
|
||||
SlotStats { asset_id, asset_name, generation: 0, resident_bytes: bytes }
|
||||
}
|
||||
BankType::SCENE => {
|
||||
let slots = self.scene_slots.read().unwrap();
|
||||
let asset_id = slots.get(slot.index).and_then(|s| *s);
|
||||
|
||||
let (bytes, asset_name) = if let Some(id) = &asset_id {
|
||||
let bytes = self
|
||||
.scene_policy
|
||||
.resident
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|entry| entry.bytes)
|
||||
.unwrap_or(0);
|
||||
let name = self.assets.read().unwrap().get(id).map(|e| e.asset_name.clone());
|
||||
(bytes, name)
|
||||
} else {
|
||||
(0, None)
|
||||
};
|
||||
|
||||
SlotStats { asset_id, asset_name, generation: 0, resident_bytes: bytes }
|
||||
}
|
||||
}
|
||||
@ -813,18 +1085,29 @@ impl AssetManager {
|
||||
pub fn shutdown(&self) {
|
||||
self.gfx_policy.clear();
|
||||
self.sound_policy.clear();
|
||||
self.scene_policy.clear();
|
||||
self.handles.write().unwrap().clear();
|
||||
self.pending_commits.lock().unwrap().clear();
|
||||
self.gfx_slots.write().unwrap().fill(None);
|
||||
self.sound_slots.write().unwrap().fill(None);
|
||||
self.scene_slots.write().unwrap().fill(None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory_banks::{GlyphBankPoolAccess, MemoryBanks, SoundBankPoolAccess};
|
||||
use prometeu_hal::asset::AssetCodec;
|
||||
use crate::memory_banks::{
|
||||
GlyphBankPoolAccess, MemoryBanks, SceneBankPoolAccess, SoundBankPoolAccess,
|
||||
};
|
||||
use prometeu_hal::asset::{
|
||||
AssetCodec, SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1, SCENE_LAYER_COUNT_V1,
|
||||
SCENE_PAYLOAD_MAGIC_V1, SCENE_PAYLOAD_VERSION_V1,
|
||||
};
|
||||
use prometeu_hal::glyph::Glyph;
|
||||
use prometeu_hal::scene_layer::{MotionFactor, SceneLayer};
|
||||
use prometeu_hal::tile::Tile;
|
||||
use prometeu_hal::tilemap::TileMap;
|
||||
|
||||
fn expected_glyph_payload_size(width: usize, height: usize) -> usize {
|
||||
(width * height).div_ceil(2) + GLYPH_BANK_PALETTE_BYTES_V1
|
||||
@ -859,6 +1142,131 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_scene() -> SceneBank {
|
||||
let make_layer =
|
||||
|glyph_bank_id: u8, motion_x: f32, motion_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 },
|
||||
tilemap: TileMap {
|
||||
width: 2,
|
||||
height: 2,
|
||||
tiles: vec![
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph {
|
||||
glyph_id: 10 + glyph_bank_id as u16,
|
||||
palette_id: glyph_bank_id,
|
||||
},
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
},
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph {
|
||||
glyph_id: 20 + glyph_bank_id as u16,
|
||||
palette_id: glyph_bank_id + 1,
|
||||
},
|
||||
flip_x: true,
|
||||
flip_y: false,
|
||||
},
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph {
|
||||
glyph_id: 30 + glyph_bank_id as u16,
|
||||
palette_id: glyph_bank_id + 2,
|
||||
},
|
||||
flip_x: false,
|
||||
flip_y: true,
|
||||
},
|
||||
Tile {
|
||||
active: glyph_bank_id != 2,
|
||||
glyph: Glyph {
|
||||
glyph_id: 40 + glyph_bank_id as u16,
|
||||
palette_id: glyph_bank_id + 3,
|
||||
},
|
||||
flip_x: true,
|
||||
flip_y: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
SceneBank {
|
||||
layers: [
|
||||
make_layer(0, 1.0, 1.0, TileSize::Size16),
|
||||
make_layer(1, 0.5, 0.75, TileSize::Size8),
|
||||
make_layer(2, 1.0, 0.5, TileSize::Size32),
|
||||
make_layer(3, 0.25, 0.25, TileSize::Size16),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
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.motion_factor.x.to_le_bytes());
|
||||
data.extend_from_slice(&layer.motion_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
|
||||
}
|
||||
|
||||
fn test_scene_asset_entry(asset_name: &str, data: &[u8], scene: &SceneBank) -> AssetEntry {
|
||||
AssetEntry {
|
||||
asset_id: 2,
|
||||
asset_name: asset_name.to_string(),
|
||||
bank_type: BankType::SCENE,
|
||||
offset: 0,
|
||||
size: data.len() as u64,
|
||||
decoded_size: expected_scene_decoded_size(scene) as u64,
|
||||
codec: AssetCodec::None,
|
||||
metadata: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_glyph_bank_unpacks_packed_pixels_and_reads_palette_colors() {
|
||||
let entry = test_glyph_asset_entry("glyphs", 2, 2);
|
||||
@ -933,11 +1341,85 @@ mod tests {
|
||||
assert_eq!(AssetManager::op_mode_for(&entry), Ok(AssetOpMode::DirectFromSlice));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_op_mode_for_scene_none_reads_direct_from_slice() {
|
||||
let scene = test_scene();
|
||||
let data = encode_scene_payload(&scene);
|
||||
let entry = test_scene_asset_entry("scene", &data, &scene);
|
||||
|
||||
assert_eq!(AssetManager::op_mode_for(&entry), Ok(AssetOpMode::DirectFromSlice));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_scene_bank_from_binary_payload() {
|
||||
let scene = test_scene();
|
||||
let data = encode_scene_payload(&scene);
|
||||
let entry = test_scene_asset_entry("scene", &data, &scene);
|
||||
|
||||
let decoded = AssetManager::decode_scene_bank_from_buffer(&entry, &data).expect("scene");
|
||||
|
||||
assert_eq!(decoded.layers[1].glyph_bank_id, 1);
|
||||
assert_eq!(decoded.layers[1].motion_factor.x, 0.5);
|
||||
assert_eq!(decoded.layers[2].tile_size, TileSize::Size32);
|
||||
assert_eq!(decoded.layers[0].tilemap.tiles[1].flip_x, true);
|
||||
assert_eq!(decoded.layers[2].tilemap.tiles[2].flip_y, true);
|
||||
assert_eq!(decoded.layers[3].active, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_scene_bank_rejects_invalid_version() {
|
||||
let scene = test_scene();
|
||||
let mut data = encode_scene_payload(&scene);
|
||||
data[4..6].copy_from_slice(&2_u16.to_le_bytes());
|
||||
let entry = test_scene_asset_entry("scene", &data, &scene);
|
||||
|
||||
let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err();
|
||||
|
||||
assert_eq!(err, "Unsupported SCENE version: 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_scene_bank_rejects_invalid_tile_size() {
|
||||
let scene = test_scene();
|
||||
let mut data = encode_scene_payload(&scene);
|
||||
data[14] = 12;
|
||||
let entry = test_scene_asset_entry("scene", &data, &scene);
|
||||
|
||||
let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err();
|
||||
|
||||
assert_eq!(err, "Invalid SCENE tile size: 12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_scene_bank_rejects_layer_count_mismatch() {
|
||||
let scene = test_scene();
|
||||
let mut data = encode_scene_payload(&scene);
|
||||
data[6..8].copy_from_slice(&3_u16.to_le_bytes());
|
||||
let entry = test_scene_asset_entry("scene", &data, &scene);
|
||||
|
||||
let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err();
|
||||
|
||||
assert_eq!(err, "Invalid SCENE layer count: 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_scene_bank_rejects_tile_count_mismatch() {
|
||||
let scene = test_scene();
|
||||
let mut data = encode_scene_payload(&scene);
|
||||
data[32..36].copy_from_slice(&5_u32.to_le_bytes());
|
||||
let entry = test_scene_asset_entry("scene", &data, &scene);
|
||||
|
||||
let err = AssetManager::decode_scene_bank_from_buffer(&entry, &data).unwrap_err();
|
||||
|
||||
assert_eq!(err, "Invalid SCENE tile count for layer: expected 4, got 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asset_loading_flow() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let gfx_installer = Arc::clone(&banks) as Arc<dyn GlyphBankPoolInstaller>;
|
||||
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
|
||||
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
|
||||
|
||||
let data = test_glyph_asset_data();
|
||||
let asset_entry = test_glyph_asset_entry("test_glyphs", 16, 16);
|
||||
@ -947,6 +1429,7 @@ mod tests {
|
||||
AssetsPayloadSource::from_bytes(data),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
);
|
||||
let handle = am.load(0, 0).expect("Should start loading");
|
||||
|
||||
@ -976,6 +1459,7 @@ mod tests {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let gfx_installer = Arc::clone(&banks) as Arc<dyn GlyphBankPoolInstaller>;
|
||||
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
|
||||
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
|
||||
|
||||
let data = test_glyph_asset_data();
|
||||
let asset_entry = test_glyph_asset_entry("test_glyphs", 16, 16);
|
||||
@ -985,6 +1469,7 @@ mod tests {
|
||||
AssetsPayloadSource::from_bytes(data),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
);
|
||||
|
||||
let handle1 = am.load(0, 0).unwrap();
|
||||
@ -1007,6 +1492,7 @@ mod tests {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let gfx_installer = Arc::clone(&banks) as Arc<dyn GlyphBankPoolInstaller>;
|
||||
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
|
||||
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
|
||||
|
||||
// 100 samples of 16-bit PCM (zeros)
|
||||
let data = vec![0u8; 200];
|
||||
@ -1030,6 +1516,7 @@ mod tests {
|
||||
AssetsPayloadSource::from_bytes(data),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
);
|
||||
let handle = am.load(1, 0).expect("Should start loading");
|
||||
|
||||
@ -1052,6 +1539,7 @@ mod tests {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let gfx_installer = Arc::clone(&banks) as Arc<dyn GlyphBankPoolInstaller>;
|
||||
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
|
||||
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
|
||||
|
||||
let data = vec![0u8; 200];
|
||||
|
||||
@ -1071,8 +1559,13 @@ mod tests {
|
||||
|
||||
let preload = vec![PreloadEntry { asset_id: 2, slot: 5 }];
|
||||
|
||||
let am =
|
||||
AssetManager::new(vec![], AssetsPayloadSource::empty(), gfx_installer, sound_installer);
|
||||
let am = AssetManager::new(
|
||||
vec![],
|
||||
AssetsPayloadSource::empty(),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
);
|
||||
|
||||
// Before init, slot 5 is empty
|
||||
assert!(banks.sound_bank_slot(5).is_none());
|
||||
@ -1088,13 +1581,83 @@ mod tests {
|
||||
assert_eq!(am.slot_info(SlotRef::audio(5)).asset_id, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scene_asset_loading() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let gfx_installer = Arc::clone(&banks) as Arc<dyn GlyphBankPoolInstaller>;
|
||||
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
|
||||
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
|
||||
|
||||
let scene = test_scene();
|
||||
let data = encode_scene_payload(&scene);
|
||||
let asset_entry = test_scene_asset_entry("test_scene", &data, &scene);
|
||||
|
||||
let am = AssetManager::new(
|
||||
vec![asset_entry],
|
||||
AssetsPayloadSource::from_bytes(data),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
);
|
||||
let handle = am.load(2, 0).expect("Should start loading scene");
|
||||
|
||||
let start = Instant::now();
|
||||
while am.status(handle) != LoadStatus::READY && start.elapsed().as_secs() < 5 {
|
||||
thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
assert_eq!(am.status(handle), LoadStatus::READY);
|
||||
am.commit(handle);
|
||||
am.apply_commits();
|
||||
|
||||
assert_eq!(am.status(handle), LoadStatus::COMMITTED);
|
||||
assert!(banks.scene_bank_slot(0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scene_preload_on_init() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let gfx_installer = Arc::clone(&banks) as Arc<dyn GlyphBankPoolInstaller>;
|
||||
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
|
||||
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
|
||||
|
||||
let scene = test_scene();
|
||||
let data = encode_scene_payload(&scene);
|
||||
let asset_entry = test_scene_asset_entry("preload_scene", &data, &scene);
|
||||
let preload = vec![PreloadEntry { asset_id: 2, slot: 4 }];
|
||||
|
||||
let am = AssetManager::new(
|
||||
vec![],
|
||||
AssetsPayloadSource::empty(),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
);
|
||||
|
||||
assert!(banks.scene_bank_slot(4).is_none());
|
||||
am.initialize_for_cartridge(
|
||||
vec![asset_entry],
|
||||
preload,
|
||||
AssetsPayloadSource::from_bytes(data),
|
||||
);
|
||||
|
||||
assert!(banks.scene_bank_slot(4).is_some());
|
||||
assert_eq!(am.slot_info(SlotRef::scene(4)).asset_id, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_returns_asset_not_found() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let gfx_installer = Arc::clone(&banks) as Arc<dyn GlyphBankPoolInstaller>;
|
||||
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
|
||||
let am =
|
||||
AssetManager::new(vec![], AssetsPayloadSource::empty(), gfx_installer, sound_installer);
|
||||
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
|
||||
let am = AssetManager::new(
|
||||
vec![],
|
||||
AssetsPayloadSource::empty(),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
);
|
||||
|
||||
let result = am.load(999, 0);
|
||||
|
||||
@ -1106,12 +1669,14 @@ mod tests {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let gfx_installer = Arc::clone(&banks) as Arc<dyn GlyphBankPoolInstaller>;
|
||||
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
|
||||
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
|
||||
let data = test_glyph_asset_data();
|
||||
let am = AssetManager::new(
|
||||
vec![test_glyph_asset_entry("test_glyphs", 16, 16)],
|
||||
AssetsPayloadSource::from_bytes(data),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
);
|
||||
|
||||
let result = am.load(0, 16);
|
||||
@ -1124,8 +1689,14 @@ mod tests {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let gfx_installer = Arc::clone(&banks) as Arc<dyn GlyphBankPoolInstaller>;
|
||||
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
|
||||
let am =
|
||||
AssetManager::new(vec![], AssetsPayloadSource::empty(), gfx_installer, sound_installer);
|
||||
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
|
||||
let am = AssetManager::new(
|
||||
vec![],
|
||||
AssetsPayloadSource::empty(),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
);
|
||||
|
||||
assert_eq!(am.status(999), LoadStatus::UnknownHandle);
|
||||
}
|
||||
@ -1135,12 +1706,14 @@ mod tests {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let gfx_installer = Arc::clone(&banks) as Arc<dyn GlyphBankPoolInstaller>;
|
||||
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
|
||||
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
|
||||
let data = test_glyph_asset_data();
|
||||
let am = AssetManager::new(
|
||||
vec![test_glyph_asset_entry("test_glyphs", 16, 16)],
|
||||
AssetsPayloadSource::from_bytes(data),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
);
|
||||
|
||||
assert_eq!(am.commit(999), AssetOpStatus::UnknownHandle);
|
||||
@ -1162,6 +1735,7 @@ mod tests {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
let gfx_installer = Arc::clone(&banks) as Arc<dyn GlyphBankPoolInstaller>;
|
||||
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
|
||||
let scene_installer = Arc::clone(&banks) as Arc<dyn SceneBankPoolInstaller>;
|
||||
|
||||
let width = 16;
|
||||
let height = 16;
|
||||
@ -1172,6 +1746,7 @@ mod tests {
|
||||
AssetsPayloadSource::from_bytes(data),
|
||||
gfx_installer,
|
||||
sound_installer,
|
||||
scene_installer,
|
||||
);
|
||||
|
||||
// Initially zero
|
||||
|
||||
@ -2,10 +2,10 @@ use crate::memory_banks::GlyphBankPoolAccess;
|
||||
use prometeu_hal::GfxBridge;
|
||||
use prometeu_hal::color::Color;
|
||||
use prometeu_hal::glyph::Glyph;
|
||||
use prometeu_hal::glyph_bank::{GlyphBank, TileSize};
|
||||
use prometeu_hal::glyph_bank::GlyphBank;
|
||||
use prometeu_hal::scene_viewport_cache::{CachedTileEntry, SceneViewportCache};
|
||||
use prometeu_hal::scene_viewport_resolver::{LayerCopyRequest, ResolverUpdate};
|
||||
use prometeu_hal::sprite::Sprite;
|
||||
use prometeu_hal::tile::Tile;
|
||||
use prometeu_hal::tile_layer::{HudTileLayer, ScrollableTileLayer, TileMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Blending modes inspired by classic 16-bit hardware.
|
||||
@ -57,10 +57,6 @@ pub struct Gfx {
|
||||
/// Back buffer: the "Work RAM" where new frames are composed.
|
||||
back: Vec<u16>,
|
||||
|
||||
/// 4 scrollable background layers. Each can have its own scroll (X, Y) and GlyphBank.
|
||||
pub layers: [ScrollableTileLayer; 4],
|
||||
/// 1 fixed layer for User Interface (HUD). It doesn't scroll.
|
||||
pub hud: HudTileLayer,
|
||||
/// Shared access to graphical memory banks (tiles and palettes).
|
||||
pub glyph_banks: Arc<dyn GlyphBankPoolAccess>,
|
||||
/// Hardware sprite list (512 slots). Equivalent to OAM (Object Attribute Memory).
|
||||
@ -214,11 +210,8 @@ impl GfxBridge for Gfx {
|
||||
fn render_all(&mut self) {
|
||||
self.render_all()
|
||||
}
|
||||
fn render_layer(&mut self, layer_idx: usize) {
|
||||
self.render_layer(layer_idx)
|
||||
}
|
||||
fn render_hud(&mut self) {
|
||||
self.render_hud()
|
||||
fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) {
|
||||
self.render_scene_from_cache(cache, update)
|
||||
}
|
||||
fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color) {
|
||||
self.draw_text(x, y, text, color)
|
||||
@ -227,18 +220,6 @@ impl GfxBridge for Gfx {
|
||||
self.draw_char(x, y, c, color)
|
||||
}
|
||||
|
||||
fn layer(&self, index: usize) -> &ScrollableTileLayer {
|
||||
&self.layers[index]
|
||||
}
|
||||
fn layer_mut(&mut self, index: usize) -> &mut ScrollableTileLayer {
|
||||
&mut self.layers[index]
|
||||
}
|
||||
fn hud(&self) -> &HudTileLayer {
|
||||
&self.hud
|
||||
}
|
||||
fn hud_mut(&mut self) -> &mut HudTileLayer {
|
||||
&mut self.hud
|
||||
}
|
||||
fn sprite(&self, index: usize) -> &Sprite {
|
||||
&self.sprites[index]
|
||||
}
|
||||
@ -289,20 +270,11 @@ impl Gfx {
|
||||
};
|
||||
|
||||
let len = w * h;
|
||||
let layers = [
|
||||
ScrollableTileLayer::new(64, 64, TileSize::Size16),
|
||||
ScrollableTileLayer::new(64, 64, TileSize::Size16),
|
||||
ScrollableTileLayer::new(64, 64, TileSize::Size16),
|
||||
ScrollableTileLayer::new(64, 64, TileSize::Size16),
|
||||
];
|
||||
|
||||
Self {
|
||||
w,
|
||||
h,
|
||||
front: vec![0; len],
|
||||
back: vec![0; len],
|
||||
layers,
|
||||
hud: HudTileLayer::new(64, 32),
|
||||
glyph_banks,
|
||||
sprites: [EMPTY_SPRITE; 512],
|
||||
scene_fade_level: 31,
|
||||
@ -564,17 +536,7 @@ impl Gfx {
|
||||
/// 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) {
|
||||
// 0. Preparation Phase: Filter and group sprites by their priority levels.
|
||||
// This avoids iterating through all 512 sprites for every layer.
|
||||
for bucket in self.priority_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);
|
||||
}
|
||||
}
|
||||
self.populate_priority_buckets();
|
||||
|
||||
// 1. Priority 0 sprites: drawn at the very back, behind everything else.
|
||||
Self::draw_bucket_on_buffer(
|
||||
@ -586,162 +548,128 @@ impl Gfx {
|
||||
&*self.glyph_banks,
|
||||
);
|
||||
|
||||
// 2. Main layers and prioritized sprites.
|
||||
// Order: Layer 0 -> Sprites 1 -> Layer 1 -> Sprites 2 ...
|
||||
for i in 0..self.layers.len() {
|
||||
let bank_id = self.layers[i].bank_id as usize;
|
||||
if let Some(bank) = self.glyph_banks.glyph_bank_slot(bank_id) {
|
||||
Self::draw_tile_map(
|
||||
&mut self.back,
|
||||
self.w,
|
||||
self.h,
|
||||
&self.layers[i].map,
|
||||
&bank,
|
||||
self.layers[i].scroll_x,
|
||||
self.layers[i].scroll_y,
|
||||
);
|
||||
// 2. Scene-only fallback path: sprites and fades still work even before a
|
||||
// cache-backed world composition request is issued for the frame.
|
||||
Self::apply_fade_to_buffer(&mut self.back, self.scene_fade_level, self.scene_fade_color);
|
||||
|
||||
// 3. HUD Fade: independent from scene fade; HUD composition itself remains external.
|
||||
Self::apply_fade_to_buffer(&mut self.back, self.hud_fade_level, self.hud_fade_color);
|
||||
}
|
||||
|
||||
// Draw sprites that belong to this depth level
|
||||
/// Composes the world from the viewport cache using resolver copy requests.
|
||||
///
|
||||
/// This is the cache-backed world path accepted by DEC-0013. The canonical scene
|
||||
/// is not consulted here; the renderer only consumes prepared cache materialization
|
||||
/// plus sprite state and fade controls.
|
||||
pub fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate) {
|
||||
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[i + 1],
|
||||
&self.priority_buckets[0],
|
||||
&self.sprites,
|
||||
&*self.glyph_banks,
|
||||
);
|
||||
|
||||
for layer_index in 0..cache.layers.len() {
|
||||
Self::draw_cache_layer_to_buffer(
|
||||
&mut self.back,
|
||||
self.w,
|
||||
self.h,
|
||||
cache,
|
||||
&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,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Scene Fade: Applies a color blend to the entire world (excluding HUD).
|
||||
Self::apply_fade_to_buffer(&mut self.back, self.scene_fade_level, self.scene_fade_color);
|
||||
|
||||
// 5. HUD: The fixed interface layer, always drawn on top of the world.
|
||||
Self::render_hud_with_pool(&mut self.back, self.w, self.h, &self.hud, &*self.glyph_banks);
|
||||
|
||||
// 6. HUD Fade: Independent fade effect for the UI.
|
||||
Self::apply_fade_to_buffer(&mut self.back, self.hud_fade_level, self.hud_fade_color);
|
||||
}
|
||||
|
||||
/// Renders a specific game layer.
|
||||
pub fn render_layer(&mut self, layer_idx: usize) {
|
||||
if layer_idx >= self.layers.len() {
|
||||
return;
|
||||
fn populate_priority_buckets(&mut self) {
|
||||
for bucket in self.priority_buckets.iter_mut() {
|
||||
bucket.clear();
|
||||
}
|
||||
|
||||
let bank_id = self.layers[layer_idx].bank_id as usize;
|
||||
let scroll_x = self.layers[layer_idx].scroll_x;
|
||||
let scroll_y = self.layers[layer_idx].scroll_y;
|
||||
|
||||
let bank = match self.glyph_banks.glyph_bank_slot(bank_id) {
|
||||
Some(b) => b,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
Self::draw_tile_map(
|
||||
&mut self.back,
|
||||
self.w,
|
||||
self.h,
|
||||
&self.layers[layer_idx].map,
|
||||
&bank,
|
||||
scroll_x,
|
||||
scroll_y,
|
||||
);
|
||||
for (idx, sprite) in self.sprites.iter().enumerate() {
|
||||
if sprite.active && sprite.priority < 5 {
|
||||
self.priority_buckets[sprite.priority as usize].push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the HUD (fixed position, no scroll).
|
||||
pub fn render_hud(&mut self) {
|
||||
Self::render_hud_with_pool(&mut self.back, self.w, self.h, &self.hud, &*self.glyph_banks);
|
||||
}
|
||||
|
||||
fn render_hud_with_pool(
|
||||
back: &mut [u16],
|
||||
w: usize,
|
||||
h: usize,
|
||||
hud: &HudTileLayer,
|
||||
glyph_banks: &dyn GlyphBankPoolAccess,
|
||||
) {
|
||||
let bank_id = hud.bank_id as usize;
|
||||
let bank = match glyph_banks.glyph_bank_slot(bank_id) {
|
||||
Some(b) => b,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
Self::draw_tile_map(back, w, h, &hud.map, &bank, 0, 0);
|
||||
}
|
||||
|
||||
/// Rasterizes a TileMap into the provided pixel buffer using scrolling.
|
||||
fn draw_tile_map(
|
||||
fn draw_cache_layer_to_buffer(
|
||||
back: &mut [u16],
|
||||
screen_w: usize,
|
||||
screen_h: usize,
|
||||
map: &TileMap,
|
||||
bank: &GlyphBank,
|
||||
scroll_x: i32,
|
||||
scroll_y: i32,
|
||||
cache: &SceneViewportCache,
|
||||
request: &LayerCopyRequest,
|
||||
glyph_banks: &dyn GlyphBankPoolAccess,
|
||||
) {
|
||||
let tile_size = bank.tile_size as usize;
|
||||
let layer_cache = &cache.layers[request.layer_index];
|
||||
if !layer_cache.valid {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Determine the range of visible tiles based on the scroll position.
|
||||
// We add a margin of 1 tile to ensure smooth pixel-perfect scrolling at the borders.
|
||||
let visible_tiles_x = (screen_w / tile_size) + 1;
|
||||
let visible_tiles_y = (screen_h / tile_size) + 1;
|
||||
let Some(bank) = glyph_banks.glyph_bank_slot(layer_cache.glyph_bank_id as usize) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 2. Calculate offsets within the tilemap.
|
||||
let start_tile_x = scroll_x / tile_size as i32;
|
||||
let start_tile_y = scroll_y / tile_size as i32;
|
||||
|
||||
// 3. Fine scroll: how many pixels the tiles are shifted within the first visible cell.
|
||||
let fine_scroll_x = scroll_x % tile_size as i32;
|
||||
let fine_scroll_y = scroll_y % tile_size as i32;
|
||||
|
||||
// 4. Iterate only through the tiles that are actually visible on screen.
|
||||
for ty in 0..visible_tiles_y {
|
||||
for tx in 0..visible_tiles_x {
|
||||
let map_x = (start_tile_x + tx as i32) as usize;
|
||||
let map_y = (start_tile_y + ty as i32) as usize;
|
||||
|
||||
// Bounds check: don't draw if the camera is outside the map.
|
||||
if map_x >= map.width || map_y >= map.height {
|
||||
let tile_size_px = request.tile_size as i32;
|
||||
for cache_y in 0..layer_cache.height() {
|
||||
let screen_tile_y = cache_y as i32 * tile_size_px - request.source_offset_y_px;
|
||||
if screen_tile_y >= screen_h as i32 || screen_tile_y + tile_size_px <= 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tile = map.tiles[map_y * map.width + map_x];
|
||||
|
||||
// Optimized skip for empty (ID 0) tiles.
|
||||
if !tile.active {
|
||||
for cache_x in 0..layer_cache.width() {
|
||||
let screen_tile_x = cache_x as i32 * tile_size_px - request.source_offset_x_px;
|
||||
if screen_tile_x >= screen_w as i32 || screen_tile_x + tile_size_px <= 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. Project the tile pixels to screen space.
|
||||
let screen_tile_x = (tx as i32 * tile_size as i32) - fine_scroll_x;
|
||||
let screen_tile_y = (ty as i32 * tile_size as i32) - fine_scroll_y;
|
||||
let entry = layer_cache.entry(cache_x, cache_y);
|
||||
if !entry.active {
|
||||
continue;
|
||||
}
|
||||
|
||||
Self::draw_tile_pixels(
|
||||
Self::draw_cached_tile_pixels(
|
||||
back,
|
||||
screen_w,
|
||||
screen_h,
|
||||
screen_tile_x,
|
||||
screen_tile_y,
|
||||
tile,
|
||||
bank,
|
||||
entry,
|
||||
&bank,
|
||||
request.tile_size,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal helper to copy a single tile's pixels to the framebuffer.
|
||||
/// Handles flipping and palette resolution.
|
||||
fn draw_tile_pixels(
|
||||
fn draw_cached_tile_pixels(
|
||||
back: &mut [u16],
|
||||
screen_w: usize,
|
||||
screen_h: usize,
|
||||
x: i32,
|
||||
y: i32,
|
||||
tile: Tile,
|
||||
entry: CachedTileEntry,
|
||||
bank: &GlyphBank,
|
||||
tile_size: prometeu_hal::glyph_bank::TileSize,
|
||||
) {
|
||||
let size = bank.tile_size as usize;
|
||||
let size = tile_size as usize;
|
||||
|
||||
for local_y in 0..size {
|
||||
let world_y = y + local_y as i32;
|
||||
@ -755,21 +683,14 @@ impl Gfx {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle flip flags by reversing the fetch coordinates.
|
||||
let fetch_x = if tile.flip_x { size - 1 - local_x } else { local_x };
|
||||
let fetch_y = if tile.flip_y { size - 1 - local_y } else { local_y };
|
||||
|
||||
// 1. Get the pixel color index (0-15) from the bank.
|
||||
let px_index = bank.get_pixel_index(tile.glyph.glyph_id, fetch_x, fetch_y);
|
||||
|
||||
// 2. Hardware rule: Color index 0 is always fully transparent.
|
||||
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);
|
||||
if px_index == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Resolve the virtual index to a real RGB565 color using the tile's assigned palette.
|
||||
let color = bank.resolve_color(tile.glyph.palette_id, px_index);
|
||||
|
||||
let color = bank.resolve_color(entry.palette_id, px_index);
|
||||
back[world_y as usize * screen_w + world_x as usize] = color.raw();
|
||||
}
|
||||
}
|
||||
@ -898,7 +819,88 @@ impl Gfx {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory_banks::MemoryBanks;
|
||||
use crate::memory_banks::{GlyphBankPoolInstaller, MemoryBanks};
|
||||
use prometeu_hal::glyph_bank::TileSize;
|
||||
use prometeu_hal::scene_bank::SceneBank;
|
||||
use prometeu_hal::scene_layer::{MotionFactor, SceneLayer};
|
||||
use prometeu_hal::scene_viewport_cache::SceneViewportCache;
|
||||
use prometeu_hal::scene_viewport_resolver::SceneViewportResolver;
|
||||
use prometeu_hal::tile::Tile;
|
||||
use prometeu_hal::tilemap::TileMap;
|
||||
|
||||
fn make_glyph_bank(tile_size: TileSize, palette_colors: &[(u8, Color)]) -> GlyphBank {
|
||||
let size = tile_size as usize;
|
||||
let mut bank = GlyphBank::new(tile_size, size, size);
|
||||
for (palette_id, color) in palette_colors {
|
||||
bank.palettes[*palette_id as usize][1] = *color;
|
||||
}
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
bank.pixel_indices[y * bank.width + x] = 1;
|
||||
}
|
||||
}
|
||||
bank
|
||||
}
|
||||
|
||||
fn make_layer(
|
||||
glyph_bank_id: u8,
|
||||
glyph_id: u16,
|
||||
palette_id: u8,
|
||||
width: usize,
|
||||
height: usize,
|
||||
) -> SceneLayer {
|
||||
SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id,
|
||||
tile_size: TileSize::Size8,
|
||||
motion_factor: MotionFactor { x: 1.0, y: 1.0 },
|
||||
tilemap: TileMap {
|
||||
width,
|
||||
height,
|
||||
tiles: vec![
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph { glyph_id, palette_id },
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
};
|
||||
width * height
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn make_inactive_layer(glyph_bank_id: u8, width: usize, height: usize) -> SceneLayer {
|
||||
SceneLayer {
|
||||
active: false,
|
||||
glyph_bank_id,
|
||||
tile_size: TileSize::Size8,
|
||||
motion_factor: MotionFactor { x: 1.0, y: 1.0 },
|
||||
tilemap: TileMap { width, height, tiles: vec![Tile::default(); width * height] },
|
||||
}
|
||||
}
|
||||
|
||||
fn make_scene(palette_ids: [u8; 4]) -> SceneBank {
|
||||
SceneBank {
|
||||
layers: [
|
||||
make_layer(0, 0, palette_ids[0], 8, 8),
|
||||
make_layer(0, 0, palette_ids[1], 8, 8),
|
||||
make_layer(0, 0, palette_ids[2], 8, 8),
|
||||
make_layer(0, 0, palette_ids[3], 8, 8),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_scene_with_inactive_top_layers() -> SceneBank {
|
||||
SceneBank {
|
||||
layers: [
|
||||
make_layer(0, 0, 0, 8, 8),
|
||||
make_layer(0, 0, 1, 8, 8),
|
||||
make_layer(0, 0, 2, 8, 8),
|
||||
make_inactive_layer(0, 8, 8),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_draw_pixel() {
|
||||
@ -950,6 +952,79 @@ mod tests {
|
||||
// Fill
|
||||
assert_eq!(gfx.back[3 * 10 + 3], Color::BLACK.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_scene_from_cache_uses_materialized_cache_not_canonical_scene() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
banks.install_glyph_bank(
|
||||
0,
|
||||
Arc::new(make_glyph_bank(TileSize::Size8, &[(0, Color::RED), (1, Color::GREEN)])),
|
||||
);
|
||||
|
||||
let mut scene = make_scene([0, 0, 0, 0]);
|
||||
let mut cache = SceneViewportCache::new(&scene, 4, 4);
|
||||
cache.materialize_all_layers(&scene);
|
||||
|
||||
scene.layers[0].tilemap.tiles[0].glyph.palette_id = 1;
|
||||
|
||||
let mut resolver = SceneViewportResolver::new(16, 16, 4, 4, 12, 20);
|
||||
let update = resolver.update(&scene, 0, 0);
|
||||
|
||||
let mut gfx = Gfx::new(16, 16, banks);
|
||||
gfx.scene_fade_level = 31;
|
||||
gfx.hud_fade_level = 31;
|
||||
gfx.render_scene_from_cache(&cache, &update);
|
||||
|
||||
assert_eq!(gfx.back[0], Color::RED.raw());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_scene_from_cache_preserves_layer_and_sprite_order() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
banks.install_glyph_bank(
|
||||
0,
|
||||
Arc::new(make_glyph_bank(
|
||||
TileSize::Size8,
|
||||
&[(0, Color::RED), (1, Color::GREEN), (2, Color::BLUE), (4, Color::WHITE)],
|
||||
)),
|
||||
);
|
||||
|
||||
let scene = make_scene_with_inactive_top_layers();
|
||||
let mut cache = SceneViewportCache::new(&scene, 4, 4);
|
||||
cache.materialize_all_layers(&scene);
|
||||
|
||||
let mut resolver = SceneViewportResolver::new(16, 16, 4, 4, 12, 20);
|
||||
let update = resolver.update(&scene, 0, 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;
|
||||
|
||||
gfx.sprites[0] = Sprite {
|
||||
glyph: Glyph { glyph_id: 0, palette_id: 4 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
bank_id: 0,
|
||||
active: true,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 0,
|
||||
};
|
||||
gfx.sprites[1] = Sprite {
|
||||
glyph: Glyph { glyph_id: 0, palette_id: 4 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
bank_id: 0,
|
||||
active: true,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 2,
|
||||
};
|
||||
|
||||
gfx.render_scene_from_cache(&cache, &update);
|
||||
|
||||
assert_eq!(gfx.back[0], Color::BLUE.raw());
|
||||
}
|
||||
}
|
||||
|
||||
/// Blends in RGB565 per channel with saturation.
|
||||
|
||||
@ -2,8 +2,8 @@ use crate::asset::AssetManager;
|
||||
use crate::audio::Audio;
|
||||
use crate::gfx::Gfx;
|
||||
use crate::memory_banks::{
|
||||
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SoundBankPoolAccess,
|
||||
SoundBankPoolInstaller,
|
||||
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller,
|
||||
SoundBankPoolAccess, SoundBankPoolInstaller,
|
||||
};
|
||||
use crate::pad::Pad;
|
||||
use crate::touch::Touch;
|
||||
@ -87,7 +87,11 @@ impl Hardware {
|
||||
|
||||
/// Creates a fresh hardware instance with default settings.
|
||||
pub fn new() -> Self {
|
||||
let memory_banks = Arc::new(MemoryBanks::new());
|
||||
Self::new_with_memory_banks(Arc::new(MemoryBanks::new()))
|
||||
}
|
||||
|
||||
/// Creates hardware with explicit shared bank ownership.
|
||||
pub fn new_with_memory_banks(memory_banks: Arc<MemoryBanks>) -> Self {
|
||||
Self {
|
||||
gfx: Gfx::new(
|
||||
Self::W,
|
||||
@ -102,7 +106,80 @@ impl Hardware {
|
||||
AssetsPayloadSource::empty(),
|
||||
Arc::clone(&memory_banks) as Arc<dyn GlyphBankPoolInstaller>,
|
||||
Arc::clone(&memory_banks) as Arc<dyn SoundBankPoolInstaller>,
|
||||
Arc::clone(&memory_banks) as Arc<dyn SceneBankPoolInstaller>,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory_banks::{
|
||||
GlyphBankPoolInstaller, SceneBankPoolAccess, SceneBankPoolInstaller,
|
||||
};
|
||||
use prometeu_hal::color::Color;
|
||||
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_viewport_cache::SceneViewportCache;
|
||||
use prometeu_hal::scene_viewport_resolver::SceneViewportResolver;
|
||||
use prometeu_hal::tile::Tile;
|
||||
use prometeu_hal::tilemap::TileMap;
|
||||
|
||||
fn make_glyph_bank() -> GlyphBank {
|
||||
let mut bank = GlyphBank::new(TileSize::Size8, 8, 8);
|
||||
bank.palettes[0][1] = Color::RED;
|
||||
for pixel in &mut bank.pixel_indices {
|
||||
*pixel = 1;
|
||||
}
|
||||
bank
|
||||
}
|
||||
|
||||
fn make_scene() -> SceneBank {
|
||||
let layer = SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id: 0,
|
||||
tile_size: TileSize::Size8,
|
||||
motion_factor: MotionFactor { x: 1.0, y: 1.0 },
|
||||
tilemap: TileMap {
|
||||
width: 4,
|
||||
height: 4,
|
||||
tiles: vec![
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph { glyph_id: 0, palette_id: 0 },
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
};
|
||||
16
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
SceneBank { layers: std::array::from_fn(|_| layer.clone()) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_can_render_scene_from_shared_scene_bank_pipeline() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
banks.install_glyph_bank(0, Arc::new(make_glyph_bank()));
|
||||
banks.install_scene_bank(0, Arc::new(make_scene()));
|
||||
|
||||
let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks));
|
||||
let scene = banks.scene_bank_slot(0).expect("scene bank slot 0 should be resident");
|
||||
let mut cache = SceneViewportCache::new(&scene, 4, 4);
|
||||
cache.materialize_all_layers(&scene);
|
||||
|
||||
let mut resolver = SceneViewportResolver::new(16, 16, 4, 4, 12, 20);
|
||||
let update = resolver.update(&scene, 0, 0);
|
||||
|
||||
hardware.gfx.scene_fade_level = 31;
|
||||
hardware.gfx.hud_fade_level = 31;
|
||||
hardware.gfx.render_scene_from_cache(&cache, &update);
|
||||
hardware.gfx.present();
|
||||
|
||||
assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw());
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,5 +9,8 @@ mod touch;
|
||||
pub use crate::asset::AssetManager;
|
||||
pub use crate::audio::{Audio, AudioCommand, Channel, MAX_CHANNELS, OUTPUT_SAMPLE_RATE};
|
||||
pub use crate::gfx::Gfx;
|
||||
pub use crate::memory_banks::MemoryBanks;
|
||||
pub use crate::memory_banks::{
|
||||
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess,
|
||||
SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller,
|
||||
};
|
||||
pub use crate::pad::Pad;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use prometeu_hal::glyph_bank::GlyphBank;
|
||||
use prometeu_hal::scene_bank::SceneBank;
|
||||
use prometeu_hal::sound_bank::SoundBank;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
@ -30,6 +31,20 @@ pub trait SoundBankPoolInstaller: Send + Sync {
|
||||
fn install_sound_bank(&self, slot: usize, bank: Arc<SoundBank>);
|
||||
}
|
||||
|
||||
/// Non-generic interface for peripherals to access scene banks.
|
||||
pub trait SceneBankPoolAccess: Send + Sync {
|
||||
/// Returns a reference to the resident SceneBank in the specified slot, if any.
|
||||
fn scene_bank_slot(&self, slot: usize) -> Option<Arc<SceneBank>>;
|
||||
/// Returns the total number of slots available in this bank.
|
||||
fn scene_bank_slot_count(&self) -> usize;
|
||||
}
|
||||
|
||||
/// Non-generic interface for the AssetManager to install scene banks.
|
||||
pub trait SceneBankPoolInstaller: Send + Sync {
|
||||
/// Atomically swaps the resident SceneBank in the specified slot.
|
||||
fn install_scene_bank(&self, slot: usize, bank: Arc<SceneBank>);
|
||||
}
|
||||
|
||||
/// Centralized container for all hardware memory banks.
|
||||
///
|
||||
/// MemoryBanks represent the actual hardware slot state.
|
||||
@ -38,6 +53,7 @@ pub trait SoundBankPoolInstaller: Send + Sync {
|
||||
pub struct MemoryBanks {
|
||||
glyph_bank_pool: Arc<RwLock<[Option<Arc<GlyphBank>>; 16]>>,
|
||||
sound_bank_pool: Arc<RwLock<[Option<Arc<SoundBank>>; 16]>>,
|
||||
scene_bank_pool: Arc<RwLock<[Option<Arc<SceneBank>>; 16]>>,
|
||||
}
|
||||
|
||||
impl Default for MemoryBanks {
|
||||
@ -52,6 +68,7 @@ impl MemoryBanks {
|
||||
Self {
|
||||
glyph_bank_pool: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
|
||||
sound_bank_pool: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
|
||||
scene_bank_pool: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -95,3 +112,23 @@ impl SoundBankPoolInstaller for MemoryBanks {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SceneBankPoolAccess for MemoryBanks {
|
||||
fn scene_bank_slot(&self, slot: usize) -> Option<Arc<SceneBank>> {
|
||||
let pool = self.scene_bank_pool.read().unwrap();
|
||||
pool.get(slot).and_then(|s| s.as_ref().map(Arc::clone))
|
||||
}
|
||||
|
||||
fn scene_bank_slot_count(&self) -> usize {
|
||||
16
|
||||
}
|
||||
}
|
||||
|
||||
impl SceneBankPoolInstaller for MemoryBanks {
|
||||
fn install_scene_bank(&self, slot: usize, bank: Arc<SceneBank>) {
|
||||
let mut pool = self.scene_bank_pool.write().unwrap();
|
||||
if slot < 16 {
|
||||
pool[slot] = Some(bank);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ pub type AssetId = i32;
|
||||
pub enum BankType {
|
||||
GLYPH,
|
||||
SOUNDS,
|
||||
SCENE,
|
||||
// TILEMAPS,
|
||||
// BLOBS,
|
||||
}
|
||||
@ -45,6 +46,17 @@ pub struct SoundBankMetadata {
|
||||
pub channels: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct SceneBankMetadata {}
|
||||
|
||||
pub const SCENE_PAYLOAD_MAGIC_V1: [u8; 4] = *b"SCNE";
|
||||
pub const SCENE_PAYLOAD_VERSION_V1: u16 = 1;
|
||||
pub const SCENE_LAYER_COUNT_V1: usize = 4;
|
||||
pub const SCENE_HEADER_BYTES_V1: usize = 12;
|
||||
pub const SCENE_LAYER_HEADER_BYTES_V1: usize = 28;
|
||||
pub const SCENE_TILE_RECORD_BYTES_V1: usize = 4;
|
||||
pub const SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1: usize = 16;
|
||||
|
||||
impl AssetEntry {
|
||||
pub fn metadata_as_glyph_bank(&self) -> Result<GlyphBankMetadata, String> {
|
||||
if self.bank_type != BankType::GLYPH {
|
||||
@ -67,6 +79,17 @@ impl AssetEntry {
|
||||
serde_json::from_value(self.metadata.clone())
|
||||
.map_err(|e| format!("Invalid SOUNDS metadata for asset {}: {}", self.asset_id, e))
|
||||
}
|
||||
|
||||
pub fn metadata_as_scene_bank(&self) -> Result<SceneBankMetadata, String> {
|
||||
if self.bank_type != BankType::SCENE {
|
||||
return Err(format!(
|
||||
"Asset {} is not a SCENE bank (type: {:?})",
|
||||
self.asset_id, self.bank_type
|
||||
));
|
||||
}
|
||||
serde_json::from_value(self.metadata.clone())
|
||||
.map_err(|e| format!("Invalid SCENE metadata for asset {}: {}", self.asset_id, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@ -133,4 +156,8 @@ impl SlotRef {
|
||||
pub fn audio(index: usize) -> Self {
|
||||
Self { asset_type: BankType::SOUNDS, index }
|
||||
}
|
||||
|
||||
pub fn scene(index: usize) -> Self {
|
||||
Self { asset_type: BankType::SCENE, index }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use crate::color::Color;
|
||||
use crate::scene_viewport_cache::SceneViewportCache;
|
||||
use crate::scene_viewport_resolver::ResolverUpdate;
|
||||
use crate::sprite::Sprite;
|
||||
use crate::tile_layer::{HudTileLayer, ScrollableTileLayer};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlendMode {
|
||||
@ -48,15 +49,10 @@ pub trait GfxBridge {
|
||||
fn draw_vertical_line(&mut self, x: i32, y0: i32, y1: i32, color: Color);
|
||||
fn present(&mut self);
|
||||
fn render_all(&mut self);
|
||||
fn render_layer(&mut self, layer_idx: usize);
|
||||
fn render_hud(&mut self);
|
||||
fn render_scene_from_cache(&mut self, cache: &SceneViewportCache, update: &ResolverUpdate);
|
||||
fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color);
|
||||
fn draw_char(&mut self, x: i32, y: i32, c: char, color: Color);
|
||||
|
||||
fn layer(&self, index: usize) -> &ScrollableTileLayer;
|
||||
fn layer_mut(&mut self, index: usize) -> &mut ScrollableTileLayer;
|
||||
fn hud(&self) -> &HudTileLayer;
|
||||
fn hud_mut(&mut self) -> &mut HudTileLayer;
|
||||
fn sprite(&self, index: usize) -> &Sprite;
|
||||
fn sprite_mut(&mut self, index: usize) -> &mut Sprite;
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct Glyph {
|
||||
pub glyph_id: u16,
|
||||
pub palette_id: u8,
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
use crate::color::Color;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const GLYPH_BANK_PALETTE_COUNT_V1: usize = 64;
|
||||
pub const GLYPH_BANK_COLORS_PER_PALETTE: usize = 16;
|
||||
|
||||
/// Standard sizes for square tiles.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub enum TileSize {
|
||||
/// 8x8 pixels.
|
||||
Size8 = 8,
|
||||
|
||||
@ -18,12 +18,16 @@ pub mod native_helpers;
|
||||
pub mod native_interface;
|
||||
pub mod pad_bridge;
|
||||
pub mod sample;
|
||||
pub mod scene_bank;
|
||||
pub mod scene_layer;
|
||||
pub mod scene_viewport_cache;
|
||||
pub mod scene_viewport_resolver;
|
||||
pub mod sound_bank;
|
||||
pub mod sprite;
|
||||
pub mod syscalls;
|
||||
pub mod telemetry;
|
||||
pub mod tile;
|
||||
pub mod tile_layer;
|
||||
pub mod tilemap;
|
||||
pub mod touch_bridge;
|
||||
pub mod vm_fault;
|
||||
pub mod window;
|
||||
|
||||
50
crates/console/prometeu-hal/src/scene_bank.rs
Normal file
50
crates/console/prometeu-hal/src/scene_bank.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use crate::scene_layer::SceneLayer;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SceneBank {
|
||||
pub layers: [SceneLayer; 4],
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::scene_layer::MotionFactor;
|
||||
use crate::tile::Tile;
|
||||
use crate::tilemap::TileMap;
|
||||
|
||||
fn layer(glyph_bank_id: u8, motion_x: f32, motion_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 },
|
||||
tilemap: TileMap {
|
||||
width: 1,
|
||||
height: 1,
|
||||
tiles: vec![Tile {
|
||||
active: true,
|
||||
glyph: Glyph { glyph_id, palette_id: glyph_bank_id },
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_bank_owns_exactly_four_layers() {
|
||||
let scene = SceneBank {
|
||||
layers: [
|
||||
layer(0, 1.0, 1.0, 10),
|
||||
layer(1, 0.5, 1.0, 11),
|
||||
layer(2, 1.0, 0.5, 12),
|
||||
layer(3, 0.25, 0.25, 13),
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(scene.layers.len(), 4);
|
||||
assert_eq!(scene.layers[3].tilemap.tiles[0].glyph.glyph_id, 13);
|
||||
}
|
||||
}
|
||||
59
crates/console/prometeu-hal/src/scene_layer.rs
Normal file
59
crates/console/prometeu-hal/src/scene_layer.rs
Normal file
@ -0,0 +1,59 @@
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::tilemap::TileMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MotionFactor {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SceneLayer {
|
||||
pub active: bool,
|
||||
pub glyph_bank_id: u8,
|
||||
pub tile_size: TileSize,
|
||||
pub motion_factor: MotionFactor,
|
||||
pub tilemap: TileMap,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::tile::Tile;
|
||||
|
||||
#[test]
|
||||
fn scene_layer_preserves_motion_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 },
|
||||
tilemap: TileMap {
|
||||
width: 2,
|
||||
height: 1,
|
||||
tiles: vec![
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph { glyph_id: 21, palette_id: 3 },
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
},
|
||||
Tile {
|
||||
active: false,
|
||||
glyph: Glyph { glyph_id: 22, palette_id: 4 },
|
||||
flip_x: true,
|
||||
flip_y: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
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.tilemap.width, 2);
|
||||
assert_eq!(layer.tilemap.tiles[1].glyph.glyph_id, 22);
|
||||
assert!(layer.tilemap.tiles[1].flip_x);
|
||||
}
|
||||
}
|
||||
430
crates/console/prometeu-hal/src/scene_viewport_cache.rs
Normal file
430
crates/console/prometeu-hal/src/scene_viewport_cache.rs
Normal file
@ -0,0 +1,430 @@
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::scene_bank::SceneBank;
|
||||
use crate::scene_layer::SceneLayer;
|
||||
use crate::tile::Tile;
|
||||
|
||||
const FLAG_FLIP_X: u8 = 0b0000_0001;
|
||||
const FLAG_FLIP_Y: u8 = 0b0000_0010;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CachedTileEntry {
|
||||
pub active: bool,
|
||||
pub glyph_id: u16,
|
||||
pub palette_id: u8,
|
||||
pub flags: u8,
|
||||
pub glyph_bank_id: u8,
|
||||
}
|
||||
|
||||
impl CachedTileEntry {
|
||||
pub fn flip_x(self) -> bool {
|
||||
(self.flags & FLAG_FLIP_X) != 0
|
||||
}
|
||||
|
||||
pub fn flip_y(self) -> bool {
|
||||
(self.flags & FLAG_FLIP_Y) != 0
|
||||
}
|
||||
|
||||
fn from_tile(layer: &SceneLayer, tile: Tile) -> Self {
|
||||
let mut flags = 0_u8;
|
||||
if tile.flip_x {
|
||||
flags |= FLAG_FLIP_X;
|
||||
}
|
||||
if tile.flip_y {
|
||||
flags |= FLAG_FLIP_Y;
|
||||
}
|
||||
|
||||
Self {
|
||||
active: tile.active,
|
||||
glyph_id: tile.glyph.glyph_id,
|
||||
palette_id: tile.glyph.palette_id,
|
||||
flags,
|
||||
glyph_bank_id: layer.glyph_bank_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ViewportRegion {
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
impl ViewportRegion {
|
||||
pub const fn new(x: usize, y: usize, width: usize, height: usize) -> Self {
|
||||
Self { x, y, width, height }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SceneViewportLayerCache {
|
||||
width: usize,
|
||||
height: usize,
|
||||
logical_origin_x: i32,
|
||||
logical_origin_y: i32,
|
||||
ring_origin_x: usize,
|
||||
ring_origin_y: usize,
|
||||
pub glyph_bank_id: u8,
|
||||
pub tile_size: TileSize,
|
||||
entries: Vec<CachedTileEntry>,
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
impl SceneViewportLayerCache {
|
||||
pub fn new(layer: &SceneLayer, width: usize, height: usize) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
logical_origin_x: 0,
|
||||
logical_origin_y: 0,
|
||||
ring_origin_x: 0,
|
||||
ring_origin_y: 0,
|
||||
glyph_bank_id: layer.glyph_bank_id,
|
||||
tile_size: layer.tile_size,
|
||||
entries: vec![CachedTileEntry::default(); width * height],
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
|
||||
pub fn logical_origin(&self) -> (i32, i32) {
|
||||
(self.logical_origin_x, self.logical_origin_y)
|
||||
}
|
||||
|
||||
pub fn ring_origin(&self) -> (usize, usize) {
|
||||
(self.ring_origin_x, self.ring_origin_y)
|
||||
}
|
||||
|
||||
pub fn entry(&self, cache_x: usize, cache_y: usize) -> CachedTileEntry {
|
||||
self.entries[self.physical_index(cache_x, cache_y)]
|
||||
}
|
||||
|
||||
pub fn invalidate_all(&mut self) {
|
||||
self.entries.fill(CachedTileEntry::default());
|
||||
self.valid = false;
|
||||
}
|
||||
|
||||
pub fn move_window_to(&mut self, origin_x: i32, origin_y: i32) {
|
||||
let delta_x = origin_x - self.logical_origin_x;
|
||||
let delta_y = origin_y - self.logical_origin_y;
|
||||
|
||||
self.logical_origin_x = origin_x;
|
||||
self.logical_origin_y = origin_y;
|
||||
|
||||
self.ring_origin_x = Self::wrapped_origin(self.ring_origin_x, delta_x, self.width);
|
||||
self.ring_origin_y = Self::wrapped_origin(self.ring_origin_y, delta_y, self.height);
|
||||
}
|
||||
|
||||
pub fn move_window_by(&mut self, delta_x: i32, delta_y: i32) {
|
||||
self.move_window_to(self.logical_origin_x + delta_x, self.logical_origin_y + delta_y);
|
||||
}
|
||||
|
||||
pub fn refresh_line(&mut self, layer: &SceneLayer, cache_y: usize) {
|
||||
self.refresh_region(layer, ViewportRegion::new(0, cache_y, self.width, 1));
|
||||
}
|
||||
|
||||
pub fn refresh_column(&mut self, layer: &SceneLayer, cache_x: usize) {
|
||||
self.refresh_region(layer, ViewportRegion::new(cache_x, 0, 1, self.height));
|
||||
}
|
||||
|
||||
pub fn refresh_region(&mut self, layer: &SceneLayer, region: ViewportRegion) {
|
||||
self.glyph_bank_id = layer.glyph_bank_id;
|
||||
self.tile_size = layer.tile_size;
|
||||
|
||||
let max_x = region.x.saturating_add(region.width).min(self.width);
|
||||
let max_y = region.y.saturating_add(region.height).min(self.height);
|
||||
|
||||
for cache_y in region.y..max_y {
|
||||
for cache_x in region.x..max_x {
|
||||
let entry = self.materialize_entry(layer, cache_x, cache_y);
|
||||
let idx = self.physical_index(cache_x, cache_y);
|
||||
self.entries[idx] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
self.valid = true;
|
||||
}
|
||||
|
||||
pub fn refresh_all(&mut self, layer: &SceneLayer) {
|
||||
self.refresh_region(layer, ViewportRegion::new(0, 0, self.width, self.height));
|
||||
}
|
||||
|
||||
fn materialize_entry(
|
||||
&self,
|
||||
layer: &SceneLayer,
|
||||
cache_x: usize,
|
||||
cache_y: usize,
|
||||
) -> CachedTileEntry {
|
||||
let scene_x = self.logical_origin_x + cache_x as i32;
|
||||
let scene_y = self.logical_origin_y + cache_y as i32;
|
||||
|
||||
if scene_x < 0 || scene_y < 0 {
|
||||
return CachedTileEntry::default();
|
||||
}
|
||||
|
||||
let tile_x = scene_x as usize;
|
||||
let tile_y = scene_y as usize;
|
||||
if tile_x >= layer.tilemap.width || tile_y >= layer.tilemap.height {
|
||||
return CachedTileEntry::default();
|
||||
}
|
||||
|
||||
let tile = layer.tilemap.tiles[tile_y * layer.tilemap.width + tile_x];
|
||||
CachedTileEntry::from_tile(layer, tile)
|
||||
}
|
||||
|
||||
fn physical_index(&self, cache_x: usize, cache_y: usize) -> usize {
|
||||
let physical_x = (self.ring_origin_x + cache_x) % self.width;
|
||||
let physical_y = (self.ring_origin_y + cache_y) % self.height;
|
||||
physical_y * self.width + physical_x
|
||||
}
|
||||
|
||||
fn wrapped_origin(current: usize, delta: i32, span: usize) -> usize {
|
||||
if span == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let span_i32 = span as i32;
|
||||
let current_i32 = current as i32;
|
||||
(current_i32 + delta).rem_euclid(span_i32) as usize
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SceneViewportCache {
|
||||
width: usize,
|
||||
height: usize,
|
||||
pub layers: [SceneViewportLayerCache; 4],
|
||||
}
|
||||
|
||||
impl SceneViewportCache {
|
||||
pub fn new(scene: &SceneBank, width: usize, height: usize) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
layers: std::array::from_fn(|i| {
|
||||
SceneViewportLayerCache::new(&scene.layers[i], width, height)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
|
||||
pub fn invalidate_all(&mut self) {
|
||||
for layer in &mut self.layers {
|
||||
layer.invalidate_all();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_layer_window_to(&mut self, layer_idx: usize, origin_x: i32, origin_y: i32) {
|
||||
self.layers[layer_idx].move_window_to(origin_x, origin_y);
|
||||
}
|
||||
|
||||
pub fn move_layer_window_by(&mut self, layer_idx: usize, delta_x: i32, delta_y: i32) {
|
||||
self.layers[layer_idx].move_window_by(delta_x, delta_y);
|
||||
}
|
||||
|
||||
pub fn refresh_layer_line(&mut self, scene: &SceneBank, layer_idx: usize, cache_y: usize) {
|
||||
self.layers[layer_idx].refresh_line(&scene.layers[layer_idx], cache_y);
|
||||
}
|
||||
|
||||
pub fn refresh_layer_column(&mut self, scene: &SceneBank, layer_idx: usize, cache_x: usize) {
|
||||
self.layers[layer_idx].refresh_column(&scene.layers[layer_idx], cache_x);
|
||||
}
|
||||
|
||||
pub fn refresh_layer_region(
|
||||
&mut self,
|
||||
scene: &SceneBank,
|
||||
layer_idx: usize,
|
||||
region: ViewportRegion,
|
||||
) {
|
||||
self.layers[layer_idx].refresh_region(&scene.layers[layer_idx], region);
|
||||
}
|
||||
|
||||
pub fn refresh_layer_all(&mut self, scene: &SceneBank, layer_idx: usize) {
|
||||
self.layers[layer_idx].refresh_all(&scene.layers[layer_idx]);
|
||||
}
|
||||
|
||||
pub fn materialize_all_layers(&mut self, scene: &SceneBank) {
|
||||
for layer_idx in 0..self.layers.len() {
|
||||
self.refresh_layer_all(scene, layer_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::scene_layer::MotionFactor;
|
||||
use crate::tile::Tile;
|
||||
use crate::tilemap::TileMap;
|
||||
|
||||
fn make_tile(glyph_id: u16, palette_id: u8, flip_x: bool, flip_y: bool) -> Tile {
|
||||
Tile { active: true, glyph: Glyph { glyph_id, palette_id }, flip_x, flip_y }
|
||||
}
|
||||
|
||||
fn make_layer(glyph_bank_id: u8, base_glyph: u16) -> SceneLayer {
|
||||
let mut tiles = Vec::new();
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
tiles.push(make_tile(
|
||||
base_glyph + (y * 4 + x) as u16,
|
||||
glyph_bank_id,
|
||||
x % 2 == 0,
|
||||
y % 2 == 1,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id,
|
||||
tile_size: TileSize::Size16,
|
||||
motion_factor: MotionFactor { x: 1.0, y: 1.0 },
|
||||
tilemap: TileMap { width: 4, height: 4, tiles },
|
||||
}
|
||||
}
|
||||
|
||||
fn make_scene() -> SceneBank {
|
||||
SceneBank {
|
||||
layers: [
|
||||
make_layer(1, 100),
|
||||
make_layer(2, 200),
|
||||
make_layer(3, 300),
|
||||
make_layer(4, 400),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_cache_wraps_ring_origin_under_window_movement() {
|
||||
let scene = make_scene();
|
||||
let mut cache = SceneViewportCache::new(&scene, 3, 3);
|
||||
|
||||
cache.move_layer_window_by(0, 1, 2);
|
||||
assert_eq!(cache.layers[0].logical_origin(), (1, 2));
|
||||
assert_eq!(cache.layers[0].ring_origin(), (1, 2));
|
||||
|
||||
cache.move_layer_window_by(0, 3, 2);
|
||||
assert_eq!(cache.layers[0].logical_origin(), (4, 4));
|
||||
assert_eq!(cache.layers[0].ring_origin(), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_entry_fields_are_derived_from_scene_tiles() {
|
||||
let scene = make_scene();
|
||||
let mut cache = SceneViewportCache::new(&scene, 2, 2);
|
||||
|
||||
cache.refresh_layer_all(&scene, 0);
|
||||
let entry = cache.layers[0].entry(1, 1);
|
||||
|
||||
assert!(entry.active);
|
||||
assert_eq!(entry.glyph_id, 105);
|
||||
assert_eq!(entry.palette_id, 1);
|
||||
assert_eq!(entry.glyph_bank_id, 1);
|
||||
assert!(!entry.flip_x());
|
||||
assert!(entry.flip_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_refresh_only_updates_the_requested_line() {
|
||||
let scene = make_scene();
|
||||
let mut cache = SceneViewportCache::new(&scene, 3, 3);
|
||||
|
||||
cache.refresh_layer_line(&scene, 0, 1);
|
||||
|
||||
assert_eq!(cache.layers[0].entry(0, 0), CachedTileEntry::default());
|
||||
assert_eq!(cache.layers[0].entry(0, 1).glyph_id, 104);
|
||||
assert_eq!(cache.layers[0].entry(2, 1).glyph_id, 106);
|
||||
assert_eq!(cache.layers[0].entry(0, 2), CachedTileEntry::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_refresh_only_updates_the_requested_column() {
|
||||
let scene = make_scene();
|
||||
let mut cache = SceneViewportCache::new(&scene, 3, 3);
|
||||
|
||||
cache.refresh_layer_column(&scene, 1, 2);
|
||||
|
||||
assert_eq!(cache.layers[1].entry(0, 0), CachedTileEntry::default());
|
||||
assert_eq!(cache.layers[1].entry(2, 0).glyph_id, 202);
|
||||
assert_eq!(cache.layers[1].entry(2, 2).glyph_id, 210);
|
||||
assert_eq!(cache.layers[1].entry(1, 2), CachedTileEntry::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_refresh_only_updates_the_requested_area() {
|
||||
let scene = make_scene();
|
||||
let mut cache = SceneViewportCache::new(&scene, 3, 3);
|
||||
|
||||
cache.refresh_layer_region(&scene, 2, ViewportRegion::new(1, 1, 2, 2));
|
||||
|
||||
assert_eq!(cache.layers[2].entry(0, 0), CachedTileEntry::default());
|
||||
assert_eq!(cache.layers[2].entry(1, 1).glyph_id, 305);
|
||||
assert_eq!(cache.layers[2].entry(2, 2).glyph_id, 310);
|
||||
assert_eq!(cache.layers[2].entry(0, 2), CachedTileEntry::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_swap_invalidation_clears_all_layers() {
|
||||
let scene = make_scene();
|
||||
let mut cache = SceneViewportCache::new(&scene, 2, 2);
|
||||
cache.materialize_all_layers(&scene);
|
||||
|
||||
cache.invalidate_all();
|
||||
|
||||
for layer in &cache.layers {
|
||||
assert!(!layer.valid);
|
||||
for y in 0..cache.height() {
|
||||
for x in 0..cache.width() {
|
||||
assert_eq!(layer.entry(x, y), CachedTileEntry::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_style_region_update_does_not_touch_outside_tiles() {
|
||||
let scene = make_scene();
|
||||
let mut cache = SceneViewportCache::new(&scene, 4, 4);
|
||||
cache.materialize_all_layers(&scene);
|
||||
|
||||
let before = cache.layers[3].entry(1, 1);
|
||||
cache.layers[3].invalidate_all();
|
||||
cache.refresh_layer_region(&scene, 3, ViewportRegion::new(2, 2, 2, 2));
|
||||
|
||||
assert_eq!(cache.layers[3].entry(0, 0), CachedTileEntry::default());
|
||||
assert_eq!(cache.layers[3].entry(1, 1), CachedTileEntry::default());
|
||||
assert_ne!(cache.layers[3].entry(2, 2), CachedTileEntry::default());
|
||||
assert_eq!(before.glyph_id, 405);
|
||||
assert_eq!(cache.layers[3].entry(3, 3).glyph_id, 415);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialization_populates_all_four_layers() {
|
||||
let scene = make_scene();
|
||||
let mut cache = SceneViewportCache::new(&scene, 2, 2);
|
||||
|
||||
cache.materialize_all_layers(&scene);
|
||||
|
||||
assert_eq!(cache.layers[0].entry(0, 0).glyph_id, 100);
|
||||
assert_eq!(cache.layers[1].entry(0, 0).glyph_id, 200);
|
||||
assert_eq!(cache.layers[2].entry(1, 1).glyph_id, 305);
|
||||
assert_eq!(cache.layers[3].entry(1, 0).glyph_id, 401);
|
||||
}
|
||||
}
|
||||
536
crates/console/prometeu-hal/src/scene_viewport_resolver.rs
Normal file
536
crates/console/prometeu-hal/src/scene_viewport_resolver.rs
Normal file
@ -0,0 +1,536 @@
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::scene_bank::SceneBank;
|
||||
use crate::scene_viewport_cache::ViewportRegion;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TileAnchor {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CacheRefreshRequest {
|
||||
InvalidateLayer { layer_index: usize },
|
||||
RefreshLine { layer_index: usize, cache_y: usize },
|
||||
RefreshColumn { layer_index: usize, cache_x: usize },
|
||||
RefreshRegion { layer_index: usize, region: ViewportRegion },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LayerCopyRequest {
|
||||
pub layer_index: usize,
|
||||
pub tile_size: TileSize,
|
||||
pub viewport_width_px: i32,
|
||||
pub viewport_height_px: i32,
|
||||
pub source_offset_x_px: i32,
|
||||
pub source_offset_y_px: i32,
|
||||
pub cache_origin_tile_x: i32,
|
||||
pub cache_origin_tile_y: i32,
|
||||
pub camera_x_px: i32,
|
||||
pub camera_y_px: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ResolverUpdate {
|
||||
pub master_anchor: TileAnchor,
|
||||
pub layer_anchors: [TileAnchor; 4],
|
||||
pub refresh_requests: Vec<CacheRefreshRequest>,
|
||||
pub copy_requests: [LayerCopyRequest; 4],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SceneViewportResolver {
|
||||
viewport_width_px: i32,
|
||||
viewport_height_px: i32,
|
||||
cache_width_tiles: usize,
|
||||
cache_height_tiles: usize,
|
||||
hysteresis_safe_px: i32,
|
||||
hysteresis_trigger_px: i32,
|
||||
initialized: bool,
|
||||
master_anchor: TileAnchor,
|
||||
layer_anchors: [TileAnchor; 4],
|
||||
}
|
||||
|
||||
impl SceneViewportResolver {
|
||||
pub fn new(
|
||||
viewport_width_px: i32,
|
||||
viewport_height_px: i32,
|
||||
cache_width_tiles: usize,
|
||||
cache_height_tiles: usize,
|
||||
hysteresis_safe_px: i32,
|
||||
hysteresis_trigger_px: i32,
|
||||
) -> Self {
|
||||
Self {
|
||||
viewport_width_px,
|
||||
viewport_height_px,
|
||||
cache_width_tiles,
|
||||
cache_height_tiles,
|
||||
hysteresis_safe_px,
|
||||
hysteresis_trigger_px,
|
||||
initialized: false,
|
||||
master_anchor: TileAnchor::default(),
|
||||
layer_anchors: [TileAnchor::default(); 4],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_scene(&mut self) -> Vec<CacheRefreshRequest> {
|
||||
self.initialized = false;
|
||||
vec![
|
||||
CacheRefreshRequest::InvalidateLayer { layer_index: 0 },
|
||||
CacheRefreshRequest::InvalidateLayer { layer_index: 1 },
|
||||
CacheRefreshRequest::InvalidateLayer { layer_index: 2 },
|
||||
CacheRefreshRequest::InvalidateLayer { layer_index: 3 },
|
||||
]
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&mut self,
|
||||
scene: &SceneBank,
|
||||
camera_x_px: i32,
|
||||
camera_y_px: i32,
|
||||
) -> ResolverUpdate {
|
||||
let mut refresh_requests = Vec::new();
|
||||
let camera_center_x = camera_x_px + self.viewport_width_px / 2;
|
||||
let camera_center_y = camera_y_px + self.viewport_height_px / 2;
|
||||
|
||||
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_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;
|
||||
(
|
||||
tile_size_px,
|
||||
layer_camera_x_px,
|
||||
layer_camera_y_px,
|
||||
layer_center_x_px,
|
||||
layer_center_y_px,
|
||||
)
|
||||
});
|
||||
|
||||
if !self.initialized {
|
||||
self.master_anchor = self.initial_anchor(
|
||||
scene.layers[0].tilemap.width,
|
||||
scene.layers[0].tilemap.height,
|
||||
scene.layers[0].tile_size as i32,
|
||||
camera_center_x,
|
||||
camera_center_y,
|
||||
);
|
||||
|
||||
for (layer_index, layer) in scene.layers.iter().enumerate() {
|
||||
let (_, _, _, layer_center_x_px, layer_center_y_px) = layer_inputs[layer_index];
|
||||
self.layer_anchors[layer_index] = self.initial_anchor(
|
||||
layer.tilemap.width,
|
||||
layer.tilemap.height,
|
||||
layer.tile_size as i32,
|
||||
layer_center_x_px,
|
||||
layer_center_y_px,
|
||||
);
|
||||
refresh_requests.push(CacheRefreshRequest::RefreshRegion {
|
||||
layer_index,
|
||||
region: ViewportRegion::new(
|
||||
0,
|
||||
0,
|
||||
self.cache_width_tiles,
|
||||
self.cache_height_tiles,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
self.initialized = true;
|
||||
} else {
|
||||
let layer0 = &scene.layers[0];
|
||||
self.master_anchor = self.advance_anchor(
|
||||
self.master_anchor,
|
||||
camera_center_x,
|
||||
camera_center_y,
|
||||
layer0.tile_size as i32,
|
||||
layer0.tilemap.width,
|
||||
layer0.tilemap.height,
|
||||
);
|
||||
|
||||
for (layer_index, layer) in scene.layers.iter().enumerate() {
|
||||
let previous = self.layer_anchors[layer_index];
|
||||
let (tile_size_px, _, _, layer_center_x_px, layer_center_y_px) =
|
||||
layer_inputs[layer_index];
|
||||
let next = self.advance_anchor(
|
||||
previous,
|
||||
layer_center_x_px,
|
||||
layer_center_y_px,
|
||||
tile_size_px,
|
||||
layer.tilemap.width,
|
||||
layer.tilemap.height,
|
||||
);
|
||||
self.layer_anchors[layer_index] = next;
|
||||
self.emit_refresh_requests(layer_index, previous, next, &mut refresh_requests);
|
||||
}
|
||||
}
|
||||
|
||||
let copy_requests = std::array::from_fn(|layer_index| {
|
||||
let layer = &scene.layers[layer_index];
|
||||
let (tile_size_px, layer_camera_x_px, layer_camera_y_px, _, _) =
|
||||
layer_inputs[layer_index];
|
||||
let anchor = self.layer_anchors[layer_index];
|
||||
let cache_origin_tile_x = anchor.x - (self.cache_width_tiles as i32 / 2);
|
||||
let cache_origin_tile_y = anchor.y - (self.cache_height_tiles as i32 / 2);
|
||||
let cache_origin_x_px = cache_origin_tile_x * tile_size_px;
|
||||
let cache_origin_y_px = cache_origin_tile_y * tile_size_px;
|
||||
|
||||
LayerCopyRequest {
|
||||
layer_index,
|
||||
tile_size: layer.tile_size,
|
||||
viewport_width_px: self.viewport_width_px,
|
||||
viewport_height_px: self.viewport_height_px,
|
||||
source_offset_x_px: layer_camera_x_px - cache_origin_x_px,
|
||||
source_offset_y_px: layer_camera_y_px - cache_origin_y_px,
|
||||
cache_origin_tile_x,
|
||||
cache_origin_tile_y,
|
||||
camera_x_px: layer_camera_x_px,
|
||||
camera_y_px: layer_camera_y_px,
|
||||
}
|
||||
});
|
||||
|
||||
ResolverUpdate {
|
||||
master_anchor: self.master_anchor,
|
||||
layer_anchors: self.layer_anchors,
|
||||
refresh_requests,
|
||||
copy_requests,
|
||||
}
|
||||
}
|
||||
|
||||
fn initial_anchor(
|
||||
&self,
|
||||
scene_width_tiles: usize,
|
||||
scene_height_tiles: usize,
|
||||
tile_size_px: i32,
|
||||
camera_center_x_px: i32,
|
||||
camera_center_y_px: i32,
|
||||
) -> TileAnchor {
|
||||
let proposed = TileAnchor {
|
||||
x: camera_center_x_px.div_euclid(tile_size_px),
|
||||
y: camera_center_y_px.div_euclid(tile_size_px),
|
||||
};
|
||||
self.clamp_anchor(proposed, scene_width_tiles, scene_height_tiles)
|
||||
}
|
||||
|
||||
fn advance_anchor(
|
||||
&self,
|
||||
current: TileAnchor,
|
||||
camera_center_x_px: i32,
|
||||
camera_center_y_px: i32,
|
||||
tile_size_px: i32,
|
||||
scene_width_tiles: usize,
|
||||
scene_height_tiles: usize,
|
||||
) -> TileAnchor {
|
||||
let mut next = current;
|
||||
|
||||
loop {
|
||||
let center_x_px = next.x * tile_size_px + tile_size_px / 2;
|
||||
let drift_x = camera_center_x_px - center_x_px;
|
||||
if drift_x.abs() <= self.hysteresis_safe_px {
|
||||
break;
|
||||
}
|
||||
if drift_x > self.hysteresis_trigger_px {
|
||||
next.x += 1;
|
||||
continue;
|
||||
}
|
||||
if drift_x < -self.hysteresis_trigger_px {
|
||||
next.x -= 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
loop {
|
||||
let center_y_px = next.y * tile_size_px + tile_size_px / 2;
|
||||
let drift_y = camera_center_y_px - center_y_px;
|
||||
if drift_y.abs() <= self.hysteresis_safe_px {
|
||||
break;
|
||||
}
|
||||
if drift_y > self.hysteresis_trigger_px {
|
||||
next.y += 1;
|
||||
continue;
|
||||
}
|
||||
if drift_y < -self.hysteresis_trigger_px {
|
||||
next.y -= 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
self.clamp_anchor(next, scene_width_tiles, scene_height_tiles)
|
||||
}
|
||||
|
||||
fn clamp_anchor(
|
||||
&self,
|
||||
proposed: TileAnchor,
|
||||
scene_width_tiles: usize,
|
||||
scene_height_tiles: usize,
|
||||
) -> TileAnchor {
|
||||
TileAnchor {
|
||||
x: self.clamp_axis(proposed.x, scene_width_tiles, self.cache_width_tiles),
|
||||
y: self.clamp_axis(proposed.y, scene_height_tiles, self.cache_height_tiles),
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_axis(&self, proposed: i32, scene_tiles: usize, cache_tiles: usize) -> i32 {
|
||||
let half = (cache_tiles / 2) as i32;
|
||||
if scene_tiles <= cache_tiles {
|
||||
return half;
|
||||
}
|
||||
|
||||
let min = half;
|
||||
let max = (scene_tiles - cache_tiles) as i32 + half;
|
||||
proposed.clamp(min, max)
|
||||
}
|
||||
|
||||
fn emit_refresh_requests(
|
||||
&self,
|
||||
layer_index: usize,
|
||||
previous: TileAnchor,
|
||||
next: TileAnchor,
|
||||
requests: &mut Vec<CacheRefreshRequest>,
|
||||
) {
|
||||
let delta_x = next.x - previous.x;
|
||||
let delta_y = next.y - previous.y;
|
||||
|
||||
if delta_x == 0 && delta_y == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if delta_x == 0 {
|
||||
self.emit_line_requests(layer_index, delta_y, requests);
|
||||
return;
|
||||
}
|
||||
|
||||
if delta_y == 0 {
|
||||
self.emit_column_requests(layer_index, delta_x, requests);
|
||||
return;
|
||||
}
|
||||
|
||||
self.emit_corner_region_requests(layer_index, delta_x, delta_y, requests);
|
||||
}
|
||||
|
||||
fn emit_line_requests(
|
||||
&self,
|
||||
layer_index: usize,
|
||||
delta_y: i32,
|
||||
requests: &mut Vec<CacheRefreshRequest>,
|
||||
) {
|
||||
let count = delta_y.unsigned_abs() as usize;
|
||||
if delta_y > 0 {
|
||||
for offset in 0..count {
|
||||
requests.push(CacheRefreshRequest::RefreshLine {
|
||||
layer_index,
|
||||
cache_y: self.cache_height_tiles - count + offset,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for cache_y in 0..count {
|
||||
requests.push(CacheRefreshRequest::RefreshLine { layer_index, cache_y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_column_requests(
|
||||
&self,
|
||||
layer_index: usize,
|
||||
delta_x: i32,
|
||||
requests: &mut Vec<CacheRefreshRequest>,
|
||||
) {
|
||||
let count = delta_x.unsigned_abs() as usize;
|
||||
if delta_x > 0 {
|
||||
for offset in 0..count {
|
||||
requests.push(CacheRefreshRequest::RefreshColumn {
|
||||
layer_index,
|
||||
cache_x: self.cache_width_tiles - count + offset,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for cache_x in 0..count {
|
||||
requests.push(CacheRefreshRequest::RefreshColumn { layer_index, cache_x });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_corner_region_requests(
|
||||
&self,
|
||||
layer_index: usize,
|
||||
delta_x: i32,
|
||||
delta_y: i32,
|
||||
requests: &mut Vec<CacheRefreshRequest>,
|
||||
) {
|
||||
let width = delta_x.unsigned_abs() as usize;
|
||||
let height = delta_y.unsigned_abs() as usize;
|
||||
|
||||
let primary_x = if delta_x > 0 { self.cache_width_tiles - width } else { 0 };
|
||||
requests.push(CacheRefreshRequest::RefreshRegion {
|
||||
layer_index,
|
||||
region: ViewportRegion::new(primary_x, 0, width, self.cache_height_tiles),
|
||||
});
|
||||
|
||||
let secondary_y = if delta_y > 0 { self.cache_height_tiles - height } else { 0 };
|
||||
let secondary_x = if delta_x > 0 { 0 } else { width };
|
||||
let secondary_width = self.cache_width_tiles.saturating_sub(width);
|
||||
|
||||
if secondary_width > 0 {
|
||||
requests.push(CacheRefreshRequest::RefreshRegion {
|
||||
layer_index,
|
||||
region: ViewportRegion::new(secondary_x, secondary_y, secondary_width, height),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::scene_layer::{MotionFactor, SceneLayer};
|
||||
use crate::tile::Tile;
|
||||
use crate::tilemap::TileMap;
|
||||
|
||||
fn make_layer(
|
||||
tile_size: TileSize,
|
||||
motion_x: f32,
|
||||
motion_y: f32,
|
||||
width: usize,
|
||||
height: usize,
|
||||
) -> SceneLayer {
|
||||
let mut tiles = Vec::new();
|
||||
for i in 0..(width * height) {
|
||||
tiles.push(Tile {
|
||||
active: true,
|
||||
glyph: Glyph { glyph_id: i as u16, palette_id: 0 },
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
});
|
||||
}
|
||||
|
||||
SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id: 1,
|
||||
tile_size,
|
||||
motion_factor: MotionFactor { x: motion_x, y: motion_y },
|
||||
tilemap: TileMap { width, height, tiles },
|
||||
}
|
||||
}
|
||||
|
||||
fn make_scene() -> SceneBank {
|
||||
SceneBank {
|
||||
layers: [
|
||||
make_layer(TileSize::Size16, 1.0, 1.0, 64, 64),
|
||||
make_layer(TileSize::Size16, 0.5, 0.5, 64, 64),
|
||||
make_layer(TileSize::Size16, 1.0, 0.75, 64, 64),
|
||||
make_layer(TileSize::Size16, 0.25, 1.0, 64, 64),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[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 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.refresh_requests.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_layer_copy_requests_follow_motion_factor() {
|
||||
let scene = make_scene();
|
||||
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
|
||||
|
||||
let update = resolver.update(&scene, 32, 48);
|
||||
|
||||
assert_eq!(update.copy_requests[0].camera_x_px, 32);
|
||||
assert_eq!(update.copy_requests[0].camera_y_px, 48);
|
||||
assert_eq!(update.copy_requests[1].camera_x_px, 16);
|
||||
assert_eq!(update.copy_requests[1].camera_y_px, 24);
|
||||
assert_eq!(update.copy_requests[3].camera_x_px, 8);
|
||||
assert_eq!(update.copy_requests[3].camera_y_px, 48);
|
||||
}
|
||||
|
||||
#[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 _ = 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 });
|
||||
}
|
||||
|
||||
#[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 _ = resolver.update(&scene, 0, 0);
|
||||
let update = resolver.update(&scene, 64, 0);
|
||||
|
||||
assert!(update.master_anchor.x > 12);
|
||||
assert!(update.refresh_requests.iter().any(|request| matches!(
|
||||
request,
|
||||
CacheRefreshRequest::RefreshColumn { layer_index: 0, .. }
|
||||
| CacheRefreshRequest::RefreshRegion { layer_index: 0, .. }
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_clamp_near_scene_edges() {
|
||||
let scene = make_scene();
|
||||
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 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 });
|
||||
}
|
||||
|
||||
#[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 _ = resolver.update(&scene, 0, 0);
|
||||
let update = resolver.update(&scene, 64, 80);
|
||||
|
||||
let regions: Vec<_> = update
|
||||
.refresh_requests
|
||||
.iter()
|
||||
.filter_map(|request| match request {
|
||||
CacheRefreshRequest::RefreshRegion { layer_index: 0, region } => Some(*region),
|
||||
_ => None,
|
||||
})
|
||||
.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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_scene_requests_full_invalidation_for_all_layers() {
|
||||
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
|
||||
|
||||
let requests = resolver.reset_scene();
|
||||
|
||||
assert_eq!(requests.len(), 4);
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.all(|request| matches!(request, CacheRefreshRequest::InvalidateLayer { .. }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
use crate::glyph::Glyph;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Sprite {
|
||||
pub glyph: Glyph,
|
||||
pub x: i32,
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
use crate::glyph::Glyph;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct Tile {
|
||||
pub glyph: Glyph,
|
||||
pub active: bool,
|
||||
pub glyph: Glyph,
|
||||
pub flip_x: bool,
|
||||
pub flip_y: bool,
|
||||
}
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
use crate::glyph_bank::TileSize;
|
||||
use crate::glyph_bank::TileSize::Size8;
|
||||
use crate::tile::Tile;
|
||||
|
||||
pub struct TileMap {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub tiles: Vec<Tile>,
|
||||
}
|
||||
|
||||
impl TileMap {
|
||||
fn create(width: usize, height: usize) -> Self {
|
||||
Self { width, height, tiles: vec![Tile::default(); width * height] }
|
||||
}
|
||||
|
||||
pub fn set_tile(&mut self, x: usize, y: usize, tile: Tile) {
|
||||
if x < self.width && y < self.height {
|
||||
self.tiles[y * self.width + x] = tile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TileLayer {
|
||||
pub bank_id: u8,
|
||||
pub tile_size: TileSize,
|
||||
pub map: TileMap,
|
||||
}
|
||||
|
||||
impl TileLayer {
|
||||
fn create(width: usize, height: usize, tile_size: TileSize) -> Self {
|
||||
Self { bank_id: 0, tile_size, map: TileMap::create(width, height) }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for TileLayer {
|
||||
type Target = TileMap;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.map
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for TileLayer {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.map
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ScrollableTileLayer {
|
||||
pub layer: TileLayer,
|
||||
pub scroll_x: i32,
|
||||
pub scroll_y: i32,
|
||||
}
|
||||
|
||||
impl ScrollableTileLayer {
|
||||
pub fn new(width: usize, height: usize, tile_size: TileSize) -> Self {
|
||||
Self { layer: TileLayer::create(width, height, tile_size), scroll_x: 0, scroll_y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ScrollableTileLayer {
|
||||
type Target = TileLayer;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.layer
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for ScrollableTileLayer {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.layer
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HudTileLayer {
|
||||
pub layer: TileLayer,
|
||||
}
|
||||
|
||||
impl HudTileLayer {
|
||||
pub fn new(width: usize, height: usize) -> Self {
|
||||
Self { layer: TileLayer::create(width, height, Size8) }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for HudTileLayer {
|
||||
type Target = TileLayer;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.layer
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for HudTileLayer {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.layer
|
||||
}
|
||||
}
|
||||
33
crates/console/prometeu-hal/src/tilemap.rs
Normal file
33
crates/console/prometeu-hal/src/tilemap.rs
Normal file
@ -0,0 +1,33 @@
|
||||
use crate::tile::Tile;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct TileMap {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub tiles: Vec<Tile>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::glyph::Glyph;
|
||||
|
||||
#[test]
|
||||
fn tilemap_tile_write_and_read_remain_canonical() {
|
||||
let mut map = TileMap { width: 2, height: 2, tiles: vec![Tile::default(); 4] };
|
||||
|
||||
let index = 3;
|
||||
map.tiles[index] = Tile {
|
||||
active: true,
|
||||
glyph: Glyph { glyph_id: 99, palette_id: 5 },
|
||||
flip_x: true,
|
||||
flip_y: false,
|
||||
};
|
||||
|
||||
assert_eq!(map.tiles[index].glyph.glyph_id, 99);
|
||||
assert_eq!(map.tiles[index].glyph.palette_id, 5);
|
||||
assert!(map.tiles[index].flip_x);
|
||||
assert!(map.tiles[index].active);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
{"type":"meta","next_id":{"DSC":25,"AGD":25,"DEC":13,"PLN":11,"LSN":30,"CLSN":1}}
|
||||
{"type":"meta","next_id":{"DSC":26,"AGD":26,"DEC":14,"PLN":17,"LSN":30,"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"}]}
|
||||
@ -17,6 +17,7 @@
|
||||
{"type":"discussion","id":"DSC-0012","status":"open","ticket":"perf-runtime-introspection-syscalls","title":"Agenda - [PERF] Runtime Introspection Syscalls","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0011","file":"workflow/agendas/AGD-0011-perf-runtime-introspection-syscalls.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
|
||||
{"type":"discussion","id":"DSC-0013","status":"done","ticket":"perf-host-debug-overlay-isolation","title":"Agenda - [PERF] Host Debug Overlay Isolation","created_at":"2026-03-27","updated_at":"2026-04-10","tags":[],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"lessons/DSC-0013-perf-host-debug-overlay-isolation/LSN-0027-host-debug-overlay-isolation.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
|
||||
{"type":"discussion","id":"DSC-0024","status":"done","ticket":"generic-memory-bank-slot-contract","title":"Agenda - Generic Memory Bank Slot Contract","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["runtime","asset","memory-bank","slots","host"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0029","file":"lessons/DSC-0024-generic-memory-bank-slot-contract/LSN-0029-slot-first-bank-telemetry-belongs-in-asset-manager.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
|
||||
{"type":"discussion","id":"DSC-0025","status":"open","ticket":"scene-bank-and-viewport-cache-refactor","title":"Agenda - Scene Bank and Viewport Cache Refactor","created_at":"2026-04-11","updated_at":"2026-04-13","tags":["gfx","tilemap","runtime","render"],"agendas":[{"id":"AGD-0025","file":"AGD-0025-scene-bank-and-viewport-cache-refactor.md","status":"accepted","created_at":"2026-04-11","updated_at":"2026-04-13"}],"decisions":[{"id":"DEC-0013","file":"DEC-0013-scene-bank-and-viewport-cache-model.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_agenda":"AGD-0025"}],"plans":[{"id":"PLN-0011","file":"PLN-0011-scene-core-types-and-bank-contract.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]},{"id":"PLN-0012","file":"PLN-0012-scene-viewport-cache-structure.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]},{"id":"PLN-0013","file":"PLN-0013-scene-viewport-resolver-and-rematerialization.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]},{"id":"PLN-0014","file":"PLN-0014-renderer-migration-to-scene-viewport-cache.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]},{"id":"PLN-0015","file":"PLN-0015-api-bank-integration-and-tests.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]},{"id":"PLN-0016","file":"PLN-0016-scene-binary-payload-format-and-decoder.md","status":"accepted","created_at":"2026-04-13","updated_at":"2026-04-13","ref_decisions":["DEC-0013"]}],"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"}]}
|
||||
|
||||
@ -0,0 +1,408 @@
|
||||
---
|
||||
id: AGD-0025
|
||||
ticket: scene-bank-and-viewport-cache-refactor
|
||||
title: Agenda - Scene Bank and Viewport Cache Refactor
|
||||
status: open
|
||||
created: 2026-04-11
|
||||
resolved:
|
||||
decision:
|
||||
tags: [gfx, tilemap, runtime, render]
|
||||
---
|
||||
|
||||
# Agenda - Scene Bank and Viewport Cache Refactor
|
||||
|
||||
## Contexto
|
||||
|
||||
Hoje o runtime mistura diretamente:
|
||||
|
||||
- o mapa canonico da layer;
|
||||
- o estado de scroll;
|
||||
- e o consumo direto pelo renderer.
|
||||
|
||||
Na implementacao atual, os `64x64` das `ScrollableTileLayer` criadas em `Gfx::new()` sao o proprio mapa residente da layer que o renderer consulta diretamente. Nao existe hoje separacao explicita entre:
|
||||
|
||||
- fonte canonica do mundo carregado;
|
||||
- layer canonica da scene;
|
||||
- view/cache materializada para render.
|
||||
|
||||
Ao longo desta discussao, o foco deixou de ser ringbuffer como tema principal. A convergencia real passou a ser um refactor do sistema de tilemap para separar melhor:
|
||||
|
||||
- modelo canonico do mundo;
|
||||
- viewport materializada;
|
||||
- camera;
|
||||
- composicao incremental no `back`.
|
||||
|
||||
Tambem ficou claro que:
|
||||
|
||||
- `Tile` nao carrega RGB; ele referencia glyph/palette/flags e continua relativamente leve;
|
||||
- por isso, manter o tilemap canonico inteiro em memoria e defensavel no baseline atual;
|
||||
- o ganho mais promissor parece estar em reduzir recomposicao bruta, nao em residentizar parcialmente o mundo logo de inicio.
|
||||
|
||||
## Problema
|
||||
|
||||
Ainda nao esta definido como reorganizar o sistema de tilemap para:
|
||||
|
||||
- manter um modelo canonico simples e previsivel;
|
||||
- materializar apenas o necessario para render;
|
||||
- permitir camera desacoplada do tilemap;
|
||||
- evitar full redraw bruto sempre que a camera ou o mundo se movem;
|
||||
- preparar o renderer para compor a world a partir de cache de viewport.
|
||||
|
||||
## Pontos Criticos
|
||||
|
||||
1. O `SceneBank` precisa ser a fonte da verdade.
|
||||
Gameplay, fisica e futuras extensoes nao devem depender do cache de viewport.
|
||||
|
||||
2. O `SceneViewportCache` precisa ser operacional, nao semantico.
|
||||
Ele existe para render, nao para redefinir o contrato do mundo.
|
||||
|
||||
3. O refactor pode quebrar o shape atual.
|
||||
Nao ha necessidade de preservar `ScrollableTileLayer` nem compatibilidade com a modelagem existente.
|
||||
|
||||
4. A camera deve ficar desacoplada.
|
||||
O bank nao deve conhecer detalhes de camera; um componente intermediario resolve camera -> viewport -> cache.
|
||||
|
||||
5. O custo principal a atacar continua sendo composicao.
|
||||
A materializacao do cache precisa conversar com uma estrategia de redraw menos destrutiva no `back`.
|
||||
|
||||
## Opcoes
|
||||
|
||||
### Opcao A - Manter o modelo atual e otimizar o renderer em volta
|
||||
|
||||
- **Abordagem:** preservar `TileLayer`/`ScrollableTileLayer` como hoje e atacar apenas dirtying, blit e composicao no `back`.
|
||||
- **Pros:** menor refactor estrutural.
|
||||
- **Contras:** mantem o acoplamento entre mapa, scroll e renderer; pior base para evolucao arquitetural.
|
||||
|
||||
### Opcao B - Refatorar para `SceneBank` + `SceneViewportCache`
|
||||
|
||||
- **Abordagem:** separar modelo canonico da scene e cache materializado de viewport, com camera desacoplada e algoritmo proprio de rematerializacao.
|
||||
- **Pros:** arquitetura mais clara; melhor separacao de responsabilidades; base melhor para dirtying e composicao incremental.
|
||||
- **Contras:** refactor maior; exige redefinir tipos e fluxo do renderer.
|
||||
|
||||
## Sugestao / Recomendacao
|
||||
|
||||
A recomendacao atual e seguir com a **Opcao B**.
|
||||
|
||||
O refactor deve:
|
||||
|
||||
1. manter o mundo canonico inteiro no `SceneBank`;
|
||||
2. substituir o consumo direto de layer/mapa por um `SceneViewportCache`;
|
||||
3. desacoplar camera do modelo canonico;
|
||||
4. preparar o renderer para compor a world a partir de cache materializado;
|
||||
5. preservar sprites, HUD e fades como sistemas separados da invalidacao do cache de viewport.
|
||||
|
||||
## Direcao Atual Consolidada
|
||||
|
||||
Fica aceito, a menos de revisao explicita posterior, que:
|
||||
|
||||
- o runtime podera carregar ate **16** entradas em `BankType::TILEMAP`;
|
||||
- cada entrada carregada representa um **`SceneBank`**;
|
||||
- cada `SceneBank` contem as **4 layers** da scene;
|
||||
- `background` e `parallax` passam a ser tratados dentro das proprias `SceneLayer`s, nao como tipo separado desta V1;
|
||||
- cada `SceneLayer` porta seu proprio `TileMap`;
|
||||
- cada `TileMap` e um conjunto de `Tile`;
|
||||
- o renderer nao deve consumir o `SceneBank` diretamente;
|
||||
- o renderer deve consumir um **`SceneViewportCache`** derivado do `SceneBank`;
|
||||
- `ScrollableTileLayer` **morre** nesta arquitetura;
|
||||
- nao ha obrigacao de compatibilidade com o shape atual.
|
||||
|
||||
## Modelo Alvo de V1
|
||||
|
||||
### Hierarquia conceitual
|
||||
|
||||
- `Scene` (`BankType::SCENE`)
|
||||
- agregado canonico carregado
|
||||
- contem as `SceneLayer`s da scene
|
||||
|
||||
- `SceneLayer`
|
||||
- layer canonica da scene
|
||||
- contem metadados da layer e seu `TileMap`
|
||||
- pode representar layer normal, background ou parallax pela sua configuracao
|
||||
|
||||
- `TileMap`
|
||||
- grade de tiles de uma layer
|
||||
|
||||
- `Tile`
|
||||
- unidade basica de conteudo por celula
|
||||
|
||||
- `SceneViewportCache`
|
||||
- cache/view materializada para render
|
||||
- derivada da `Scene`
|
||||
- contem ringbuffers internos, um por layer
|
||||
|
||||
### Papel de cada tipo
|
||||
|
||||
- `Scene`
|
||||
- fonte da verdade
|
||||
- usado por gameplay, fisica e materializacao
|
||||
|
||||
- `SceneViewportCache`
|
||||
- usado pelo renderer
|
||||
- sabe scroll fino em pixels
|
||||
- nao e fonte da verdade do mundo
|
||||
- e a fonte imediata de copia para o blit
|
||||
|
||||
- `SceneLayer`
|
||||
- passa a carregar tambem metadados de movimento relativos ao totem mestre
|
||||
- isso permite compor layers normais e parallax sob o mesmo contrato base
|
||||
|
||||
### O que um tile do cache pode guardar
|
||||
|
||||
V1 deve partir de cache leve. Alem do `Tile` cru, ele pode guardar alguns dados derivados para acelerar raster:
|
||||
|
||||
- `glyph_id` pronto para consulta;
|
||||
- `palette_id` pronto para uso;
|
||||
- flags de flip compactadas;
|
||||
- marca rapida de `active/empty`;
|
||||
- referencia do glyph bank ja resolvida por layer;
|
||||
- metadados de dirty local do proprio cache.
|
||||
|
||||
V1 nao deve:
|
||||
|
||||
- guardar pixels RGB resolvidos por tile;
|
||||
- duplicar desnecessariamente fisica no cache de render;
|
||||
- virar um mini-framebuffer por tile.
|
||||
|
||||
### Movimento por layer
|
||||
|
||||
Fica aceito que cada `SceneLayer` pode carregar um fator de movimento relativo ao totem mestre.
|
||||
|
||||
Leitura:
|
||||
|
||||
- existe um **totem mestre** resolvido pelo sistema de camera/viewport;
|
||||
- cada layer resolve seu proprio deslocamento efetivo a partir desse totem mestre;
|
||||
- layers com fator `1.0` acompanham o mundo principal;
|
||||
- layers com fator diferente de `1.0` permitem efeito de parallax;
|
||||
- isso mantem `background` e `parallax` dentro do mesmo modelo estrutural de `SceneLayer`.
|
||||
|
||||
## Camera e Resolver
|
||||
|
||||
Fica aceito que:
|
||||
|
||||
- a camera existe em **pixel space**;
|
||||
- a `Scene` nao conhece camera;
|
||||
- um componente externo faz a ligacao entre camera e cache materializado;
|
||||
- esse componente calcula viewport logica, totem mestre, drift, clamp e rematerializacao.
|
||||
|
||||
Nome conceitual provisório:
|
||||
|
||||
- `SceneViewportResolver`
|
||||
- ou equivalente com a mesma responsabilidade.
|
||||
|
||||
Direcao aceita para o `SceneViewportResolver`:
|
||||
|
||||
- ele e responsavel pelos totens:
|
||||
- totem mestre
|
||||
- totens derivados por layer
|
||||
- ele recebe posicao de camera de uma entidade externa;
|
||||
- internamente, a posicao da camera e tratada como insumo para o totem mestre;
|
||||
- ele clampa o totem mestre;
|
||||
- ele decide se o cache precisa ou nao ser atualizado;
|
||||
- se necessario, ele dispara atualizacao do `SceneViewportCache`;
|
||||
- ele tambem deve saber instrumentalizar requests de copia por layer a partir da posicao da camera e do estado do cache;
|
||||
- ele **nao executa a copia** no `back`, apenas informa o que deve ser copiado e de onde.
|
||||
|
||||
## Viewport, Halo e Tamanho da Janela
|
||||
|
||||
Dados atuais do runtime:
|
||||
|
||||
- resolucao interna: **320x180**
|
||||
- world layers: **16x16**
|
||||
- HUD: **8x8**
|
||||
|
||||
Para world `16x16` em `320x180`, a viewport raster minima atual equivale a:
|
||||
|
||||
- **21x12 tiles**
|
||||
|
||||
Direcao aceita para V1:
|
||||
|
||||
- o `SceneViewportCache` materializa mais do que a viewport raster minima;
|
||||
- o tamanho alvo de V1 passa a ser **25x16 tiles**;
|
||||
- isso da folga suficiente para velocidades agressivas de camera sem inflar demais o cache;
|
||||
- `64x64` deixa de ser tamanho alvo e passa a ser apenas referencia historica do modelo atual.
|
||||
|
||||
Ordem de grandeza de memoria para `25x16`:
|
||||
|
||||
- `400 tiles`
|
||||
- usando `36 bytes/tile` como estimativa conservadora com margem, o cache fica em ~`14.1 KiB`
|
||||
|
||||
## Algoritmo de Totem, Drift e Histerese
|
||||
|
||||
### Modelo aceito
|
||||
|
||||
- existe um totem mestre em `tile space`: `(i, j)`
|
||||
- existe uma camera em `pixel space`: `(x, y)`
|
||||
- com tile `16x16`, o centro do totem mestre em pixels e:
|
||||
- `cx = 16 * i + 8`
|
||||
- `cy = 16 * j + 8`
|
||||
- o drift por eixo e:
|
||||
- `dx = x - cx`
|
||||
- `dy = y - cy`
|
||||
|
||||
### Histerese aceita para V1
|
||||
|
||||
Fica aceito como baseline:
|
||||
|
||||
- `safe = 12 px`
|
||||
- `trigger = 20 px`
|
||||
|
||||
Interpretacao:
|
||||
|
||||
- dentro de `[-12, +12]`, nada acontece;
|
||||
- entre `12` e `20`, existe tolerancia sem rematerializacao;
|
||||
- ao ultrapassar `20`, o totem anda em tiles e o cache rematerializa as faixas necessarias.
|
||||
|
||||
### Regra por eixo
|
||||
|
||||
Horizontal:
|
||||
|
||||
- se `dx > +20`, o totem anda `+1` tile em `x`
|
||||
- se `dx < -20`, o totem anda `-1` tile em `x`
|
||||
|
||||
Vertical:
|
||||
|
||||
- se `dy > +20`, o totem anda `+1` tile em `y`
|
||||
- se `dy < -20`, o totem anda `-1` tile em `y`
|
||||
|
||||
Depois de cada movimento:
|
||||
|
||||
- recalcula-se o centro do totem;
|
||||
- recalcula-se o drift;
|
||||
- se ainda houver excesso, o processo repete.
|
||||
|
||||
### Motivo da histerese
|
||||
|
||||
Histerese fica cravada na agenda como tecnica explicita para evitar flick/thrash de borda.
|
||||
|
||||
Ela existe para:
|
||||
|
||||
- evitar vai-e-volta quando camera/player oscilam perto do limite;
|
||||
- manter o `SceneViewportCache` estavel;
|
||||
- disparar rematerializacao por eventos discretos, nao por ruido de borda.
|
||||
|
||||
### Politica de atualizacao do cache
|
||||
|
||||
Fica aceito que:
|
||||
|
||||
- a atualizacao normal do cache ocorre por **linha/coluna**;
|
||||
- se houver trigger simultaneo em `x` e `y`, o algoritmo deve permitir atualizacao por **area/regiao** para evitar carregar tiles ja presentes;
|
||||
- troca de `Scene` invalida o `SceneViewportCache` completamente;
|
||||
- scroll/camera nao devem invalidar a janela inteira como regra normal.
|
||||
|
||||
### `SceneViewportCache` e ringbuffer
|
||||
|
||||
Leitura atual da agenda:
|
||||
|
||||
- **sim, o `SceneViewportCache` deve preferencialmente usar ringbuffer internamente**;
|
||||
- **nao** deve virar contrato semantico do mundo nem da `Scene`;
|
||||
- a principal vantagem aparece justamente no padrao aceito de atualizacao por linha/coluna e por area nos cantos;
|
||||
- com layers de parallax, esse beneficio aumenta, porque cada layer pode materializar sua propria janela efetiva a partir do totem mestre sem precisar copiar janelas inteiras sempre.
|
||||
|
||||
Direcao aceita:
|
||||
|
||||
- `SceneViewportCache` usa armazenamento em anel internamente como implementacao preferida;
|
||||
- existe **um ringbuffer por layer**;
|
||||
- isso fica encapsulado dentro do cache;
|
||||
- `Scene`, `SceneLayer` e `TileMap` continuam semanticamente simples;
|
||||
- o renderer deve consumir o cache como view materializada, nao como estrutura ciclica exposta.
|
||||
- na primeira leva, o blit/composite no `back` ainda pode continuar destrutivo;
|
||||
- o ganho esperado do ringbuffer fica principalmente em evitar copias internas repetidas do proprio cache antes do blit final.
|
||||
|
||||
### Clamp de borda
|
||||
|
||||
Direcao atual:
|
||||
|
||||
- o totem e um valor de referencia em `x/y`;
|
||||
- o clamp inicial pode ser pensado como:
|
||||
- minimos em torno de `w/2` e `h/2`
|
||||
- maximos em torno de `layer_size - (w/2, h/2)`
|
||||
- nos cantos, camera e cache nao precisam coincidir de forma simetrica;
|
||||
- o cache pode ficar clampado ao limite do `SceneBank`.
|
||||
|
||||
## Composicao no Back
|
||||
|
||||
Fica aceito que:
|
||||
|
||||
- `HUD` continua sempre por cima;
|
||||
- `sprites` podem aparecer entre layers;
|
||||
- a ordem observavel de composicao continua sendo algo como:
|
||||
- `layer 0`
|
||||
- `sprites relevantes`
|
||||
- `layer 1`
|
||||
- `sprites relevantes`
|
||||
- `layer 2`
|
||||
- `sprites relevantes`
|
||||
- `layer 3`
|
||||
- `sprites relevantes`
|
||||
- `HUD`
|
||||
|
||||
Leitura atual:
|
||||
|
||||
- ainda pode haver redraw completo da ordem de composicao observavel;
|
||||
- o ganho principal esperado vem de parar de resolver o tilemap bruto toda vez e passar a compor a partir do `SceneViewportCache`;
|
||||
- `sprites`, `HUD` e `fades` ficam separados da politica de invalidacao do cache de viewport.
|
||||
|
||||
Invariantes aceitos:
|
||||
|
||||
1. `SceneViewportCache` nao e fonte da verdade de dados, mas e a fonte imediata de copia para o blit;
|
||||
2. `sprites`, `HUD` e `fades` nao entram no cache;
|
||||
3. a ordem observavel continua:
|
||||
- `layer 0`
|
||||
- sprites intermediarios
|
||||
- `layer 1`
|
||||
- sprites intermediarios
|
||||
- `layer 2`
|
||||
- sprites intermediarios
|
||||
- `layer 3`
|
||||
- sprites intermediarios
|
||||
- `HUD`
|
||||
|
||||
## Open Questions Prioritarias
|
||||
|
||||
### 1. Shape do `SceneBank`
|
||||
|
||||
- **Direcao aceita:** o agregado canonico passa a se chamar **`Scene`**, com `BankType::SCENE`.
|
||||
- **Direcao revisada:** `background` e `parallax` entram no mesmo contrato de `SceneLayer`, diferenciados por seus metadados e fator de movimento relativo ao totem mestre.
|
||||
- **Direcao aceita:** a `Scene` deve expor leitura/escrita por tile **e** operacoes por regiao/faixa.
|
||||
|
||||
### 2. Shape do `SceneViewportCache`
|
||||
|
||||
- **Direcao aceita:** o cache e um unico agregado por scene, contendo as 4 layers internamente.
|
||||
- **Direcao aceita:** entram na V1 campos derivados leves para acelerar raster, incluindo `glyph_id`, `palette_id`, flags de flip, marca de `active/empty` e referencia rapida de bank por layer.
|
||||
- **Direcao aceita:** o cache usara ringbuffer internamente desde a V1 como implementacao preferida, com um ringbuffer por layer.
|
||||
|
||||
### 3. Resolver camera -> cache
|
||||
|
||||
- **Direcao aceita:** drift, trigger, clamp e posicao do totem moram no resolver.
|
||||
- **Direcao aceita:** o resolver trabalha com um totem mestre e deriva deslocamentos efetivos por layer quando houver fator de movimento diferente.
|
||||
- **Direcao aceita:** o cache permanece focado em armazenar a materializacao para render.
|
||||
|
||||
### 4. Composicao incremental
|
||||
|
||||
- **Leitura aceita desta agenda:** neste momento nao ha seguranca para assumir algo mais sofisticado do que composicao destrutiva total da ordem observavel no `back`.
|
||||
- **Direcao atual:** o ganho esperado continua vindo de deixar de resolver o tilemap bruto toda vez, mesmo que a composicao no `back` continue destrutiva.
|
||||
|
||||
### 5. Camera
|
||||
|
||||
- **Direcao aceita:** camera completa fica fora desta decisao.
|
||||
- **Direcao aceita:** esta decisao trabalha apenas com uma API que tenta mover o totem; o contrato completo de camera sera discutido separadamente.
|
||||
|
||||
## Criterio para Encerrar
|
||||
|
||||
Esta agenda pode ser encerrada quando houver alinhamento explicito sobre:
|
||||
|
||||
- shape minimo de `Scene`, `SceneLayer`, `TileMap`, `Tile`, `SceneViewportCache` e `SceneViewportResolver`;
|
||||
- contrato do resolver camera -> viewport cache;
|
||||
- politica de rematerializacao por faixa/regiao;
|
||||
- relacao entre `SceneViewportCache` e composicao no `back`;
|
||||
- invariantes que devem aparecer na decisao posterior para renderer, camera e scene loading.
|
||||
|
||||
## Estado Atual da Agenda
|
||||
|
||||
Leitura consolidada desta agenda neste momento:
|
||||
|
||||
- a direcao arquitetural principal ja esta aceita;
|
||||
- o tema deixou de ser ringbuffer generico e virou refactor do sistema de tilemap/render para `Scene` + `SceneViewportCache`;
|
||||
- `background` e `parallax` passam a ser absorvidos pelo proprio contrato de `SceneLayer`, via fator de movimento relativo ao totem mestre;
|
||||
- `ScrollableTileLayer` deixa de existir na arquitetura alvo;
|
||||
- a decisao seguinte deve cristalizar tipos, responsabilidades e invariantes, em vez de reabrir o debate macro.
|
||||
@ -0,0 +1,227 @@
|
||||
---
|
||||
id: DEC-0013
|
||||
ticket: scene-bank-and-viewport-cache-refactor
|
||||
title: Decision - Scene Bank and Viewport Cache Model
|
||||
status: accepted
|
||||
created: 2026-04-13
|
||||
accepted: 2026-04-13
|
||||
agenda: AGD-0025
|
||||
plans: [PLN-0011, PLN-0012, PLN-0013, PLN-0014, PLN-0015]
|
||||
tags: [gfx, tilemap, runtime, render]
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
This decision normatively locks the V1 model for scene-backed tilemap rendering around:
|
||||
|
||||
- `Scene` as canonical loaded state;
|
||||
- `SceneLayer` as the canonical layer unit;
|
||||
- `SceneViewportCache` as the materialized render cache;
|
||||
- `SceneViewportResolver` as the owner of totems, drift, hysteresis, clamp, and cache update decisions.
|
||||
|
||||
## Contexto
|
||||
|
||||
The current runtime couples three concerns too tightly:
|
||||
|
||||
- canonical layer data;
|
||||
- scroll state;
|
||||
- direct renderer consumption.
|
||||
|
||||
In the current implementation, the `64x64` maps created for scrollable layers are not an independent viewport cache. They are the actual layer maps consumed directly by the renderer. That shape makes it harder to:
|
||||
|
||||
- keep canonical world state simple;
|
||||
- materialize only the viewport-relevant subset for render;
|
||||
- evolve camera separately from the tilemap model;
|
||||
- reduce repeated tilemap resolution work before the final destructive blit into `back`.
|
||||
|
||||
During agenda `AGD-0025`, the discussion initially explored ringbuffer as a general topic, but converged on a different architectural target: a clean split between canonical scene state and render-oriented viewport materialization.
|
||||
|
||||
## Decisao
|
||||
|
||||
The runtime SHALL adopt the following V1 model:
|
||||
|
||||
1. Canonical loaded tilemap state SHALL be represented by `Scene`.
|
||||
2. A `Scene` SHALL contain four canonical `SceneLayer`s.
|
||||
3. Each `SceneLayer` SHALL own its canonical `TileMap`.
|
||||
4. Each `TileMap` SHALL remain the canonical grid of `Tile`s for that layer.
|
||||
5. The renderer MUST NOT consume canonical `Scene` data directly for normal world composition.
|
||||
6. The renderer SHALL consume a `SceneViewportCache` derived from `Scene`.
|
||||
7. `SceneViewportCache` SHALL be operational render state only and MUST NOT become the semantic source of truth for gameplay or physics.
|
||||
8. `SceneViewportCache` SHOULD use internal ringbuffer storage as the preferred V1 implementation strategy.
|
||||
9. Ringbuffer details MUST remain encapsulated inside `SceneViewportCache` and MUST NOT leak into the semantic contract of `Scene`, `SceneLayer`, `TileMap`, or `Tile`.
|
||||
10. `ScrollableTileLayer` SHALL be removed from the architecture and MUST NOT be preserved as a canonical runtime concept.
|
||||
11. `background` and `parallax` SHALL remain expressible through `SceneLayer` itself, not through a separate V1 background type.
|
||||
12. Each `SceneLayer` SHALL carry a relative movement factor against a master totem, allowing normal layers, background-like layers, and parallax-like layers under the same layer contract.
|
||||
|
||||
## Rationale
|
||||
|
||||
This decision prefers architectural clarity over preserving the current type graph.
|
||||
|
||||
The key arguments are:
|
||||
|
||||
- `Tile` remains light enough that keeping canonical tilemaps resident is acceptable in the current baseline.
|
||||
- The more pressing cost appears to be repeated world resolution and composition work, not merely the existence of tilemap data in memory.
|
||||
- A dedicated viewport cache gives the renderer a better contract without forcing gameplay and physics to depend on residency mechanics.
|
||||
- Internal ringbuffering in the cache is useful because the first expected gain is avoiding repeated cache-side copies, even if final composition into `back` remains destructive in V1.
|
||||
- Keeping parallax inside `SceneLayer` avoids prematurely splitting the scene model into separate layer families while still allowing differentiated movement behavior.
|
||||
|
||||
This model also keeps room for later refinement:
|
||||
|
||||
- richer camera semantics can evolve separately;
|
||||
- background-specific types can still be introduced later if the layer contract becomes insufficient;
|
||||
- bank-type wiring can still be finalized during planning/implementation;
|
||||
- cache implementation can improve while the canonical scene contract remains stable.
|
||||
|
||||
## Invariantes / Contrato
|
||||
|
||||
### 1. Canonical Scene Model
|
||||
|
||||
- `Scene` SHALL be the canonical loaded aggregate.
|
||||
- `Scene` SHALL contain four canonical `SceneLayer`s.
|
||||
- `SceneLayer` SHALL be the canonical replacement for the old `TileLayer`.
|
||||
- `SceneLayer` SHALL minimally include:
|
||||
- `glyph_bank_id`
|
||||
- `tile_size`
|
||||
- `active`
|
||||
- `totem_factor (x, y)`
|
||||
- `tilemap`
|
||||
- `TileMap` SHALL remain a canonical grid of `Tile`.
|
||||
- `Tile` SHALL remain lightweight and MUST NOT be expanded into resolved RGB pixel payload for canonical storage.
|
||||
|
||||
### 2. Viewport Cache Model
|
||||
|
||||
- `SceneViewportCache` SHALL be a single cache aggregate for one `Scene`.
|
||||
- It SHALL contain four internal layer caches, one per canonical scene layer.
|
||||
- The preferred V1 implementation is one internal ringbuffer per layer cache.
|
||||
- Ringbuffer details MUST stay internal to the cache implementation.
|
||||
- `SceneViewportCache` MAY store lightweight derived fields to accelerate raster, including:
|
||||
- resolved `glyph_id`
|
||||
- resolved `palette_id`
|
||||
- packed flip flags
|
||||
- `active/empty` markers
|
||||
- fast layer-local glyph bank references
|
||||
- `SceneViewportCache` MUST NOT duplicate full physics state unless a later decision explicitly requires it.
|
||||
- `SceneViewportCache` SHALL be the immediate source of copy data for world blits, but MUST NOT become the source of truth for world semantics.
|
||||
|
||||
### 3. Resolver Contract
|
||||
|
||||
- `SceneViewportResolver` SHALL own:
|
||||
- master totem
|
||||
- per-layer derived totems
|
||||
- drift calculation
|
||||
- hysteresis
|
||||
- clamp logic
|
||||
- cache update decisions
|
||||
- An external caller SHALL provide camera position to the resolver.
|
||||
- The resolver SHALL treat that camera position as input for the master totem flow.
|
||||
- The resolver SHALL decide whether cache updates are needed.
|
||||
- The resolver SHALL trigger cache update operations when needed.
|
||||
- The resolver SHALL know how to instrument per-layer copy requests from the cache to the final compositor.
|
||||
- The resolver MUST NOT perform the actual framebuffer copy itself.
|
||||
|
||||
### 4. Viewport Size and Cache Update Policy
|
||||
|
||||
- V1 world viewport cache size SHALL be materially larger than the minimum visible world tile window and MUST include explicit halo for cache stability.
|
||||
- An initial working target in the order of `25x16` tiles is accepted as planning guidance, but exact numeric sizing MAY be finalized in the implementation plan.
|
||||
- Normal cache update SHALL occur by line and/or column.
|
||||
- When simultaneous X and Y movement requires corner refresh, the cache MUST support area/region refresh to avoid reloading tiles already present.
|
||||
- Swapping to a different `Scene` SHALL fully invalidate `SceneViewportCache`.
|
||||
- Normal camera motion MUST NOT invalidate the whole viewport cache as the default path.
|
||||
|
||||
### 5. Totem, Drift, and Hysteresis
|
||||
|
||||
- The resolver SHALL use a master totem in tile space.
|
||||
- Camera input SHALL remain in pixel space.
|
||||
- For `16x16` tiles, the master totem center SHALL be computed as:
|
||||
- `cx = 16 * i + 8`
|
||||
- `cy = 16 * j + 8`
|
||||
- Drift SHALL be computed as:
|
||||
- `dx = x - cx`
|
||||
- `dy = y - cy`
|
||||
- V1 SHALL use hysteresis with:
|
||||
- an internal safe band where no cache movement occurs;
|
||||
- an external trigger band that advances the totem and requests cache refresh.
|
||||
- Initial working values in the order of `safe = 12 px` and `trigger = 20 px` are accepted as planning guidance, but exact numeric tuning MAY be finalized in the implementation plan.
|
||||
- Inside the safe band, no cache movement SHALL occur.
|
||||
- Between safe and trigger, the system SHALL tolerate drift without rematerialization.
|
||||
- Beyond trigger, the resolver SHALL move the totem discretely in tile steps and request cache refresh.
|
||||
- Hysteresis is mandatory in V1 to prevent edge flick/thrash.
|
||||
|
||||
### 6. Clamp Behavior
|
||||
|
||||
- The resolver SHALL clamp the master totem against scene bounds.
|
||||
- Initial clamp reasoning SHALL be based on minimums around `(w/2, h/2)` and maximums around `layer_size - (w/2, h/2)`.
|
||||
- Near bounds, the cache MAY remain asymmetrically aligned relative to camera expectations.
|
||||
- That asymmetry at scene edges SHALL be considered expected behavior, not an error.
|
||||
|
||||
### 7. Composition Contract
|
||||
|
||||
- `HUD` SHALL remain above world composition.
|
||||
- `sprites` MAY appear between canonical world layers.
|
||||
- The observable composition order SHALL remain:
|
||||
- `layer 0`
|
||||
- intermediate sprites
|
||||
- `layer 1`
|
||||
- intermediate sprites
|
||||
- `layer 2`
|
||||
- intermediate sprites
|
||||
- `layer 3`
|
||||
- intermediate sprites
|
||||
- `HUD`
|
||||
- `sprites`, `HUD`, and `fades` MUST remain outside the invalidation contract of `SceneViewportCache`.
|
||||
- V1 MAY still use destructive full composition ordering in `back`.
|
||||
- The expected win in V1 comes from avoiding repeated brute-force canonical tilemap resolution before that final destructive composition.
|
||||
|
||||
## Impactos
|
||||
|
||||
This decision impacts the runtime model directly:
|
||||
|
||||
- `TileLayer` will be replaced by `SceneLayer`.
|
||||
- `ScrollableTileLayer` will be removed.
|
||||
- bank integration and naming at the asset-bank enum level SHALL be aligned during planning/implementation.
|
||||
- The renderer contract will shift from direct map consumption to cache consumption.
|
||||
- Scene loading, viewport cache maintenance, and composition responsibilities become explicitly separated.
|
||||
|
||||
Expected propagation areas:
|
||||
|
||||
- bank/domain model types;
|
||||
- tilemap/layer runtime structures;
|
||||
- renderer world composition flow;
|
||||
- camera-to-render adapter logic;
|
||||
- future plan/implementation artifacts derived from this decision.
|
||||
|
||||
## Referencias
|
||||
|
||||
- Agenda: [AGD-0025-scene-bank-and-viewport-cache-refactor.md](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/discussion/workflow/agendas/AGD-0025-scene-bank-and-viewport-cache-refactor.md)
|
||||
- Current tile/layer model:
|
||||
- [tile_layer.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile_layer.rs:1)
|
||||
- [tile.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile.rs:1)
|
||||
- [glyph.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/glyph.rs:1)
|
||||
- Current renderer usage:
|
||||
- [gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs:291)
|
||||
- [gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs:594)
|
||||
- [gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs:673)
|
||||
|
||||
## Propagacao Necessaria
|
||||
|
||||
The following work MUST derive from this decision:
|
||||
|
||||
1. Introduce the new canonical runtime types:
|
||||
- `Scene`
|
||||
- `SceneLayer`
|
||||
- `SceneViewportCache`
|
||||
- `SceneViewportResolver`
|
||||
2. Remove or retire `ScrollableTileLayer`.
|
||||
3. Migrate renderer world composition to consume `SceneViewportCache`.
|
||||
4. Define the concrete cache update API for:
|
||||
- line
|
||||
- column
|
||||
- area/region
|
||||
5. Define the concrete blit instrumentation API emitted by the resolver.
|
||||
6. Write an implementation plan before code changes.
|
||||
|
||||
## Revision Log
|
||||
|
||||
- 2026-04-13: Initial accepted decision from AGD-0025.
|
||||
@ -0,0 +1,152 @@
|
||||
---
|
||||
id: PLN-0011
|
||||
ticket: scene-bank-and-viewport-cache-refactor
|
||||
title: Plan - Scene Core Types and Bank Contract
|
||||
status: accepted
|
||||
created: 2026-04-13
|
||||
completed:
|
||||
tags: [gfx, tilemap, runtime, render]
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Introduce the canonical type model required by `DEC-0013`: `SceneBank`, `SceneLayer`, `TileMap`, and `Tile`, plus the asset-bank/domain contract changes needed to carry scene-backed tilemap data as the new source of truth.
|
||||
|
||||
## Background
|
||||
|
||||
`DEC-0013` locks the canonical runtime model around `SceneBank` and `SceneLayer`, replacing the old `TileLayer`/`ScrollableTileLayer` coupling. This plan isolates the domain-model and bank-contract refactor before cache or renderer migration begins.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- Introduce `SceneBank` and `SceneLayer` in the HAL/runtime model.
|
||||
- Preserve `TileMap` and `Tile` as canonical grid/unit concepts.
|
||||
- Define the minimum `SceneLayer` fields required by the decision.
|
||||
- Update bank/domain enums and payload contracts so scenes can exist as first-class banked content.
|
||||
- Add compile-time migration shims only when strictly needed to keep the tree buildable during the refactor.
|
||||
|
||||
### Excluded
|
||||
- `SceneViewportCache`
|
||||
- `SceneViewportResolver`
|
||||
- renderer migration to cache consumption
|
||||
- rematerialization algorithm
|
||||
- final composition changes
|
||||
- final binary `SCENE` payload format and decoder implementation
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1 - Introduce canonical scene types in HAL
|
||||
|
||||
**What:**
|
||||
Add `SceneBank` and `SceneLayer` to the HAL model and define the minimum canonical fields required by the decision.
|
||||
|
||||
**How:**
|
||||
- Replace or retire the old `TileLayer`-centric model in [tile_layer.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile_layer.rs).
|
||||
- Keep `TileMap` as the canonical grid owner and `Tile` as the canonical cell unit.
|
||||
- Define `SceneLayer` with:
|
||||
- `glyph_bank_id`
|
||||
- `tile_size`
|
||||
- `active`
|
||||
- `motion_factor`
|
||||
- `tilemap`
|
||||
- Define `SceneBank` as the aggregate of exactly four `SceneLayer`s using a fixed array.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-hal/src/tile_layer.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile_layer.rs)
|
||||
- [crates/console/prometeu-hal/src/tile.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile.rs)
|
||||
- [crates/console/prometeu-hal/src/glyph.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/glyph.rs)
|
||||
- HAL module export files as needed
|
||||
|
||||
### Step 2 - Remove `ScrollableTileLayer` from the canonical model
|
||||
|
||||
**What:**
|
||||
Delete the canonical role of `ScrollableTileLayer` and any direct ownership path that makes scroll part of the canonical scene representation.
|
||||
|
||||
**How:**
|
||||
- Remove `ScrollableTileLayer` and `HudTileLayer` from the HAL tile-layer model or reduce them to non-canonical transitional wrappers only if needed to keep compilation moving during the refactor.
|
||||
- Ensure canonical scene state no longer encodes direct renderer scroll ownership.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-hal/src/tile_layer.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/tile_layer.rs)
|
||||
- [crates/console/prometeu-hal/src/gfx_bridge.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/gfx_bridge.rs)
|
||||
|
||||
### Step 3 - Introduce the bank/domain contract for scene-backed content
|
||||
|
||||
**What:**
|
||||
Add or revise the asset-bank/domain model so the canonical loaded scene can be represented in the bank contract.
|
||||
|
||||
**How:**
|
||||
- Update `BankType` and related asset/bank metadata in HAL to support `Scene`-backed content at the domain level.
|
||||
- Adjust slot/reference types where necessary so scene content can be addressed consistently with existing banks.
|
||||
- Keep the exact asset-bank enum wiring minimal and domain-first; do not conflate it with renderer cache details.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-hal/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/asset.rs)
|
||||
- [crates/console/prometeu-hal/src/syscalls/domains/bank.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/syscalls/domains/bank.rs)
|
||||
- [crates/console/prometeu-hal/src/syscalls/domains/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/syscalls/domains/asset.rs)
|
||||
- Any bank-facing shared types used by runtime and drivers
|
||||
|
||||
### Step 4 - Thread the new scene types through driver-facing runtime structures
|
||||
|
||||
**What:**
|
||||
Prepare the driver layer to reference canonical `SceneBank` types instead of old layer/map ownership.
|
||||
|
||||
**How:**
|
||||
- Identify the driver-side structs currently owning the old layer model.
|
||||
- Replace those type references with the new canonical scene types without yet switching renderer logic to the viewport cache.
|
||||
- Keep this step focused on compile-safe type replacement, not behavior changes.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs)
|
||||
- [crates/console/prometeu-drivers/src/hardware.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/hardware.rs)
|
||||
|
||||
### Step 5 - Add baseline tests for the canonical scene model
|
||||
|
||||
**What:**
|
||||
Protect the new canonical scene types with focused tests before cache and renderer work begins.
|
||||
|
||||
**How:**
|
||||
- Add unit tests for:
|
||||
- scene-bank construction
|
||||
- layer ownership of tilemaps
|
||||
- tile write/read behavior
|
||||
- preservation of `motion_factor`
|
||||
- Keep tests domain-focused and independent of viewport caching.
|
||||
|
||||
**File(s):**
|
||||
- HAL tests colocated with scene/tile modules
|
||||
- Driver tests only if required to preserve build integrity
|
||||
|
||||
## Test Requirements
|
||||
|
||||
### Unit Tests
|
||||
- `SceneBank` owns exactly four canonical `SceneLayer`s.
|
||||
- `SceneLayer` exposes the required minimal fields.
|
||||
- `TileMap` indexing and mutation still behave canonically.
|
||||
- `motion_factor` survives construction and mutation paths.
|
||||
|
||||
### Integration Tests
|
||||
- Asset/bank domain types accept the new scene-backed contract without breaking existing glyph/sound usage.
|
||||
- `SCENE` binary decode remains intentionally open and out of scope for this plan.
|
||||
|
||||
### Manual Verification
|
||||
- Build the console crates and verify no canonical runtime path still treats `ScrollableTileLayer` as the source of truth.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `SceneBank` and `SceneLayer` exist as canonical runtime types.
|
||||
- [ ] `ScrollableTileLayer` is no longer the canonical scene model.
|
||||
- [ ] Bank/domain types can represent scene-backed content.
|
||||
- [ ] Driver-facing runtime structures compile against the new canonical scene model.
|
||||
- [ ] Baseline tests protect the new type graph.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Source decision: `DEC-0013`
|
||||
- No dependency on later plans; this is the foundation for the rest of the family.
|
||||
|
||||
## Risks
|
||||
|
||||
- Breaking too many public or cross-crate type references at once can stall compilation.
|
||||
- Bank enum changes can ripple into syscalls and telemetry if not scoped carefully.
|
||||
- Transitional wrappers can accidentally survive longer than intended if not explicitly retired in later plans.
|
||||
@ -0,0 +1,147 @@
|
||||
---
|
||||
id: PLN-0012
|
||||
ticket: scene-bank-and-viewport-cache-refactor
|
||||
title: Plan - Scene Viewport Cache Structure
|
||||
status: accepted
|
||||
created: 2026-04-13
|
||||
completed:
|
||||
tags: [gfx, tilemap, runtime, render]
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Implement the `SceneViewportCache` as the operational render cache for one `SceneBank`, including one internal ringbuffer per layer and lightweight derived cache entries for raster acceleration.
|
||||
|
||||
## Background
|
||||
|
||||
`DEC-0013` locks `SceneViewportCache` as the renderer-facing view of scene data and prefers internal ringbuffer storage per layer. `PLN-0011` already established the canonical source as `SceneBank` with exactly four `SceneLayer`s in a fixed array and moved per-layer movement metadata to `motion_factor`. This plan isolates the cache data structure and update API before resolver and renderer integration.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- Define `SceneViewportCache`.
|
||||
- Define the per-layer internal ringbuffer structure.
|
||||
- Define the cached tile entry format.
|
||||
- Define cache update APIs for line, column, and area/region.
|
||||
- Define full invalidation on scene swap.
|
||||
|
||||
### Excluded
|
||||
- camera logic
|
||||
- drift/hysteresis logic
|
||||
- final renderer migration
|
||||
- execution of blits into `back`
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1 - Define cache entry and layer-cache structures
|
||||
|
||||
**What:**
|
||||
Create the internal data model for one viewport cache layer and for cached tile entries.
|
||||
|
||||
**How:**
|
||||
- Introduce a cache tile entry type that stores:
|
||||
- resolved `glyph_id`
|
||||
- resolved `palette_id`
|
||||
- packed flip flags
|
||||
- `active/empty`
|
||||
- fast layer-local glyph bank reference/index
|
||||
- Introduce one ringbuffer-backed layer-cache structure per scene layer.
|
||||
- Keep ringbuffer internals encapsulated and out of public semantic APIs.
|
||||
|
||||
**File(s):**
|
||||
- New HAL or driver-side viewport-cache module(s), likely under:
|
||||
- `crates/console/prometeu-hal/src/`
|
||||
- and/or `crates/console/prometeu-drivers/src/`
|
||||
|
||||
### Step 2 - Define `SceneViewportCache` as a four-layer aggregate
|
||||
|
||||
**What:**
|
||||
Create the top-level cache aggregate for one `SceneBank`.
|
||||
|
||||
**How:**
|
||||
- Represent one cache aggregate containing four internal layer caches aligned to `SceneBank.layers`.
|
||||
- Thread through cache dimensions and tile-size assumptions for V1.
|
||||
- Make the cache explicitly scene-derived and non-canonical.
|
||||
|
||||
**File(s):**
|
||||
- New cache module(s)
|
||||
- Any related exports in HAL/driver public surfaces
|
||||
|
||||
### Step 3 - Implement cache mutation APIs
|
||||
|
||||
**What:**
|
||||
Define the update surface that later plans will use for rematerialization.
|
||||
|
||||
**How:**
|
||||
- Add explicit APIs for:
|
||||
- refresh line
|
||||
- refresh column
|
||||
- refresh area/region
|
||||
- invalidate whole cache on scene swap
|
||||
- Ensure corner refresh can use region updates without duplicate work on already-present tiles.
|
||||
|
||||
**File(s):**
|
||||
- New cache module(s)
|
||||
- Any helper modules shared with resolver integration
|
||||
|
||||
### Step 4 - Implement scene-to-cache materialization helpers
|
||||
|
||||
**What:**
|
||||
Add helpers that copy canonical scene data into cache entries.
|
||||
|
||||
**How:**
|
||||
- Build helpers that read canonical `SceneBank` / `SceneLayer` / `TileMap` data and populate cache entries.
|
||||
- Keep the helpers unaware of camera policy; they should only perform requested materialization work.
|
||||
- Ensure per-layer movement factors remain metadata of the scene layer, not cache-only state.
|
||||
|
||||
**File(s):**
|
||||
- New cache/materialization helper module(s)
|
||||
|
||||
### Step 5 - Add focused tests for cache structure and update rules
|
||||
|
||||
**What:**
|
||||
Protect the cache shape and update operations before wiring the resolver.
|
||||
|
||||
**How:**
|
||||
- Add tests for:
|
||||
- ringbuffer wrap behavior
|
||||
- line refresh
|
||||
- column refresh
|
||||
- region refresh
|
||||
- scene-swap invalidation
|
||||
- no duplicate reload for corner-style region updates
|
||||
|
||||
**File(s):**
|
||||
- Tests colocated with cache modules
|
||||
|
||||
## Test Requirements
|
||||
|
||||
### Unit Tests
|
||||
- Each cache layer maintains ringbuffer invariants under wrap.
|
||||
- Cache entry fields match the expected derived values from canonical scene tiles.
|
||||
- Line/column/region refresh APIs only rewrite the requested area.
|
||||
|
||||
### Integration Tests
|
||||
- Materialization from `SceneBank` into `SceneViewportCache` succeeds across all four layers.
|
||||
|
||||
### Manual Verification
|
||||
- Inspect debug output or temporary probes to confirm cache updates do not expose ringbuffer details outside the cache boundary.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `SceneViewportCache` exists as a four-layer aggregate.
|
||||
- [ ] Each layer cache uses internal ringbuffer storage.
|
||||
- [ ] Cache entries store the lightweight derived raster fields defined by the decision.
|
||||
- [ ] Explicit APIs exist for line, column, area/region, and full invalidation.
|
||||
- [ ] Tests cover wrap and non-duplicative corner updates.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Depends on `PLN-0011` for canonical `SceneBank` / `SceneLayer` / `TileMap` types and the fixed four-layer shape.
|
||||
- Source decision: `DEC-0013`
|
||||
|
||||
## Risks
|
||||
|
||||
- Over-designing cache entry shape can accidentally turn the cache into a second heavy scene representation.
|
||||
- Ringbuffer implementation bugs can stay hidden until resolver integration if tests are too weak.
|
||||
- Region refresh semantics can become ambiguous if API boundaries are not explicit early.
|
||||
@ -0,0 +1,161 @@
|
||||
---
|
||||
id: PLN-0013
|
||||
ticket: scene-bank-and-viewport-cache-refactor
|
||||
title: Plan - Scene Viewport Resolver and Rematerialization
|
||||
status: accepted
|
||||
created: 2026-04-13
|
||||
completed:
|
||||
tags: [gfx, tilemap, runtime, render]
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Implement `SceneViewportResolver` as the owner of master anchor, per-layer anchors, drift, hysteresis, clamp, cache update decisions, and per-layer copy instrumentation metadata.
|
||||
|
||||
## Background
|
||||
|
||||
`DEC-0013` makes the resolver the owner of movement policy between camera input and viewport cache updates. `PLN-0011` already fixed the canonical source as `SceneBank` and renamed per-layer movement metadata to `motion_factor`. This plan isolates the decision logic before final renderer migration.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- Define `SceneViewportResolver`.
|
||||
- Implement master anchor and per-layer derived anchors.
|
||||
- Implement drift and hysteresis.
|
||||
- Implement clamp behavior.
|
||||
- Implement cache-update triggering and copy instrumentation metadata.
|
||||
|
||||
### Excluded
|
||||
- actual framebuffer copy execution
|
||||
- sprite/HUD/fade composition logic
|
||||
- broad renderer migration
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1 - Define resolver state and inputs
|
||||
|
||||
**What:**
|
||||
Create the resolver state model and its public input surface.
|
||||
|
||||
**How:**
|
||||
- Define resolver state to own:
|
||||
- master anchor
|
||||
- per-layer derived anchors
|
||||
- viewport dimensions
|
||||
- hysteresis thresholds
|
||||
- clamp-relevant scene bounds
|
||||
- Define the external entry point that accepts camera position input.
|
||||
|
||||
**File(s):**
|
||||
- New resolver module(s), likely under HAL or drivers depending on final ownership
|
||||
|
||||
### Step 2 - Implement master-to-layer totem derivation
|
||||
|
||||
**What:**
|
||||
Make layer motion derive from the master anchor using each layer’s `motion_factor`.
|
||||
|
||||
**How:**
|
||||
- Compute per-layer effective motion from the master anchor.
|
||||
- Preserve support for normal layers (`1.0`) and parallax/background-like layers (`!= 1.0`).
|
||||
- Keep the derivation explicit and testable per axis.
|
||||
|
||||
**File(s):**
|
||||
- Resolver module(s)
|
||||
- Any math/helper module(s) needed for factor handling
|
||||
|
||||
### Step 3 - Implement drift, hysteresis, and clamp
|
||||
|
||||
**What:**
|
||||
Translate camera motion into discrete anchor advancement safely.
|
||||
|
||||
**How:**
|
||||
- Implement drift calculation in pixel space against anchor centers in tile space.
|
||||
- Implement hysteresis with:
|
||||
- internal safe band
|
||||
- external trigger band
|
||||
- Implement clamp against scene bounds.
|
||||
- Ensure edge behavior is explicitly asymmetric when clamped.
|
||||
|
||||
**File(s):**
|
||||
- Resolver module(s)
|
||||
|
||||
### Step 4 - Connect resolver decisions to cache update requests
|
||||
|
||||
**What:**
|
||||
Turn resolver state changes into concrete cache update requests.
|
||||
|
||||
**How:**
|
||||
- Emit requests for:
|
||||
- line refresh
|
||||
- column refresh
|
||||
- area/region refresh for corner updates
|
||||
- Ensure no duplicate work is scheduled for already-covered cache content.
|
||||
- Keep the resolver in charge of “what to refresh,” not “how cache storage performs it.”
|
||||
|
||||
**File(s):**
|
||||
- Resolver module(s)
|
||||
- Shared request/command structs between resolver and cache
|
||||
|
||||
### Step 5 - Add copy instrumentation outputs for later renderer use
|
||||
|
||||
**What:**
|
||||
Make the resolver capable of describing which cache slice/region should be copied for each layer.
|
||||
|
||||
**How:**
|
||||
- Define a per-layer copy request/instrumentation type.
|
||||
- Include enough information for a later compositor to copy from cache into `back` without re-deciding viewport math.
|
||||
- Do not execute the copy here.
|
||||
|
||||
**File(s):**
|
||||
- Resolver module(s)
|
||||
- Shared copy-request types
|
||||
|
||||
### Step 6 - Add focused resolver tests
|
||||
|
||||
**What:**
|
||||
Protect resolver correctness before renderer integration.
|
||||
|
||||
**How:**
|
||||
- Add tests for:
|
||||
- master anchor updates
|
||||
- per-layer motion-factor derivation
|
||||
- hysteresis stability
|
||||
- repeated high-speed movement
|
||||
- clamp at scene edges
|
||||
- corner-trigger conversion into region refresh requests
|
||||
|
||||
**File(s):**
|
||||
- Tests colocated with resolver modules
|
||||
|
||||
## Test Requirements
|
||||
|
||||
### Unit Tests
|
||||
- Hysteresis prevents edge flick/thrash.
|
||||
- Anchor movement occurs discretely in tile steps.
|
||||
- Per-layer anchors follow the master according to `motion_factor`.
|
||||
- Clamp behavior is correct near scene edges.
|
||||
|
||||
### Integration Tests
|
||||
- Resolver outputs valid update requests consumable by `SceneViewportCache`.
|
||||
|
||||
### Manual Verification
|
||||
- Instrument logs or debug traces to confirm no unnecessary refresh churn during back-and-forth movement near edges.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `SceneViewportResolver` exists with the full state required by `DEC-0013`.
|
||||
- [ ] Master and per-layer anchors are derived correctly.
|
||||
- [ ] Hysteresis and clamp are implemented and tested.
|
||||
- [ ] Cache refresh requests are emitted by line, column, and region as required.
|
||||
- [ ] Copy instrumentation metadata is available for later renderer use.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Depends on `PLN-0011` and `PLN-0012`.
|
||||
- Source decision: `DEC-0013`
|
||||
|
||||
## Risks
|
||||
|
||||
- Resolver logic can become hard to reason about if movement, clamp, and copy instrumentation are not clearly separated.
|
||||
- Parallax factor derivation can introduce subtle off-by-one or drift mismatch issues per layer.
|
||||
- Region-update scheduling can duplicate work if X/Y corner movement is not normalized carefully.
|
||||
@ -0,0 +1,142 @@
|
||||
---
|
||||
id: PLN-0014
|
||||
ticket: scene-bank-and-viewport-cache-refactor
|
||||
title: Plan - Renderer Migration to Scene Viewport Cache
|
||||
status: accepted
|
||||
created: 2026-04-13
|
||||
completed:
|
||||
tags: [gfx, tilemap, runtime, render]
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Migrate world rendering from direct canonical `SceneBank` consumption to `SceneViewportCache` consumption while preserving the accepted observable composition order.
|
||||
|
||||
## Background
|
||||
|
||||
`DEC-0013` makes `SceneViewportCache` the immediate source of copy data for world blits and preserves the visible order of world layers, interleaved sprites, and HUD. `PLN-0011` already removed the old canonical `TileLayer` / `ScrollableTileLayer` model and cleaned the bridge surface accordingly. This plan focuses on the renderer migration itself.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- Remove direct world-layer map reads from the renderer hot path.
|
||||
- Make world-layer composition consume cache-backed copy requests.
|
||||
- Preserve sprite interleaving and HUD ordering.
|
||||
- Keep V1 destructive `back` composition acceptable.
|
||||
|
||||
### Excluded
|
||||
- scene domain model refactor
|
||||
- cache structural implementation
|
||||
- resolver movement logic
|
||||
- non-world rendering systems beyond required integration points
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1 - Identify and isolate direct canonical map consumption in the renderer
|
||||
|
||||
**What:**
|
||||
Locate current renderer paths that read canonical scene/layer maps directly.
|
||||
|
||||
**How:**
|
||||
- Replace any remaining direct `SceneBank` / `SceneLayer` / `TileMap` reads in [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs) with abstraction points prepared to accept cache-sourced copy requests.
|
||||
- Keep the migration incremental enough to preserve buildability.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs)
|
||||
|
||||
### Step 2 - Introduce world-layer copy paths driven by cache requests
|
||||
|
||||
**What:**
|
||||
Teach the renderer to copy world content from `SceneViewportCache`.
|
||||
|
||||
**How:**
|
||||
- Add renderer-facing entry points that consume per-layer copy instrumentation emitted by the resolver.
|
||||
- Ensure the renderer treats the cache as the immediate world source for blit operations.
|
||||
- Do not let the renderer re-own totem/drift/clamp policy.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs)
|
||||
- Supporting cache/resolver integration modules
|
||||
|
||||
### Step 3 - Preserve accepted composition order
|
||||
|
||||
**What:**
|
||||
Keep the visible ordering of layers, sprites, and HUD intact during migration.
|
||||
|
||||
**How:**
|
||||
- Preserve composition order:
|
||||
- world layer 0
|
||||
- sprites
|
||||
- world layer 1
|
||||
- sprites
|
||||
- world layer 2
|
||||
- sprites
|
||||
- world layer 3
|
||||
- sprites
|
||||
- HUD
|
||||
- Keep `HUD`, sprites, and fades outside the viewport-cache invalidation model.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs)
|
||||
|
||||
### Step 4 - Preserve destructive `back` composition for V1
|
||||
|
||||
**What:**
|
||||
Accept destructive composition while still benefiting from cache-backed world inputs.
|
||||
|
||||
**How:**
|
||||
- Keep the first migrated renderer implementation destructive in `back`.
|
||||
- Ensure the code path clearly separates:
|
||||
- canonical scene data
|
||||
- viewport cache data
|
||||
- final composition buffer
|
||||
- Avoid mixing “cache update” with “final buffer copy” concerns in the renderer.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs)
|
||||
|
||||
### Step 5 - Add renderer regression coverage
|
||||
|
||||
**What:**
|
||||
Protect the migrated composition order and world rendering behavior.
|
||||
|
||||
**How:**
|
||||
- Add tests or golden-style checks for:
|
||||
- world layers rendered from cache, not canonical maps
|
||||
- visible ordering of sprites between layers
|
||||
- HUD remaining on top
|
||||
- Add probes or assertions preventing accidental fallback to direct canonical map reads.
|
||||
|
||||
**File(s):**
|
||||
- Renderer tests in [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs) and adjacent test modules
|
||||
|
||||
## Test Requirements
|
||||
|
||||
### Unit Tests
|
||||
- Cache-driven world copy code produces the same visible layer ordering as the prior renderer.
|
||||
|
||||
### Integration Tests
|
||||
- Full `render_all()` path uses `SceneViewportCache` for world layers.
|
||||
- Sprites/HUD/fades remain correctly ordered relative to world layers.
|
||||
|
||||
### Manual Verification
|
||||
- Render representative scenes with multiple active layers and sprites to confirm no visible ordering regressions.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Renderer world composition no longer reads canonical `SceneBank` data directly in the hot path.
|
||||
- [ ] World layers are copied from `SceneViewportCache`.
|
||||
- [ ] Sprite interleaving and HUD ordering remain correct.
|
||||
- [ ] V1 destructive composition still works end-to-end.
|
||||
- [ ] Regression coverage protects the migrated path.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Depends on `PLN-0011`, `PLN-0012`, and `PLN-0013`.
|
||||
- Source decision: `DEC-0013`
|
||||
|
||||
## Risks
|
||||
|
||||
- Renderer migration can accidentally duplicate world-copy work if cache and compositor responsibilities blur.
|
||||
- Sprite ordering regressions are easy to introduce during world-layer refactoring.
|
||||
- Temporary fallback paths can linger unless tests explicitly block them.
|
||||
@ -0,0 +1,124 @@
|
||||
---
|
||||
id: PLN-0015
|
||||
ticket: scene-bank-and-viewport-cache-refactor
|
||||
title: Plan - API, Bank Integration, and Tests
|
||||
status: accepted
|
||||
created: 2026-04-13
|
||||
completed:
|
||||
tags: [gfx, tilemap, runtime, render]
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Finish the scene/viewport-cache migration by aligning exposed APIs, final integration surfaces, and test coverage across HAL, drivers, and system entry points.
|
||||
|
||||
## Background
|
||||
|
||||
After core types, cache, resolver, and renderer migration exist, the remaining work is to align the surrounding surfaces so the new architecture is usable as the runtime’s operational model. `PLN-0011` already removed the old canonical tile-layer APIs and introduced `BankType::SCENE` plus scene-aware bank slots. The binary `SCENE` payload contract and decoder are now isolated in `PLN-0016`.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- Update bridge APIs and exposed runtime surfaces.
|
||||
- Remove stale tile-layer-era interfaces.
|
||||
- Add cross-layer regression coverage.
|
||||
|
||||
### Excluded
|
||||
- additional architectural redesign
|
||||
- feature work beyond the accepted decision
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1 - Update HAL and bridge surfaces
|
||||
|
||||
**What:**
|
||||
Remove or replace public APIs still shaped around the old scrollable tile-layer model.
|
||||
|
||||
**How:**
|
||||
- Verify `GfxBridge` and adjacent bridge traits stay free of obsolete canonical layer ownership assumptions after cache/resolver integration.
|
||||
- Introduce scene/cache/resolver-oriented access only where required by runtime consumers.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-hal/src/gfx_bridge.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/gfx_bridge.rs)
|
||||
- Other bridge traits affected by scene model changes
|
||||
|
||||
### Step 2 - Align driver/hardware construction paths
|
||||
|
||||
**What:**
|
||||
Ensure hardware and driver initialization paths can construct and own the new scene/cache/resolver model.
|
||||
|
||||
**How:**
|
||||
- Update hardware bootstrap and driver ownership paths to instantiate the canonical scene and viewport-cache stack.
|
||||
- Remove any remaining ownership assumptions tied to the pre-`SceneBank` model.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-drivers/src/hardware.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/hardware.rs)
|
||||
- [crates/console/prometeu-drivers/src/gfx.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/gfx.rs)
|
||||
|
||||
### Step 3 - Remove stale type and API remnants
|
||||
|
||||
**What:**
|
||||
Clean out old interfaces that would leave the codebase in a dual-model state.
|
||||
|
||||
**How:**
|
||||
- Remove any remaining obsolete references to:
|
||||
- `TileLayer`
|
||||
- `ScrollableTileLayer`
|
||||
- direct old-layer scroll ownership APIs
|
||||
- transitional commented fallback paths left during migration
|
||||
- Keep only the canonical scene model and the viewport-cache/render pipeline required by the decision.
|
||||
|
||||
**File(s):**
|
||||
- HAL and driver modules touched by earlier plans
|
||||
|
||||
### Step 4 - Add full-stack regression coverage
|
||||
|
||||
**What:**
|
||||
Add the minimum test family needed to execute the migration safely one plan at a time.
|
||||
|
||||
**How:**
|
||||
- Add tests for:
|
||||
- canonical scene ownership
|
||||
- viewport-cache update behavior
|
||||
- resolver drift/hysteresis
|
||||
- renderer composition order
|
||||
- scene swap invalidation
|
||||
- absence of stale old-layer APIs
|
||||
- Add at least one integration path that exercises:
|
||||
- scene load
|
||||
- cache population
|
||||
- resolver update
|
||||
- renderer world composition
|
||||
|
||||
**File(s):**
|
||||
- Test modules across HAL, drivers, and system crates as needed
|
||||
|
||||
## Test Requirements
|
||||
|
||||
### Unit Tests
|
||||
- Public/runtime-facing APIs no longer depend on `ScrollableTileLayer`.
|
||||
- Cache, resolver, and renderer modules remain individually covered.
|
||||
|
||||
### Integration Tests
|
||||
- End-to-end scene load -> cache update -> renderer composition path succeeds.
|
||||
- Scene swap invalidates cache and repopulates correctly.
|
||||
|
||||
### Manual Verification
|
||||
- Build the runtime and exercise representative world scenes to confirm no stale assumptions remain in construction or render flow.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bridge and runtime-facing APIs align with the new scene/cache model.
|
||||
- [ ] Hardware/driver construction paths instantiate the new architecture correctly.
|
||||
- [ ] Stale old-layer APIs are removed.
|
||||
- [ ] Full-stack regression coverage exists for the migration.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Depends on `PLN-0011`, `PLN-0012`, `PLN-0013`, `PLN-0014`, and `PLN-0016`.
|
||||
- Source decision: `DEC-0013`
|
||||
|
||||
## Risks
|
||||
|
||||
- API cleanup is where hidden dependencies on the old model are most likely to surface.
|
||||
- If stale APIs are not removed aggressively, the codebase can get stuck in a fragile dual-model transition.
|
||||
@ -0,0 +1,164 @@
|
||||
---
|
||||
id: PLN-0016
|
||||
ticket: scene-bank-and-viewport-cache-refactor
|
||||
title: Plan - Scene Binary Payload Format and Decoder
|
||||
status: accepted
|
||||
created: 2026-04-13
|
||||
completed:
|
||||
tags: [asset, runtime, scene, codec, binary-format]
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Define the canonical binary payload contract for `SCENE` assets and implement the runtime decoder needed to load `SceneBank` content without JSON-based scene payloads.
|
||||
|
||||
## Background
|
||||
|
||||
`DEC-0013` established `SceneBank` as the canonical loaded scene aggregate. `PLN-0011` introduced `BankType::SCENE` and the scene-aware bank slot model, but intentionally left `SCENE` payload decoding open. A temporary `todo!()` now blocks scene payload materialization in the asset manager until the binary contract is closed. This plan isolates that format and decoder work before the final integration pass.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- Define the binary on-disk/on-wire payload format for `SCENE`.
|
||||
- Define the minimum metadata contract needed in `AssetEntry` for `SCENE`.
|
||||
- Implement the runtime decoder from payload bytes into `SceneBank`.
|
||||
- Add format and decode tests for valid and invalid scene payloads.
|
||||
- Re-enable scene asset load/preload coverage once the decoder exists.
|
||||
|
||||
### Excluded
|
||||
- `SceneViewportCache`
|
||||
- `SceneViewportResolver`
|
||||
- renderer migration
|
||||
- cartridge authoring tooling beyond what is strictly needed to encode test fixtures
|
||||
- reopening canonical scene/runtime type design
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1 - Define the binary `SCENE` payload contract
|
||||
|
||||
**What:**
|
||||
Close the binary layout for serialized `SceneBank` assets.
|
||||
|
||||
**How:**
|
||||
- Define a versioned binary format with:
|
||||
- file/payload prelude or version marker
|
||||
- fixed four-layer scene layout
|
||||
- per-layer metadata block
|
||||
- per-layer tilemap dimensions
|
||||
- serialized tile records
|
||||
- Keep the format aligned with the canonical runtime shape from `PLN-0011`:
|
||||
- `SceneBank`
|
||||
- `[SceneLayer; 4]`
|
||||
- `motion_factor`
|
||||
- `TileMap`
|
||||
- `Tile`
|
||||
- Document field sizes, endianness, ordering, and validation rules.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-hal/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/asset.rs)
|
||||
- [crates/console/prometeu-hal/src/cartridge_loader.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/cartridge_loader.rs)
|
||||
- Additional shared asset-format module(s) if needed
|
||||
|
||||
### Step 2 - Define `SCENE` metadata requirements in `AssetEntry`
|
||||
|
||||
**What:**
|
||||
Decide what belongs in `AssetEntry.metadata` versus the binary payload itself.
|
||||
|
||||
**How:**
|
||||
- Keep metadata minimal and stable.
|
||||
- Add only fields that materially help validation or loader routing.
|
||||
- Avoid duplicating large structural scene information in both metadata and payload.
|
||||
- Add typed metadata helpers in HAL if required for scene assets.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-hal/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/asset.rs)
|
||||
|
||||
### Step 3 - Implement the runtime `SCENE` decoder
|
||||
|
||||
**What:**
|
||||
Replace the current `todo!()` loader path with a real binary decoder.
|
||||
|
||||
**How:**
|
||||
- Implement `perform_load_scene_bank(...)` using the accepted binary contract.
|
||||
- Decode from slice/reader into canonical runtime objects:
|
||||
- `SceneBank`
|
||||
- `SceneLayer`
|
||||
- `TileMap`
|
||||
- `Tile`
|
||||
- Add explicit validation for:
|
||||
- invalid version
|
||||
- short payload
|
||||
- invalid tile-size values
|
||||
- layer count mismatch
|
||||
- tile count mismatch
|
||||
- numeric overflow / malformed dimensions
|
||||
- Keep decode logic self-contained and free of viewport/cache behavior.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-drivers/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/asset.rs)
|
||||
- Supporting HAL/shared modules if decoder helpers are factored out
|
||||
|
||||
### Step 4 - Re-enable scene asset loading paths and tests
|
||||
|
||||
**What:**
|
||||
Turn scene asset loading back on in the asset manager and restore coverage.
|
||||
|
||||
**How:**
|
||||
- Re-enable the `SCENE` load/preload path currently blocked by the `todo!()`.
|
||||
- Add tests for:
|
||||
- scene payload decode success
|
||||
- scene asset load
|
||||
- scene preload on initialization
|
||||
- malformed scene payload rejection
|
||||
- Ensure glyph/sound behavior remains unchanged.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-drivers/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-drivers/src/asset.rs)
|
||||
|
||||
### Step 5 - Confirm cartridge-loader and asset-table compatibility
|
||||
|
||||
**What:**
|
||||
Ensure the broader cartridge/asset pipeline accepts the finalized `SCENE` contract cleanly.
|
||||
|
||||
**How:**
|
||||
- Verify `AssetEntry` and cartridge-loading paths accept the new scene metadata contract.
|
||||
- Add targeted tests for asset-table parsing or preload validation if needed.
|
||||
- Keep this step scoped to compatibility with the finalized binary scene payload, not new tooling features.
|
||||
|
||||
**File(s):**
|
||||
- [crates/console/prometeu-hal/src/cartridge_loader.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/cartridge_loader.rs)
|
||||
- [crates/console/prometeu-hal/src/asset.rs](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/runtime/crates/console/prometeu-hal/src/asset.rs)
|
||||
|
||||
## Test Requirements
|
||||
|
||||
### Unit Tests
|
||||
- Valid binary scene payloads decode into the expected `SceneBank`.
|
||||
- Invalid payloads fail with explicit decoder errors.
|
||||
- Per-layer metadata and tile contents survive round-trip fixture decode.
|
||||
|
||||
### Integration Tests
|
||||
- `SCENE` assets can be loaded and preloaded through `AssetManager`.
|
||||
- Scene bank slot installation works without breaking glyph/sound behavior.
|
||||
|
||||
### Manual Verification
|
||||
- Inspect one representative binary scene fixture and confirm the decoded `SceneBank` matches the expected four-layer canonical shape.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] The binary `SCENE` payload format is explicitly defined and versioned.
|
||||
- [ ] `AssetEntry` requirements for `SCENE` are documented and implemented.
|
||||
- [ ] `perform_load_scene_bank(...)` is implemented without JSON payload parsing.
|
||||
- [ ] Scene load/preload tests pass against the finalized binary decoder.
|
||||
- [ ] Glyph and sound asset paths remain unaffected.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Depends on `PLN-0011`.
|
||||
- Should complete before the final integration/cleanup pass in `PLN-0015`.
|
||||
- Source decision: `DEC-0013`
|
||||
|
||||
## Risks
|
||||
|
||||
- Overloading `AssetEntry.metadata` can duplicate scene structure and make the contract brittle.
|
||||
- A weak binary format definition can create hidden compatibility problems for future scene growth.
|
||||
- Decoder validation gaps can surface later as corrupted scene state instead of explicit asset-load failures.
|
||||
Loading…
x
Reference in New Issue
Block a user