MVP tauri frontend - Windows #1

Merged
manbo merged 8 commits from dev-tauri into master 2026-02-14 20:15:55 +08:00
13 changed files with 1105 additions and 96 deletions
Showing only changes of commit 901bf0ffc3 - Show all commits

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

View File

@@ -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<RuntimeCore>,
runtime: Arc<Runtime>,
tray_state: Arc<std::sync::Mutex<Option<TrayMenuState>>>,
}
#[derive(Clone)]
@@ -96,6 +125,7 @@ fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String
#[tauri::command]
fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSpritePack, String> {
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<UiSprite
.map_err(|_| "config lock poisoned".to_string())?
.clone();
let root = std::env::current_dir()
.map_err(|err| err.to_string())?
.join("assets")
.join(config.sprite.sprite_packs_dir);
let selected = config.sprite.selected_pack;
let pack_path = match resolve_pack_path(&root, &selected) {
Ok(path) => path,
@@ -117,6 +142,8 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
let manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?;
let image_path = pack_path.join(&manifest.image);
let image_bytes = std::fs::read(&image_path).map_err(|err| err.to_string())?;
let geometry = atlas_geometry_for_manifest(&manifest, &image_bytes)?;
validate_animation_frames(&manifest)?;
let atlas_data_url = format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(image_bytes)
@@ -124,14 +151,10 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
Ok(UiSpritePack {
id: manifest.id,
frame_width: manifest.frame_width,
frame_height: manifest.frame_height,
frame_width: geometry.frame_width,
frame_height: geometry.frame_height,
atlas_data_url,
animations: manifest
.animations
.into_iter()
.map(to_ui_clip)
.collect(),
animations: manifest.animations.into_iter().map(to_ui_clip).collect(),
anchor: UiAnchor {
x: manifest.anchor.x,
y: manifest.anchor.y,
@@ -147,6 +170,139 @@ fn debug_overlay_visible(state: tauri::State<'_, AppState>) -> Result<bool, Stri
.map_err(|err| err.to_string())
}
#[tauri::command]
fn settings_snapshot(state: tauri::State<'_, AppState>) -> Result<UiSettingsSnapshot, String> {
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<Vec<UiSpritePackOption>, 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<UiSnapshot, String> {
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<UiSnapshot, String> {
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<UiSnapshot, String> {
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<UiSnapshot, String> {
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<std::path::PathBuf, String> {
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<Wry>) -> Result<UiSnapshot, String> {
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<Wry>,
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<Wry>) -> 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<AtlasGeometry, String> {
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))
}

View File

@@ -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.

View File

@@ -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)

View File

@@ -64,3 +64,14 @@ Path: `<pack_dir>/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`).

View File

@@ -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.

View File

@@ -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

View File

@@ -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<UiSnapshot> {
return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath });
}
async function invokeSetScale(scale: number): Promise<UiSnapshot> {
return invoke<UiSnapshot>("set_scale", { scale });
}
async function invokeSetVisibility(visible: boolean): Promise<UiSnapshot> {
return invoke<UiSnapshot>("set_visibility", { visible });
}
async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise<UiSnapshot> {
return invoke<UiSnapshot>("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<void> {
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<voi
await window.setPosition(new LogicalPosition(targetX, targetY));
}
function App(): JSX.Element {
function MainOverlayWindow(): JSX.Element {
const [snapshot, setSnapshot] = React.useState<UiSnapshot | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [debugOverlayVisible, setDebugOverlayVisible] = React.useState(false);
const hostRef = React.useRef<HTMLDivElement | null>(null);
const rendererRef = React.useRef<PixiPetRenderer | null>(null);
const activePackRef = React.useRef<UiSpritePack | null>(null);
const loadedPackKeyRef = React.useRef<string | null>(null);
const scaleFitRef = React.useRef<number | null>(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<boolean> => {
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<boolean> => {
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<void> => {
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<UiSpritePack>("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<UiSpritePack>("load_active_sprite_pack"),
invoke<UiSnapshot>("current_state"),
invoke<boolean>("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<UiSnapshot>("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<UiSnapshot>("runtime:snapshot", (event) => {
void processSnapshot(event.payload);
});
const unlistenDebug = await listen<boolean>(
"runtime:debug-overlay-visible",
(event) => {
if (!mounted) {
return;
}
setDebugOverlayVisible(Boolean(event.payload));
const unlistenDebug = await listen<boolean>("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 (
<main className="app" onMouseDown={onMouseDown}>
<main className="app overlay-app" onMouseDown={onMouseDown}>
<div className="canvas-host" ref={hostRef} />
{error !== null && !debugOverlayVisible ? <p className="error-banner">{error}</p> : null}
{debugOverlayVisible ? (
@@ -200,8 +273,198 @@ function App(): JSX.Element {
);
}
function SettingsWindow(): JSX.Element {
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
const [error, setError] = React.useState<string | null>(null);
const [pending, setPending] = React.useState(false);
React.useEffect(() => {
let unlisten: null | (() => void) = null;
let mounted = true;
Promise.all([
invoke<UiSettingsSnapshot>("settings_snapshot"),
invoke<UiSpritePackOption[]>("list_sprite_packs")
])
.then(async ([snapshot, options]) => {
if (!mounted) {
return;
}
setSettings(snapshot);
setPacks(options);
unlisten = await listen<UiSnapshot>("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 <T,>(fn: () => Promise<T>): Promise<T | null> => {
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<HTMLSelectElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<main className="settings-root">
<section className="settings-card">
<h1>Settings</h1>
<p className="settings-subtitle">Character and window controls</p>
{error !== null ? <p className="settings-error">{error}</p> : null}
{settings === null ? (
<p>Loading settings...</p>
) : (
<>
<label className="field">
<span>Character</span>
<select
value={settings.active_sprite_pack}
disabled={pending}
onChange={onPackChange}
>
{packs.map((pack) => (
<option key={pack.pack_id_or_path} value={pack.pack_id_or_path}>
{pack.id}
</option>
))}
</select>
</label>
<label className="field">
<span>Scale: {settings.scale.toFixed(2)}x</span>
<input
type="range"
min={0.5}
max={3.0}
step={0.05}
value={settings.scale}
disabled={pending}
onChange={onScaleChange}
/>
</label>
<label className="toggle">
<input
type="checkbox"
checked={settings.visible}
disabled={pending}
onChange={onVisibleChange}
/>
<span>Visible</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={settings.always_on_top}
disabled={pending}
onChange={onAlwaysOnTopChange}
/>
<span>Always on top</span>
</label>
</>
)}
</section>
</main>
);
}
function AppRoot(): JSX.Element {
const windowLabel = getCurrentWindow().label;
if (windowLabel === "settings") {
return <SettingsWindow />;
}
return <MainOverlayWindow />;
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<AppRoot />
</React.StrictMode>
);

View File

@@ -28,6 +28,8 @@ export type UiSnapshot = {
y: number;
scale: number;
active_sprite_pack: string;
visible: boolean;
always_on_top: boolean;
};
type AnimationMap = Map<string, UiAnimationClip>;
@@ -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;

View File

@@ -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;
}

98
issues/issue3.md Normal file
View File

@@ -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:

131
issues/issue4.md Normal file
View File

@@ -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: