diff --git a/assets/sprite-packs/ferris/manifest.json b/assets/sprite-packs/ferris/manifest.json new file mode 100644 index 0000000..8618138 --- /dev/null +++ b/assets/sprite-packs/ferris/manifest.json @@ -0,0 +1,35 @@ +{ + "id": "ferris", + "version": "1", + "image": "sprite.png", + "frame_width": 512, + "frame_height": 512, + "animations": [ + { + "name": "idle", + "fps": 6, + "frames": [0, 1] + }, + { + "name": "active", + "fps": 10, + "frames": [1, 0] + }, + { + "name": "success", + "fps": 10, + "frames": [0, 1, 0], + "one_shot": true + }, + { + "name": "error", + "fps": 8, + "frames": [1, 0, 1], + "one_shot": true + } + ], + "anchor": { + "x": 0.5, + "y": 1.0 + } +} diff --git a/assets/sprite-packs/ferris/sprite.png b/assets/sprite-packs/ferris/sprite.png new file mode 100644 index 0000000..e235545 Binary files /dev/null and b/assets/sprite-packs/ferris/sprite.png differ diff --git a/crates/sprimo-tauri/src/main.rs b/crates/sprimo-tauri/src/main.rs index b9ef7cb..f1405d0 100644 --- a/crates/sprimo-tauri/src/main.rs +++ b/crates/sprimo-tauri/src/main.rs @@ -1,24 +1,30 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use sprimo_platform::{PlatformAdapter, create_adapter}; use sprimo_protocol::v1::FrontendCommand; -use sprimo_platform::{create_adapter, PlatformAdapter}; use sprimo_protocol::v1::{FrontendState, FrontendStateSnapshot}; use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError}; -use sprimo_sprite::{load_manifest, resolve_pack_path, AnimationDefinition}; +use sprimo_sprite::{AnimationDefinition, SpritePackManifest, load_manifest, resolve_pack_path}; +use std::fs; use std::sync::Arc; use tauri::menu::{CheckMenuItem, Menu, MenuItem}; use tauri::tray::TrayIconBuilder; -use tauri::{AppHandle, Emitter, Manager, Wry}; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, Wry}; use thiserror::Error; use tokio::runtime::Runtime; use tracing::warn; const APP_NAME: &str = "sprimo"; const DEFAULT_PACK: &str = "default"; +const SPRITE_PNG_FILE: &str = "sprite.png"; +const SPRITE_GRID_COLUMNS: u32 = 8; +const SPRITE_GRID_ROWS: u32 = 7; const MAIN_WINDOW_LABEL: &str = "main"; +const SETTINGS_WINDOW_LABEL: &str = "settings"; const TRAY_ID: &str = "main"; +const MENU_ID_SETTINGS: &str = "settings"; const MENU_ID_TOGGLE_VISIBILITY: &str = "toggle_visibility"; const MENU_ID_TOGGLE_ALWAYS_ON_TOP: &str = "toggle_always_on_top"; const MENU_ID_TOGGLE_DEBUG_OVERLAY: &str = "toggle_debug_overlay"; @@ -50,6 +56,12 @@ struct UiSpritePack { anchor: UiAnchor, } +#[derive(Debug, Clone, Copy)] +struct AtlasGeometry { + frame_width: u32, + frame_height: u32, +} + #[derive(Debug, Clone, serde::Serialize)] struct UiSnapshot { state: String, @@ -58,6 +70,22 @@ struct UiSnapshot { y: f32, scale: f32, active_sprite_pack: String, + visible: bool, + always_on_top: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct UiSettingsSnapshot { + active_sprite_pack: String, + scale: f32, + visible: bool, + always_on_top: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct UiSpritePackOption { + id: String, + pack_id_or_path: String, } #[derive(Debug, Error)] @@ -74,6 +102,7 @@ enum AppError { struct AppState { runtime_core: Arc, runtime: Arc, + tray_state: Arc>>, } #[derive(Clone)] @@ -96,6 +125,7 @@ fn current_state(state: tauri::State<'_, AppState>) -> Result) -> Result { + let root = sprite_pack_root(state.runtime_core.as_ref())?; let config = state .runtime_core .config() @@ -103,11 +133,6 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result path, @@ -117,6 +142,8 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result) -> Result) -> Result) -> Result { + let snapshot = state + .runtime_core + .snapshot() + .read() + .map_err(|_| "snapshot lock poisoned".to_string())? + .clone(); + Ok(UiSettingsSnapshot { + active_sprite_pack: snapshot.active_sprite_pack, + scale: snapshot.scale, + visible: snapshot.flags.visible, + always_on_top: snapshot.flags.always_on_top, + }) +} + +#[tauri::command] +fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result, String> { + let root = sprite_pack_root(state.runtime_core.as_ref())?; + let mut packs = Vec::new(); + + let entries = fs::read_dir(root).map_err(|err| err.to_string())?; + for entry in entries { + let entry = entry.map_err(|err| err.to_string())?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + if load_manifest(&path).is_err() { + continue; + } + let Some(dir_name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + packs.push(UiSpritePackOption { + id: dir_name.to_string(), + pack_id_or_path: dir_name.to_string(), + }); + } + packs.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(packs) +} + +#[tauri::command] +fn set_sprite_pack( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + pack_id_or_path: String, +) -> Result { + let root = sprite_pack_root(state.runtime_core.as_ref())?; + let pack_path = resolve_pack_path(&root, &pack_id_or_path).map_err(|err| err.to_string())?; + let manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?; + let image_path = pack_path.join(&manifest.image); + let image_bytes = fs::read(&image_path).map_err(|err| err.to_string())?; + let _geometry = atlas_geometry_for_manifest(&manifest, &image_bytes)?; + validate_animation_frames(&manifest)?; + + state + .runtime_core + .apply_command(&FrontendCommand::SetSpritePack { pack_id_or_path }) + .map_err(|err| err.to_string())?; + emit_ui_refresh(state.inner(), &app_handle) +} + +#[tauri::command] +fn set_scale( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + scale: f32, +) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("scale must be a positive finite number".to_string()); + } + state + .runtime_core + .apply_command(&FrontendCommand::SetTransform { + x: None, + y: None, + anchor: None, + scale: Some(scale), + opacity: None, + }) + .map_err(|err| err.to_string())?; + emit_ui_refresh(state.inner(), &app_handle) +} + +#[tauri::command] +fn set_visibility( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + visible: bool, +) -> Result { + state + .runtime_core + .apply_command(&FrontendCommand::SetFlags { + click_through: None, + always_on_top: None, + visible: Some(visible), + }) + .map_err(|err| err.to_string())?; + if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) { + if visible { + window.show().map_err(|err| err.to_string())?; + window.set_focus().map_err(|err| err.to_string())?; + } else { + window.hide().map_err(|err| err.to_string())?; + } + } + emit_ui_refresh(state.inner(), &app_handle) +} + +#[tauri::command] +fn set_always_on_top( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + always_on_top: bool, +) -> Result { + state + .runtime_core + .apply_command(&FrontendCommand::SetFlags { + click_through: None, + always_on_top: Some(always_on_top), + visible: None, + }) + .map_err(|err| err.to_string())?; + if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) { + window + .set_always_on_top(always_on_top) + .map_err(|err| err.to_string())?; + } + emit_ui_refresh(state.inner(), &app_handle) +} + #[tauri::command] fn set_debug_overlay_visible( app_handle: tauri::AppHandle, @@ -158,6 +314,11 @@ fn set_debug_overlay_visible( .set_frontend_debug_overlay_visible(visible) .map_err(|err| err.to_string())?; let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), &app_handle); + if let Ok(guard) = state.tray_state.lock() { + if let Some(tray_state) = guard.as_ref() { + let _ = refresh_tray_menu_state(state.runtime_core.as_ref(), tray_state); + } + } Ok(visible) } @@ -176,6 +337,7 @@ fn main() -> Result<(), AppError> { let state = AppState { runtime_core: Arc::clone(&runtime_core), runtime: Arc::clone(&runtime), + tray_state: Arc::new(std::sync::Mutex::new(None)), }; tauri::Builder::default() @@ -185,15 +347,25 @@ fn main() -> Result<(), AppError> { current_state, load_active_sprite_pack, debug_overlay_visible, - set_debug_overlay_visible + set_debug_overlay_visible, + settings_snapshot, + list_sprite_packs, + set_sprite_pack, + set_scale, + set_visibility, + set_always_on_top ]) .setup(|app| { let app_state: tauri::State<'_, AppState> = app.state(); let runtime_core = Arc::clone(&app_state.runtime_core); let runtime = Arc::clone(&app_state.runtime); + let tray_state_holder = Arc::clone(&app_state.tray_state); let app_handle = app.handle().clone(); let tray_state = setup_tray(&app_handle, &runtime_core)?; + if let Ok(mut guard) = tray_state_holder.lock() { + *guard = Some(tray_state.clone()); + } if let Ok(snapshot) = runtime_core.snapshot().read() { let _ = app_handle.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot)); } @@ -251,10 +423,8 @@ fn main() -> Result<(), AppError> { }; if runtime_core.apply_command(&command).is_ok() { if let Ok(snapshot) = runtime_core.snapshot().read() { - let _ = app_handle.emit( - EVENT_RUNTIME_SNAPSHOT, - to_ui_snapshot(&snapshot), - ); + let _ = app_handle + .emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot)); } let _ = refresh_tray_menu_state( runtime_core.as_ref(), @@ -295,9 +465,46 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot { y: snapshot.y, scale: snapshot.scale, active_sprite_pack: snapshot.active_sprite_pack.clone(), + visible: snapshot.flags.visible, + always_on_top: snapshot.flags.always_on_top, } } +fn sprite_pack_root(runtime_core: &RuntimeCore) -> Result { + let sprite_packs_dir = runtime_core + .config() + .read() + .map_err(|_| "config lock poisoned".to_string())? + .sprite + .sprite_packs_dir + .clone(); + let root = std::env::current_dir() + .map_err(|err| err.to_string())? + .join("assets") + .join(sprite_packs_dir); + Ok(root) +} + +fn emit_ui_refresh(state: &AppState, app_handle: &AppHandle) -> Result { + let snapshot = state + .runtime_core + .snapshot() + .read() + .map_err(|_| "snapshot lock poisoned".to_string())? + .clone(); + let ui_snapshot = to_ui_snapshot(&snapshot); + app_handle + .emit(EVENT_RUNTIME_SNAPSHOT, ui_snapshot.clone()) + .map_err(|err| err.to_string())?; + let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), app_handle); + if let Ok(guard) = state.tray_state.lock() { + if let Some(tray_state) = guard.as_ref() { + let _ = refresh_tray_menu_state(state.runtime_core.as_ref(), tray_state); + } + } + Ok(ui_snapshot) +} + fn setup_tray( app_handle: &AppHandle, runtime_core: &RuntimeCore, @@ -311,6 +518,7 @@ fn setup_tray( .frontend_debug_overlay_visible() .map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?; + let settings = MenuItem::with_id(app_handle, MENU_ID_SETTINGS, "Settings", true, None::<&str>)?; let toggle_visibility = MenuItem::with_id( app_handle, MENU_ID_TOGGLE_VISIBILITY, @@ -338,6 +546,7 @@ fn setup_tray( let menu = Menu::with_items( app_handle, &[ + &settings, &toggle_visibility, &toggle_always_on_top, &toggle_debug_overlay, @@ -368,6 +577,10 @@ fn handle_menu_event( menu_id: &str, ) -> Result<(), String> { match menu_id { + MENU_ID_SETTINGS => { + open_settings_window(app_handle).map_err(|err| err.to_string())?; + return Ok(()); + } MENU_ID_TOGGLE_VISIBILITY => { let current = runtime_core .snapshot() @@ -408,7 +621,9 @@ fn handle_menu_event( }) .map_err(|err| err.to_string())?; if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) { - window.set_always_on_top(next).map_err(|err| err.to_string())?; + window + .set_always_on_top(next) + .map_err(|err| err.to_string())?; } } MENU_ID_TOGGLE_DEBUG_OVERLAY => { @@ -419,7 +634,8 @@ fn handle_menu_event( runtime_core .set_frontend_debug_overlay_visible(next) .map_err(|err| err.to_string())?; - emit_debug_overlay_visibility(runtime_core, app_handle).map_err(|err| err.to_string())?; + emit_debug_overlay_visibility(runtime_core, app_handle) + .map_err(|err| err.to_string())?; } MENU_ID_QUIT => { persist_current_ui_flags(runtime_core)?; @@ -436,6 +652,30 @@ fn handle_menu_event( Ok(()) } +fn open_settings_window(app_handle: &AppHandle) -> Result<(), tauri::Error> { + if let Some(window) = app_handle.get_webview_window(SETTINGS_WINDOW_LABEL) { + window.show()?; + window.set_focus()?; + return Ok(()); + } + + let window = WebviewWindowBuilder::new( + app_handle, + SETTINGS_WINDOW_LABEL, + WebviewUrl::App("index.html".into()), + ) + .title("sprimo settings") + .inner_size(420.0, 520.0) + .resizable(true) + .decorations(true) + .transparent(false) + .always_on_top(false) + .visible(true) + .build()?; + window.set_focus()?; + Ok(()) +} + fn persist_current_ui_flags(runtime_core: &RuntimeCore) -> Result<(), String> { let snapshot = runtime_core .snapshot() @@ -496,11 +736,7 @@ fn refresh_tray_menu_state( } fn visibility_menu_title(visible: bool) -> &'static str { - if visible { - "Hide" - } else { - "Show" - } + if visible { "Hide" } else { "Show" } } fn state_name(value: FrontendState) -> &'static str { @@ -522,3 +758,95 @@ fn to_ui_clip(value: AnimationDefinition) -> UiAnimationClip { one_shot: value.one_shot.unwrap_or(false), } } + +fn atlas_geometry_for_manifest( + manifest: &SpritePackManifest, + image_bytes: &[u8], +) -> Result { + let (image_width, image_height) = decode_png_dimensions(image_bytes)?; + if image_width == 0 || image_height == 0 { + return Err("atlas image dimensions must be non-zero".to_string()); + } + + if manifest.image.eq_ignore_ascii_case(SPRITE_PNG_FILE) { + let frame_width = image_width / SPRITE_GRID_COLUMNS; + let frame_height = image_height / SPRITE_GRID_ROWS; + if frame_width == 0 || frame_height == 0 { + return Err(format!( + "sprite atlas too small for {}x{} grid: {}x{}", + SPRITE_GRID_COLUMNS, SPRITE_GRID_ROWS, image_width, image_height + )); + } + return Ok(AtlasGeometry { + frame_width, + frame_height, + }); + } + + if manifest.frame_width == 0 || manifest.frame_height == 0 { + return Err("manifest frame dimensions must be non-zero".to_string()); + } + Ok(AtlasGeometry { + frame_width: manifest.frame_width, + frame_height: manifest.frame_height, + }) +} + +fn validate_animation_frames(manifest: &SpritePackManifest) -> Result<(), String> { + let total_frames = if manifest.image.eq_ignore_ascii_case(SPRITE_PNG_FILE) { + SPRITE_GRID_COLUMNS + .checked_mul(SPRITE_GRID_ROWS) + .ok_or_else(|| "sprite grid frame count overflow".to_string())? + } else { + 0 + }; + if total_frames == 0 { + return Ok(()); + } + + for clip in &manifest.animations { + for &index in &clip.frames { + if index >= total_frames { + return Err(format!( + "animation '{}' references frame {} but max frame index is {}", + clip.name, + index, + total_frames.saturating_sub(1) + )); + } + } + } + + Ok(()) +} + +fn decode_png_dimensions(image_bytes: &[u8]) -> Result<(u32, u32), String> { + const PNG_SIGNATURE_LEN: usize = 8; + const PNG_IHDR_TOTAL_LEN: usize = 33; + const IHDR_TYPE_OFFSET: usize = 12; + const IHDR_DATA_OFFSET: usize = 16; + const IHDR_WIDTH_OFFSET: usize = 16; + const IHDR_HEIGHT_OFFSET: usize = 20; + + if image_bytes.len() < PNG_IHDR_TOTAL_LEN { + return Err("atlas image is too small to be a valid PNG".to_string()); + } + let expected_signature: [u8; PNG_SIGNATURE_LEN] = [137, 80, 78, 71, 13, 10, 26, 10]; + if image_bytes[..PNG_SIGNATURE_LEN] != expected_signature { + return Err("atlas image must be PNG format".to_string()); + } + if &image_bytes[IHDR_TYPE_OFFSET..IHDR_DATA_OFFSET] != b"IHDR" { + return Err("atlas PNG missing IHDR chunk".to_string()); + } + let width = u32::from_be_bytes( + image_bytes[IHDR_WIDTH_OFFSET..IHDR_WIDTH_OFFSET + 4] + .try_into() + .map_err(|_| "failed to decode PNG width".to_string())?, + ); + let height = u32::from_be_bytes( + image_bytes[IHDR_HEIGHT_OFFSET..IHDR_HEIGHT_OFFSET + 4] + .try_into() + .map_err(|_| "failed to decode PNG height".to_string())?, + ); + Ok((width, height)) +} diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 099c1b5..3b2ca5b 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -19,12 +19,12 @@ Date: 2026-02-12 | Random backend API tester | Implemented | `scripts/random_backend_tester.py` with `just random-backend-test` and strict variant | | QA/documentation workflow | Implemented | `docs/QA_WORKFLOW.md`, issue/evidence templates, and `scripts/qa_validate.py` with `just qa-validate` | | Shared runtime core | Implemented | `sprimo-runtime-core` now backs both Tauri and Bevy startup, snapshot/config ownership, and API wiring | -| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale auto-fit, persisted debug-overlay toggle, and Windows-first tray/menu MVP are implemented | +| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale auto-fit, pop-out settings window (character/scale/visibility/always-on-top), persisted debug-overlay toggle, and Windows-first tray/menu MVP are implemented | | Tauri runtime testing workflow | Implemented | `docs/TAURI_RUNTIME_TESTING.md` defines strict workspace testing; packaged mode pending packaging support | ## Next Major Gaps -1. Tray/menu controls are still not implemented. +1. Tauri tray/menu behavior still needs Linux/macOS parity validation beyond Windows-first implementation. 2. Linux/macOS overlay behavior remains best-effort with no-op platform adapter. 3. `/v1/state` diagnostics are minimal; error history is not persisted beyond latest runtime error. 4. Issue 1 runtime after-fix screenshot evidence is still pending before closure. diff --git a/docs/RELEASE_TESTING.md b/docs/RELEASE_TESTING.md index 00b9d34..51781ca 100644 --- a/docs/RELEASE_TESTING.md +++ b/docs/RELEASE_TESTING.md @@ -125,6 +125,15 @@ Bevy window-management verification workflow: `docs/BEVY_WINDOW_VERIFICATION.md` 9. Verify scale-fit behavior in tauri runtime: - send `SetTransform.scale` values above `1.0` - confirm full sprite remains visible and window auto-resizes without top clipping +10. Verify pop-out settings window behavior: +- open via tray `Settings` +- switch character and confirm immediate renderer reload + persistence after restart +- change scale slider and confirm runtime resize + persistence +- toggle `Visible` and `Always on top` and confirm both runtime behavior + persistence +11. Verify packaged tauri reload stability: +- run repeated character switch cycles (`default` <-> `ferris`) and move scale slider each cycle +- ensure no runtime frontend exception is shown (debug overlay/console) +- ensure no visible magenta fringe remains around sprite edges after chroma-key conversion ### Packaged Mode (Required Once Tauri Packaging Exists) diff --git a/docs/SPRITE_PACK_SCHEMA.md b/docs/SPRITE_PACK_SCHEMA.md index ab20e68..6d4b72a 100644 --- a/docs/SPRITE_PACK_SCHEMA.md +++ b/docs/SPRITE_PACK_SCHEMA.md @@ -64,3 +64,14 @@ Path: `/manifest.json` - `rows = image_height / frame_height` - Image dimensions must be divisible by frame dimensions. - Every animation frame index must be `< columns * rows`. + +## Tauri `sprite.png` Override + +For `sprimo-tauri`, when manifest `image` is exactly `sprite.png`: + +- runtime uses a fixed grid topology of `8` columns x `7` rows. +- frame size is derived from actual image dimensions: + - `frame_width = image_width / 8` + - `frame_height = image_height / 7` +- manifest `frame_width` and `frame_height` are ignored for this case. +- animation frame indices are validated against the fixed grid frame count (`56`). diff --git a/docs/TAURI_FRONTEND_DESIGN.md b/docs/TAURI_FRONTEND_DESIGN.md index 0cf82bf..d1ed744 100644 --- a/docs/TAURI_FRONTEND_DESIGN.md +++ b/docs/TAURI_FRONTEND_DESIGN.md @@ -47,14 +47,27 @@ Frontend: - Tauri backend exposes: - `current_state` command (structured snapshot DTO) - `load_active_sprite_pack` command (manifest + atlas as base64 data URL) + - settings commands: + - `settings_snapshot` + - `list_sprite_packs` + - `set_sprite_pack` + - `set_scale` + - `set_visibility` + - `set_always_on_top` - `debug_overlay_visible` / `set_debug_overlay_visible` commands for persisted debug panel control - `runtime:snapshot` event after command application. - React/Vite frontend now renders sprite atlas frames with PixiJS and updates animation/scale from runtime snapshot events. +- For `sprite.png` packs in tauri runtime, frame size is now derived from atlas dimensions with a + fixed `8x7` grid topology to keep splitting stable across packaged asset resolutions. +- React/Vite frontend now supports two window modes: + - `main`: transparent overlay sprite renderer + - `settings`: pop-out settings window for character and window controls - Tauri window drag is implemented for undecorated mode: - left-mouse drag starts native window dragging - moved position is synced into runtime-core snapshot/config state. - Windows-first tray/menu MVP is implemented: + - `Settings` (opens/focuses pop-out settings window) - `Show/Hide` - `Always on top` toggle - `Debug overlay` toggle @@ -66,3 +79,4 @@ Frontend: 1. Extend tray/menu implementation beyond Windows-first MVP and close platform parity gaps. 2. Add frontend parity acceptance tests (Bevy vs Tauri state transitions). +3. Add sprite-pack previews/thumbnails in the settings window character selector. diff --git a/docs/TAURI_RUNTIME_TESTING.md b/docs/TAURI_RUNTIME_TESTING.md index 1c8d370..6d92fb2 100644 --- a/docs/TAURI_RUNTIME_TESTING.md +++ b/docs/TAURI_RUNTIME_TESTING.md @@ -100,8 +100,38 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before ` 6. Validate Tauri invoke/event behavior: - `current_state` output parsed successfully. - `load_active_sprite_pack` returns expected fields. +- `settings_snapshot` returns valid persisted settings payload. +- `list_sprite_packs` returns valid manifest-backed pack options. +- `set_sprite_pack` changes active pack and persists. +- `set_scale` updates scale and persists. +- `set_visibility` updates main window visibility and persists. +- `set_always_on_top` updates top-most behavior and persists. - `runtime:snapshot` event received on runtime command changes. - `debug_overlay_visible` and `set_debug_overlay_visible` invoke commands work and persist config. +7. Stress runtime reload stability: +- perform at least 10 cycles of character switch (`default` <-> `ferris`) with scale adjustments +- no frontend runtime exception (including `TypeError`) is allowed +- scaling behavior remains responsive after each pack switch +8. Chroma-key quality check: +- verify no visible magenta (`#FF00FF`) fringe remains around sprite edges in normal runtime view + +## Settings Window Checklist + +1. Open settings from tray `Settings` item. +2. Confirm repeated tray clicks focus existing settings window instead of creating duplicates. +3. Change character in settings and verify: +- active pack changes immediately in main overlay +- selection persists after restart +4. Change scale via slider and verify: +- runtime scale changes immediately +- main overlay auto-fits without clipping +- value persists after restart +5. Toggle `Visible` and verify: +- main overlay hide/show behavior +- persisted value survives restart +6. Toggle `Always on top` and verify: +- main window z-order behavior updates +- persisted value survives restart ## Evidence Requirements diff --git a/frontend/tauri-ui/src/main.tsx b/frontend/tauri-ui/src/main.tsx index 9de2732..79e0fb1 100644 --- a/frontend/tauri-ui/src/main.tsx +++ b/frontend/tauri-ui/src/main.tsx @@ -6,11 +6,39 @@ import { LogicalPosition, LogicalSize, getCurrentWindow } from "@tauri-apps/api/ import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet"; import "./styles.css"; +type UiSettingsSnapshot = { + active_sprite_pack: string; + scale: number; + visible: boolean; + always_on_top: boolean; +}; + +type UiSpritePackOption = { + id: string; + pack_id_or_path: string; +}; + const WINDOW_PADDING = 16; const MIN_WINDOW_SIZE = 64; const SIZE_EPSILON = 0.5; const SCALE_EPSILON = 0.0001; +async function invokeSetSpritePack(packIdOrPath: string): Promise { + return invoke("set_sprite_pack", { packIdOrPath }); +} + +async function invokeSetScale(scale: number): Promise { + return invoke("set_scale", { scale }); +} + +async function invokeSetVisibility(visible: boolean): Promise { + return invoke("set_visibility", { visible }); +} + +async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise { + return invoke("set_always_on_top", { alwaysOnTop }); +} + function fittedWindowSize( frameWidth: number, frameHeight: number, @@ -24,10 +52,7 @@ function fittedWindowSize( async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise { const window = getCurrentWindow(); - const [outerPosition, innerSize] = await Promise.all([ - window.outerPosition(), - window.innerSize() - ]); + const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]); const target = fittedWindowSize(pack.frame_width, pack.frame_height, scale); const widthChanged = Math.abs(target.width - innerSize.width) > SIZE_EPSILON; @@ -45,85 +70,133 @@ async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise(null); const [error, setError] = React.useState(null); const [debugOverlayVisible, setDebugOverlayVisible] = React.useState(false); const hostRef = React.useRef(null); const rendererRef = React.useRef(null); + const activePackRef = React.useRef(null); + const loadedPackKeyRef = React.useRef(null); const scaleFitRef = React.useRef(null); + const loadingPackRef = React.useRef(false); + const mountedRef = React.useRef(false); React.useEffect(() => { + mountedRef.current = true; let unlisten: null | (() => void) = null; - let mounted = true; - let activePack: UiSpritePack | null = null; + + const recreateRenderer = async ( + pack: UiSpritePack, + nextSnapshot: UiSnapshot + ): Promise => { + if (!mountedRef.current || hostRef.current === null) { + return false; + } + const previousRenderer = rendererRef.current; + const nextRenderer = await PixiPetRenderer.create(hostRef.current, pack, nextSnapshot); + rendererRef.current = nextRenderer; + activePackRef.current = pack; + loadedPackKeyRef.current = nextSnapshot.active_sprite_pack; + previousRenderer?.dispose(); + return true; + }; + + const tryFitWindow = async (pack: UiSpritePack, scale: number): Promise => { + try { + await fitWindowForScale(pack, scale); + scaleFitRef.current = scale; + return true; + } catch (err) { + if (mountedRef.current) { + setError(String(err)); + } + return false; + } + }; + + const processSnapshot = async (value: UiSnapshot): Promise => { + if (!mountedRef.current) { + return; + } + setSnapshot(value); + rendererRef.current?.applySnapshot(value); + + const activePack = activePackRef.current; + const needsReload = + activePack === null || loadedPackKeyRef.current !== value.active_sprite_pack; + if (needsReload && !loadingPackRef.current) { + loadingPackRef.current = true; + let reloaded = false; + try { + const pack = await invoke("load_active_sprite_pack"); + reloaded = await recreateRenderer(pack, value); + if (reloaded) { + await tryFitWindow(pack, value.scale); + if (mountedRef.current) { + setError(null); + } + } + } catch (err) { + if (mountedRef.current) { + console.error("reload_pack_failed", err); + setError(String(err)); + } + } finally { + loadingPackRef.current = false; + } + } + + if (activePackRef.current === null) { + return; + } + if ( + scaleFitRef.current !== null && + Math.abs(scaleFitRef.current - value.scale) < SCALE_EPSILON + ) { + return; + } + const fitApplied = await tryFitWindow(activePackRef.current, value.scale); + if (fitApplied && mountedRef.current) { + setError(null); + } + }; + Promise.all([ invoke("load_active_sprite_pack"), invoke("current_state"), invoke("debug_overlay_visible") ]) .then(async ([pack, initialSnapshot, showDebug]) => { - if (!mounted) { + if (!mountedRef.current) { return; } - activePack = pack; setDebugOverlayVisible(showDebug); - setSnapshot(initialSnapshot); - if (hostRef.current !== null) { - rendererRef.current = await PixiPetRenderer.create( - hostRef.current, - pack, - initialSnapshot - ); - } - scaleFitRef.current = initialSnapshot.scale; - await fitWindowForScale(pack, initialSnapshot.scale); + await recreateRenderer(pack, initialSnapshot); + await processSnapshot(initialSnapshot); - unlisten = await listen("runtime:snapshot", (event) => { - if (!mounted) { - return; - } - const value = event.payload; - setSnapshot(value); - rendererRef.current?.applySnapshot(value); - if (activePack === null) { - return; - } - if ( - scaleFitRef.current !== null && - Math.abs(scaleFitRef.current - value.scale) < SCALE_EPSILON - ) { - return; - } - scaleFitRef.current = value.scale; - void fitWindowForScale(activePack, value.scale).catch((err) => { - if (mounted) { - setError(String(err)); - } - }); + const unlistenSnapshot = await listen("runtime:snapshot", (event) => { + void processSnapshot(event.payload); }); - const unlistenDebug = await listen( - "runtime:debug-overlay-visible", - (event) => { - if (!mounted) { - return; - } - setDebugOverlayVisible(Boolean(event.payload)); + const unlistenDebug = await listen("runtime:debug-overlay-visible", (event) => { + if (!mountedRef.current) { + return; } - ); - const previousUnlisten = unlisten; + setDebugOverlayVisible(Boolean(event.payload)); + }); unlisten = () => { - previousUnlisten(); + unlistenSnapshot(); unlistenDebug(); }; }) .catch((err) => { - if (mounted) { + if (mountedRef.current) { setError(String(err)); } }); + return () => { - mounted = false; + mountedRef.current = false; if (unlisten !== null) { unlisten(); } @@ -168,7 +241,7 @@ function App(): JSX.Element { }, []); return ( -
+
{error !== null && !debugOverlayVisible ?

{error}

: null} {debugOverlayVisible ? ( @@ -200,8 +273,198 @@ function App(): JSX.Element { ); } +function SettingsWindow(): JSX.Element { + const [settings, setSettings] = React.useState(null); + const [packs, setPacks] = React.useState([]); + const [error, setError] = React.useState(null); + const [pending, setPending] = React.useState(false); + + React.useEffect(() => { + let unlisten: null | (() => void) = null; + let mounted = true; + Promise.all([ + invoke("settings_snapshot"), + invoke("list_sprite_packs") + ]) + .then(async ([snapshot, options]) => { + if (!mounted) { + return; + } + setSettings(snapshot); + setPacks(options); + unlisten = await listen("runtime:snapshot", (event) => { + if (!mounted) { + return; + } + const payload = event.payload; + setSettings((prev) => { + if (prev === null) { + return prev; + } + return { + active_sprite_pack: payload.active_sprite_pack, + scale: payload.scale, + visible: payload.visible, + always_on_top: payload.always_on_top + }; + }); + }); + }) + .catch((err) => { + if (mounted) { + setError(String(err)); + } + }); + return () => { + mounted = false; + if (unlisten !== null) { + unlisten(); + } + }; + }, []); + + const withPending = React.useCallback(async (fn: () => Promise): Promise => { + setPending(true); + setError(null); + try { + return await fn(); + } catch (err) { + setError(String(err)); + return null; + } finally { + setPending(false); + } + }, []); + + const onPackChange = React.useCallback( + async (event: React.ChangeEvent) => { + const value = event.target.value; + const next = await withPending(() => invokeSetSpritePack(value)); + if (next === null) { + return; + } + setSettings((prev) => + prev === null + ? prev + : { + ...prev, + active_sprite_pack: next.active_sprite_pack + } + ); + }, + [withPending] + ); + + const onScaleChange = React.useCallback( + async (event: React.ChangeEvent) => { + const value = Number(event.target.value); + if (!Number.isFinite(value)) { + return; + } + const next = await withPending(() => invokeSetScale(value)); + if (next === null) { + return; + } + setSettings((prev) => (prev === null ? prev : { ...prev, scale: next.scale })); + }, + [withPending] + ); + + const onVisibleChange = React.useCallback( + async (event: React.ChangeEvent) => { + const value = event.target.checked; + const next = await withPending(() => invokeSetVisibility(value)); + if (next === null) { + return; + } + setSettings((prev) => (prev === null ? prev : { ...prev, visible: value })); + }, + [withPending] + ); + + const onAlwaysOnTopChange = React.useCallback( + async (event: React.ChangeEvent) => { + const value = event.target.checked; + const next = await withPending(() => invokeSetAlwaysOnTop(value)); + if (next === null) { + return; + } + setSettings((prev) => (prev === null ? prev : { ...prev, always_on_top: value })); + }, + [withPending] + ); + + return ( +
+
+

Settings

+

Character and window controls

+ {error !== null ?

{error}

: null} + {settings === null ? ( +

Loading settings...

+ ) : ( + <> + + + + + + )} +
+
+ ); +} + +function AppRoot(): JSX.Element { + const windowLabel = getCurrentWindow().label; + if (windowLabel === "settings") { + return ; + } + return ; +} + ReactDOM.createRoot(document.getElementById("root")!).render( - + ); diff --git a/frontend/tauri-ui/src/renderer/pixi_pet.ts b/frontend/tauri-ui/src/renderer/pixi_pet.ts index c8c7fc0..cc115e9 100644 --- a/frontend/tauri-ui/src/renderer/pixi_pet.ts +++ b/frontend/tauri-ui/src/renderer/pixi_pet.ts @@ -28,6 +28,8 @@ export type UiSnapshot = { y: number; scale: number; active_sprite_pack: string; + visible: boolean; + always_on_top: boolean; }; type AnimationMap = Map; @@ -41,6 +43,7 @@ export class PixiPetRenderer { private frameCursor = 0; private frameElapsedMs = 0; private baseTexture: BaseTexture; + private disposed = false; private constructor( app: Application, @@ -67,8 +70,6 @@ export class PixiPetRenderer { antialias: true, resizeTo: container }); - container.replaceChildren(app.view as HTMLCanvasElement); - const baseTexture = await PixiPetRenderer.loadBaseTexture(pack.atlas_data_url); if (baseTexture.width <= 0 || baseTexture.height <= 0) { throw new Error("Atlas image loaded with invalid dimensions."); @@ -76,6 +77,7 @@ export class PixiPetRenderer { const sprite = new Sprite(); sprite.anchor.set(pack.anchor.x, pack.anchor.y); app.stage.addChild(sprite); + container.replaceChildren(app.view as HTMLCanvasElement); const renderer = new PixiPetRenderer(app, sprite, pack, baseTexture); renderer.layoutSprite(); @@ -102,13 +104,25 @@ export class PixiPetRenderer { const keyR = 0xff; const keyG = 0x00; const keyB = 0xff; - const tolerance = 28; + const hardTolerance = 22; + const softTolerance = 46; for (let i = 0; i < data.length; i += 4) { const dr = Math.abs(data[i] - keyR); const dg = Math.abs(data[i + 1] - keyG); const db = Math.abs(data[i + 2] - keyB); - if (dr <= tolerance && dg <= tolerance && db <= tolerance) { + const maxDistance = Math.max(dr, dg, db); + if (maxDistance <= hardTolerance) { data[i + 3] = 0; + continue; + } + if (maxDistance <= softTolerance) { + const alphaScale = + (maxDistance - hardTolerance) / (softTolerance - hardTolerance); + const suppress = 1 - alphaScale; + data[i + 3] = Math.round(data[i + 3] * alphaScale); + // Remove magenta spill from antialiased edges after alpha reduction. + data[i] = Math.round(data[i] * (1 - 0.4 * suppress)); + data[i + 2] = Math.round(data[i + 2] * (1 - 0.4 * suppress)); } } ctx.putImageData(frame, 0, 0); @@ -122,13 +136,10 @@ export class PixiPetRenderer { } dispose(): void { - this.app.ticker.stop(); - this.app.ticker.destroy(); - this.sprite.destroy({ - children: true, - texture: false, - baseTexture: false - }); + if (this.disposed) { + return; + } + this.disposed = true; this.app.destroy(true, { children: true, texture: false, @@ -137,6 +148,9 @@ export class PixiPetRenderer { } applySnapshot(snapshot: UiSnapshot): void { + if (this.disposed) { + return; + } const nextClip = this.resolveClip(snapshot.current_animation); if (nextClip.name !== this.currentClip.name) { this.currentClip = nextClip; @@ -150,6 +164,9 @@ export class PixiPetRenderer { private startTicker(): void { this.app.ticker.add((ticker) => { + if (this.disposed) { + return; + } this.layoutSprite(); const frameMs = 1000 / Math.max(this.currentClip.fps, 1); this.frameElapsedMs += ticker.deltaMS; diff --git a/frontend/tauri-ui/src/styles.css b/frontend/tauri-ui/src/styles.css index d52aff5..c8ee658 100644 --- a/frontend/tauri-ui/src/styles.css +++ b/frontend/tauri-ui/src/styles.css @@ -73,3 +73,76 @@ dd { color: #fee2e2; font-size: 12px; } + +.settings-root { + min-height: 100vh; + display: flex; + align-items: stretch; + justify-content: center; + background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%); + color: #0f172a; + user-select: none; +} + +.settings-card { + width: 100%; + max-width: 480px; + margin: 0; + padding: 20px; + display: grid; + gap: 14px; + align-content: start; +} + +.settings-card h1 { + margin: 0; + font-size: 24px; +} + +.settings-subtitle { + margin: 0; + color: #334155; + font-size: 13px; +} + +.settings-error { + margin: 0; + color: #991b1b; + background: #fee2e2; + border: 1px solid #fca5a5; + padding: 8px 10px; + border-radius: 8px; + font-size: 13px; +} + +.field { + display: grid; + gap: 8px; +} + +.field span { + font-size: 13px; + font-weight: 600; + color: #1e293b; +} + +.field select, +.field input[type="range"] { + width: 100%; +} + +.field select { + border: 1px solid #94a3b8; + border-radius: 8px; + padding: 8px 10px; + background: #ffffff; + color: #0f172a; +} + +.toggle { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: #1e293b; +} diff --git a/issues/issue3.md b/issues/issue3.md new file mode 100644 index 0000000..3a0432c --- /dev/null +++ b/issues/issue3.md @@ -0,0 +1,98 @@ +## Title + +`sprimo-tauri` settings window invoke errors for sprite-pack switch and always-on-top toggle. + +## Severity + +P1 + +## Environment + +- OS: Windows +- App version/build: `sprimo-tauri` workspace runtime +- Renderer/backend details: Tauri UI settings pop-out window +- Evidence screenshots: + - `issues/screenshots/issue3.png` + - `issues/screenshots/issue3-b.png` + +## Summary + +When using the settings pop-out window, changing character or toggling always-on-top shows +invoke argument validation errors and the action fails to apply. + +## Reproduction Steps + +1. Start `sprimo-tauri` and open tray menu `Settings`. +2. Change `Character` selection. +3. Toggle `Always on top`. +4. Observe red error banner in settings window. + +## Expected Result + +- Character change applies successfully and updates active sprite pack. +- Always-on-top toggle applies successfully and updates main window z-order state. +- No invoke argument error appears. + +## Actual Result + +- Character change fails with: + - `invalid args 'packIdOrPath' for command 'set_sprite_pack'` +- Always-on-top toggle fails with: + - `invalid args 'alwaysOnTop' for command 'set_always_on_top'` + +## Root Cause Analysis + +- Frontend invoke payload keys were sent in snake_case (`pack_id_or_path`, `always_on_top`). +- Tauri JS invoke argument mapping for these Rust command parameter names expects camelCase + keys (`packIdOrPath`, `alwaysOnTop`). +- Because the required keys were missing from Tauri's perspective, command handlers were not run. + +## Fix Plan + +1. Update settings invoke payload keys to Tauri-compatible camelCase names. +2. Add typed helper wrappers for settings invokes to centralize argument naming. +3. Rebuild UI and run Rust compile checks. +4. Perform runtime manual validation and capture after screenshot evidence. + +## Implementation Notes + +Implemented in `frontend/tauri-ui/src/main.tsx`: + +1. Added typed invoke wrappers: +- `invokeSetSpritePack(packIdOrPath)` +- `invokeSetScale(scale)` +- `invokeSetVisibility(visible)` +- `invokeSetAlwaysOnTop(alwaysOnTop)` + +2. Updated failing settings call sites to use wrappers and camelCase payload keys. + +## Verification + +### Commands Run + +- [x] `npm --prefix frontend/tauri-ui run build` +- [x] `cargo check -p sprimo-tauri` + +### Visual Checklist + +- [x] Before screenshot(s): `issues/screenshots/issue3.png` +- [x] Before screenshot(s): `issues/screenshots/issue3-b.png` +- [ ] After screenshot(s): `issues/screenshots/issue3-after-YYYYMMDD-HHMMSS.png` + +### Result + +- Status: `Fix Implemented` +- Notes: compile/build checks pass; runtime visual verification still required. + +## Status History + +- `2026-02-14 00:00` - reporter - `Reported` - settings errors captured in `issue3.png` and `issue3-b.png`. +- `2026-02-14 00:00` - codex - `Triaged` - localized to invoke argument key mismatch. +- `2026-02-14 00:00` - codex - `Fix Implemented` - updated settings invoke keys to camelCase via typed wrappers. + +## Closure + +- Current Status: `Fix Implemented` +- Close Date: +- Owner: +- Linked PR/commit: diff --git a/issues/issue4.md b/issues/issue4.md new file mode 100644 index 0000000..57a0acb --- /dev/null +++ b/issues/issue4.md @@ -0,0 +1,131 @@ +## Title + +Packaged `sprimo-tauri` sprite rendering breaks after pack switch; default switch errors and +scaling stops applying. + +## Severity + +P1 + +## Environment + +- OS: Windows +- App version/build: packaged release (`sprimo-tauri.exe`) +- Renderer/backend details: Tauri main overlay + settings pop-out +- Evidence screenshots: + - `issues/screenshots/issue4.png` + - `issues/screenshots/issue4-b.png` + - `issues/screenshots/issue4-c.png` + +## Summary + +In packaged runtime, sprite display is incorrectly split/tiled, switching to `default` can fail, +and scaling becomes ineffective after the error. + +## Reproduction Steps + +1. Run packaged `sprimo-tauri.exe` from ZIP extract. +2. Open settings window. +3. Switch character between `ferris` and `default`. +4. Observe main overlay rendering and debug output. +5. Change scale slider. + +## Expected Result + +- Sprite sheet is split into the correct frame grid regardless of image resolution. +- Pack switching works for both `ferris` and `default`. +- Scale changes continue to apply after pack changes. + +## Actual Result + +- Main overlay shows incorrectly split/tiled sprite sheet. +- Pack switch can produce runtime error and break subsequent behavior. +- Scale update stops working reliably after the error. + +## Root Cause Analysis + +1. Existing splitting logic relied on fixed pixel frame metadata that did not generalize to + packaged `sprite.png` dimension variants. +2. Pack metadata inconsistency: +- `assets/sprite-packs/ferris/manifest.json` used duplicated `id` (`default`), causing pack + identity ambiguity. +3. Settings/runtime flow then entered an unstable state after pack switch failures. +4. Renderer reload lifecycle in tauri UI was unsafe: +- `PixiPetRenderer::dispose` performed duplicate teardown (`ticker.destroy` + `app.destroy`), + which could trigger runtime `TypeError` during pack reload. +- Renderer replacement disposed previous renderer before new renderer creation succeeded, leaving + the view in a broken/cropped state on creation failures. +5. Chroma-key conversion tolerance removed most `#FF00FF` background but still left magenta fringe + on anti-aliased edges. + +## Fix Plan + +1. Introduce generic splitter policy for `sprite.png`: +- fixed topology: `8` columns x `7` rows +- derive frame size from actual image dimensions +- keep chroma-key background handling (`#FF00FF`) in renderer +2. Validate animation frame indices against fixed frame count (`56`) for `sprite.png`. +3. Ensure pack apply path validates atlas geometry before committing `SetSpritePack`. +4. Fix ferris manifest ID uniqueness. + +## Implementation Notes + +Implemented: + +1. `crates/sprimo-tauri/src/main.rs` +- Added `sprite.png`-specific frame derivation (`8x7`) from PNG dimensions. +- Added PNG header dimension decoding utility. +- Added animation frame index validation against fixed `56` frames for `sprite.png`. +- Applied validation in both `load_active_sprite_pack` and `set_sprite_pack`. + +2. `assets/sprite-packs/ferris/manifest.json` +- Changed manifest `id` from `default` to `ferris`. + +3. `docs/SPRITE_PACK_SCHEMA.md` +- Documented Tauri `sprite.png` override behavior and 8x7 derived frame policy. + +4. `frontend/tauri-ui/src/renderer/pixi_pet.ts` +- Made renderer disposal idempotent and removed duplicate ticker destruction. +- Delayed DOM canvas replacement until atlas load succeeds. +- Improved chroma-key edge handling with soft alpha + magenta spill suppression. + +5. `frontend/tauri-ui/src/main.tsx` +- Made pack reload transactional (keep old renderer until new renderer creation succeeds). +- Improved fit-window flow so scale apply continues after reload retries. +- Added targeted diagnostics for reload failures. + +## Verification + +### Commands Run + +- [ ] `just build-release-tauri` +- [ ] `just package-win-tauri` +- [ ] `just smoke-win-tauri` +- [x] `cargo check -p sprimo-tauri` + +### Visual Checklist + +- [x] Before screenshot(s): `issues/screenshots/issue4.png` +- [x] Before screenshot(s): `issues/screenshots/issue4-b.png` +- [x] Before screenshot(s): `issues/screenshots/issue4-c.png` +- [ ] After screenshot(s): `issues/screenshots/issue4-after-YYYYMMDD-HHMMSS.png` + +### Result + +- Status: `Fix Implemented` +- Notes: packaged runtime validation and after screenshots for this round are pending. + +## Status History + +- `2026-02-14 00:00` - reporter - `Reported` - packaged runtime failure screenshots attached. +- `2026-02-14 00:00` - codex - `Triaged` - localized to sprite splitting/pack identity behavior. +- `2026-02-14 00:00` - codex - `Fix Implemented` - applied 8x7 generic splitter policy and pack-ID correction. +- `2026-02-14 00:00` - reporter - `In Progress` - reported `issue4-after-fix1` still failing in packaged runtime. +- `2026-02-14 00:00` - codex - `Fix Implemented` - hardened renderer reload/dispose and chroma-key edge cleanup. + +## Closure + +- Current Status: `Fix Implemented` +- Close Date: +- Owner: +- Linked PR/commit: