#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use bevy::prelude::*; use bevy::render::render_asset::RenderAssetUsages; use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; use bevy::window::{PrimaryWindow, WindowLevel, WindowMode, WindowPlugin, WindowPosition}; use image::{DynamicImage, GenericImageView, Rgba}; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use sprimo_platform::{create_adapter, PlatformAdapter}; use sprimo_protocol::v1::{CommandEnvelope, FrontendCommand, FrontendState, FrontendStateSnapshot}; use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError}; use sprimo_sprite::{ load_manifest, resolve_pack_path, AnimationDefinition, SpriteError, SpritePackManifest, }; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver}; use std::sync::{Arc, RwLock}; use std::time::Duration; use thiserror::Error; use tokio::runtime::Runtime; use tracing::{info, warn}; const APP_NAME: &str = "sprimo"; const DEFAULT_PACK: &str = "default"; const WINDOW_PADDING: f32 = 16.0; const STARTUP_WINDOW_SIZE: f32 = 416.0; const MAGENTA_KEY: [u8; 3] = [255, 0, 255]; const CHROMA_KEY_TOLERANCE: u8 = 24; const CHROMA_KEY_FORCE_RATIO: f32 = 0.15; const WINDOWS_COLOR_KEY: [u8; 3] = [255, 0, 255]; #[derive(Debug, Error)] enum AppError { #[error("{0}")] RuntimeCore(#[from] RuntimeCoreError), } #[derive(Resource, Clone)] struct SharedSnapshot(Arc>); #[derive(Resource, Clone)] struct PlatformResource(Arc); #[derive(Resource)] struct RuntimeCoreResource(Arc); #[derive(Resource, Copy, Clone)] struct DurableState { state: FrontendState, } #[derive(Resource, Clone)] struct PendingStateReset { timer: Option, revert_to: FrontendState, } #[derive(Resource)] struct SpritePackRoot { asset_root: PathBuf, } #[derive(Debug, Clone)] struct AnimationClip { fps: u16, frames: Vec, one_shot: bool, } #[derive(Debug, Clone)] struct PackRuntime { pack_id: String, clips: HashMap, texture_handle: Handle, layout_handle: Handle, idle_frame: u32, frame_width: u32, frame_height: u32, } #[derive(Debug)] struct LoadedManifest { pack_id: String, pack_path: PathBuf, manifest: SpritePackManifest, } #[derive(Debug, Clone, Copy)] enum AlphaMode { NativeAlpha, ChromaKey, } #[derive(Resource, Clone)] struct CurrentPackResource { runtime: PackRuntime, } #[derive(Resource)] struct AnimationResource { current: String, timer: Timer, frame_cursor: usize, one_shot_return: Option, } #[derive(Component)] struct PetSprite; struct CommandIngress(Receiver); struct HotkeyIngress(Receiver<()>); #[cfg(target_os = "windows")] #[allow(unsafe_code)] mod windows_hotkey { use std::ffi::c_void; use std::sync::mpsc::Sender; use std::thread; use tracing::{info, warn}; use windows::Win32::Foundation::HWND; use windows::Win32::UI::Input::KeyboardAndMouse::{RegisterHotKey, MOD_ALT, MOD_CONTROL}; use windows::Win32::UI::WindowsAndMessaging::{ DispatchMessageW, GetMessageW, TranslateMessage, MSG, WM_HOTKEY, }; pub fn spawn_recovery_hotkey(tx: Sender<()>, configured: &str) { let hotkey = configured.trim().to_ascii_uppercase(); let (modifiers, key) = if hotkey == "CTRL+ALT+P" { (MOD_CONTROL | MOD_ALT, u32::from(b'P')) } else { warn!(hotkey = configured, "unsupported hotkey config, falling back to Ctrl+Alt+P"); (MOD_CONTROL | MOD_ALT, u32::from(b'P')) }; thread::spawn(move || { // SAFETY: Registering a process-wide hotkey with a null HWND is valid. let result = unsafe { RegisterHotKey(HWND(std::ptr::null_mut::()), 1, modifiers, key) }; if result.is_err() { warn!("failed to register global recovery hotkey"); return; } info!("global recovery hotkey registered"); let mut msg = MSG::default(); loop { // SAFETY: `msg` points to initialized storage for the thread message queue. let code = unsafe { GetMessageW(&mut msg, HWND(std::ptr::null_mut::()), 0, 0) }; if code.0 <= 0 { break; } if msg.message == WM_HOTKEY { let _ = tx.send(()); } // SAFETY: `msg` was produced by `GetMessageW`; dispatch is valid for this thread. unsafe { let _ = TranslateMessage(&msg); DispatchMessageW(&msg); } } }); } } fn main() -> Result<(), AppError> { tracing_subscriber::fmt() .with_env_filter("sprimo=info") .with_target(false) .compact() .init(); let platform: Arc = create_adapter().into(); let runtime_core = Arc::new(RuntimeCore::new(APP_NAME, platform.capabilities())?); let shared_snapshot = runtime_core.snapshot(); let config = runtime_core .config() .read() .expect("runtime core config lock poisoned") .clone(); let runtime = Runtime::new().expect("tokio runtime"); let (bevy_command_tx, bevy_command_rx) = mpsc::channel(); let (hotkey_tx, hotkey_rx) = mpsc::channel(); runtime_core.spawn_api(&runtime); let command_rx = runtime_core.command_receiver(); runtime.spawn(async move { loop { let next = { let mut receiver = command_rx.lock().await; receiver.recv().await }; let Some(command) = next else { break; }; if bevy_command_tx.send(command).is_err() { break; } } }); if config.controls.hotkey_enabled { #[cfg(target_os = "windows")] windows_hotkey::spawn_recovery_hotkey(hotkey_tx, &config.controls.recovery_hotkey); } let mut app = App::new(); app.add_plugins(DefaultPlugins.set(WindowPlugin { primary_window: Some(bevy::window::Window { title: "sprimo-overlay".to_string(), mode: WindowMode::Windowed, transparent: window_transparent_mode(), decorations: false, resizable: false, window_level: WindowLevel::AlwaysOnTop, resolution: bevy::window::WindowResolution::new( STARTUP_WINDOW_SIZE, STARTUP_WINDOW_SIZE, ), position: WindowPosition::At(IVec2::new( config.window.x.round() as i32, config.window.y.round() as i32, )), ..default() }), ..default() })); app.insert_resource(ClearColor(window_clear_color())); app.insert_resource(SharedSnapshot(shared_snapshot)); app.insert_resource(RuntimeCoreResource(Arc::clone(&runtime_core))); app.insert_resource(PlatformResource(Arc::clone(&platform))); app.insert_resource(DurableState { state: FrontendState::Idle, }); app.insert_resource(PendingStateReset { timer: None, revert_to: FrontendState::Idle, }); app.insert_resource(SpritePackRoot { asset_root: default_asset_root(), }); app.insert_non_send_resource(CommandIngress(bevy_command_rx)); app.insert_non_send_resource(HotkeyIngress(hotkey_rx)); app.add_systems(Startup, setup_scene); app.add_systems( Update, ( attach_window_handle_once, poll_backend_commands, poll_hotkey_recovery, tick_state_reset, animate_sprite, ), ); info!("sprimo frontend starting Bevy runtime"); app.run(); Ok(()) } fn default_asset_root() -> PathBuf { std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join("assets") .join("sprite-packs") } fn setup_scene( mut commands: Commands, runtime_core: Res, root: Res, mut texture_layouts: ResMut>, mut images: ResMut>, asset_server: Res, mut window_query: Query<&mut Window, With>, snapshot: Res, ) { commands.spawn(Camera2dBundle { camera: Camera { clear_color: bevy::render::camera::ClearColorConfig::Custom(window_clear_color()), ..default() }, ..default() }); let selected = runtime_core .0 .config() .read() .expect("runtime core config lock poisoned") .sprite .selected_pack .clone(); let runtime = match load_pack_runtime( &root.asset_root, &selected, true, &asset_server, &mut images, &mut texture_layouts, ) { Ok(runtime) => runtime, Err(err) => { warn!(%err, "failed to load selected sprite pack, falling back to default"); load_pack_runtime( &root.asset_root, DEFAULT_PACK, false, &asset_server, &mut images, &mut texture_layouts, ) .expect("embedded default sprite pack must load") } }; let (scale, visible) = { let guard = snapshot .0 .read() .expect("frontend snapshot lock poisoned"); (guard.scale, guard.flags.visible) }; let mut entity = commands.spawn(( SpriteBundle { texture: runtime.texture_handle.clone(), transform: Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(scale)), ..default() }, TextureAtlas { layout: runtime.layout_handle.clone(), index: runtime.idle_frame as usize, }, PetSprite, )); if !visible { entity.insert(Visibility::Hidden); } if let Ok(mut window) = window_query.get_single_mut() { let size = window_size_for_pack(&runtime, scale); window.resolution.set(size.x, size.y); } commands.insert_resource(CurrentPackResource { runtime: runtime.clone(), }); commands.insert_resource(AnimationResource { current: "idle".to_string(), timer: Timer::from_seconds(0.125, TimerMode::Repeating), frame_cursor: 0, one_shot_return: None, }); } #[cfg(target_os = "windows")] fn window_clear_color() -> Color { Color::srgb_u8( WINDOWS_COLOR_KEY[0], WINDOWS_COLOR_KEY[1], WINDOWS_COLOR_KEY[2], ) } #[cfg(not(target_os = "windows"))] fn window_clear_color() -> Color { Color::srgba(0.0, 0.0, 0.0, 0.0) } #[cfg(target_os = "windows")] fn window_transparent_mode() -> bool { false } #[cfg(not(target_os = "windows"))] fn window_transparent_mode() -> bool { true } #[derive(Debug, Error)] enum PackLoadError { #[error("{0}")] Sprite(#[from] SpriteError), #[error("sprite pack path must be under asset root: {0}")] ExternalPackPath(String), #[error("sprite pack has no `idle` animation: {0}")] MissingIdleAnimation(String), #[error("sprite pack identifier must be a directory name: {0}")] InvalidPackName(String), #[error("failed to read image metadata for {path}: {source}")] Image { path: String, source: image::ImageError, }, #[error("invalid frame grid for {pack_id}: image={image_width}x{image_height}, frame={frame_width}x{frame_height}")] InvalidFrameGrid { pack_id: String, image_width: u32, image_height: u32, frame_width: u32, frame_height: u32, }, #[error("frame index {frame} out of bounds for {pack_id}; total frames: {total_frames}")] FrameOutOfRange { pack_id: String, frame: u32, total_frames: u32, }, #[error("invalid chroma key value `{value}`; expected `#RRGGBB`")] InvalidChromaKey { value: String }, } fn load_pack_runtime( asset_root: &Path, selected: &str, allow_default_fallback: bool, asset_server: &AssetServer, images: &mut Assets, texture_layouts: &mut Assets, ) -> Result { let loaded = if allow_default_fallback { match load_pack_manifest_from_selector(asset_root, selected) { Ok(v) => v, Err(_) => load_pack_manifest_from_selector(asset_root, DEFAULT_PACK)?, } } else { load_pack_manifest_from_selector(asset_root, selected)? }; let clips = to_clip_map(&loaded.manifest, &loaded.pack_id)?; let image_path = loaded.pack_path.join(&loaded.manifest.image); let source_image = image::open(&image_path).map_err(|source| PackLoadError::Image { path: image_path.display().to_string(), source, })?; let (image_width, image_height) = source_image.dimensions(); let (columns, rows, total_frames) = compute_grid( &loaded.pack_id, image_width, image_height, loaded.manifest.frame_width, loaded.manifest.frame_height, )?; validate_frames(&loaded.pack_id, &clips, total_frames)?; let layout_handle = texture_layouts.add(TextureAtlasLayout::from_grid( UVec2::new(loaded.manifest.frame_width, loaded.manifest.frame_height), columns, rows, None, None, )); let (texture_handle, alpha_mode) = create_texture_handle( &loaded.pack_id, &loaded.manifest, source_image, &image_path, asset_server, images, )?; let alpha_mode_label = match alpha_mode { AlphaMode::NativeAlpha => "native_alpha", AlphaMode::ChromaKey => "chroma_key", }; info!( pack_id = loaded.pack_id, image_width, image_height, frame_width = loaded.manifest.frame_width, frame_height = loaded.manifest.frame_height, columns, rows, alpha_mode = alpha_mode_label, "sprite pack loaded" ); let idle_frame = clips .get("idle") .and_then(|clip| clip.frames.first()) .copied() .unwrap_or(0); Ok(PackRuntime { pack_id: loaded.pack_id, clips, texture_handle, layout_handle, idle_frame, frame_width: loaded.manifest.frame_width, frame_height: loaded.manifest.frame_height, }) } fn load_pack_manifest_from_selector( asset_root: &Path, selected: &str, ) -> Result { let pack_path = resolve_pack_path(asset_root, selected)?; if pack_path.strip_prefix(asset_root).is_err() { return Err(PackLoadError::ExternalPackPath(pack_path.display().to_string())); } let pack_id = pack_path .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| PackLoadError::InvalidPackName(pack_path.display().to_string()))? .to_string(); let manifest = load_manifest(&pack_path)?; Ok(LoadedManifest { pack_id, pack_path, manifest, }) } fn to_clip_map( manifest: &SpritePackManifest, pack_id: &str, ) -> Result, PackLoadError> { let mut clips = HashMap::new(); for animation in &manifest.animations { clips.insert(animation.name.clone(), to_clip(animation.clone())); } if !clips.contains_key("idle") { return Err(PackLoadError::MissingIdleAnimation(pack_id.to_string())); } Ok(clips) } fn create_texture_handle( pack_id: &str, manifest: &SpritePackManifest, source_image: DynamicImage, image_path: &Path, asset_server: &AssetServer, images: &mut Assets, ) -> Result<(Handle, AlphaMode), PackLoadError> { let key = manifest .chroma_key_color .as_deref() .map(parse_hex_rgb) .transpose()? .unwrap_or(MAGENTA_KEY); let enabled = manifest.chroma_key_enabled.unwrap_or(true); let rgba_available = source_image.color().has_alpha(); let force_chroma_key = enabled && should_force_chroma_key(&source_image, key); if rgba_available && !force_chroma_key { let texture_path = format!("sprite-packs/{}/{}", pack_id, manifest.image); return Ok((asset_server.load(texture_path), AlphaMode::NativeAlpha)); } if !enabled { let texture_path = format!("sprite-packs/{}/{}", pack_id, manifest.image); return Ok((asset_server.load(texture_path), AlphaMode::NativeAlpha)); } if force_chroma_key { warn!( pack_id, source = %image_path.display(), "sprite image reports alpha but appears opaque with key-colored background; forcing chroma-key" ); } let converted = apply_chroma_key(source_image, key); let (width, height) = converted.dimensions(); let bytes = converted.into_raw(); let image = Image::new( Extent3d { width, height, depth_or_array_layers: 1, }, TextureDimension::D2, bytes, TextureFormat::Rgba8UnormSrgb, RenderAssetUsages::default(), ); let handle = images.add(image); info!( pack_id, source = %image_path.display(), "loaded RGB sprite sheet via chroma-key conversion" ); Ok((handle, AlphaMode::ChromaKey)) } fn parse_hex_rgb(value: &str) -> Result<[u8; 3], PackLoadError> { let bytes = value.trim(); if bytes.len() != 7 || !bytes.starts_with('#') { return Err(PackLoadError::InvalidChromaKey { value: value.to_string(), }); } let r = u8::from_str_radix(&bytes[1..3], 16).map_err(|_| PackLoadError::InvalidChromaKey { value: value.to_string(), })?; let g = u8::from_str_radix(&bytes[3..5], 16).map_err(|_| PackLoadError::InvalidChromaKey { value: value.to_string(), })?; let b = u8::from_str_radix(&bytes[5..7], 16).map_err(|_| PackLoadError::InvalidChromaKey { value: value.to_string(), })?; Ok([r, g, b]) } fn apply_chroma_key(image: DynamicImage, key: [u8; 3]) -> image::RgbaImage { let mut rgba = image.to_rgba8(); let keyed_alpha = chroma_key_output_alpha(); for pixel in rgba.pixels_mut() { if is_chroma_key_match(pixel.0, key, CHROMA_KEY_TOLERANCE) { *pixel = Rgba([key[0], key[1], key[2], keyed_alpha]); } } rgba } #[cfg(target_os = "windows")] fn chroma_key_output_alpha() -> u8 { // Windows fallback uses layered color-key transparency, so keyed pixels must keep // opaque alpha to preserve exact key RGB through the presentation path. u8::MAX } #[cfg(not(target_os = "windows"))] fn chroma_key_output_alpha() -> u8 { 0 } fn should_force_chroma_key(image: &DynamicImage, key: [u8; 3]) -> bool { if !image.color().has_alpha() { return false; } let rgba = image.to_rgba8(); let mut opaque_alpha = true; let mut key_like = 0usize; for pixel in rgba.pixels() { if pixel[3] != u8::MAX { opaque_alpha = false; break; } if is_chroma_key_match(pixel.0, key, CHROMA_KEY_TOLERANCE) { key_like += 1; } } if !opaque_alpha { return false; } let total = (rgba.width() as usize).saturating_mul(rgba.height() as usize); total != 0 && (key_like as f32 / total as f32) >= CHROMA_KEY_FORCE_RATIO } fn is_chroma_key_match(pixel: [u8; 4], key: [u8; 3], tolerance: u8) -> bool { pixel[0].abs_diff(key[0]) <= tolerance && pixel[1].abs_diff(key[1]) <= tolerance && pixel[2].abs_diff(key[2]) <= tolerance } fn to_clip(animation: AnimationDefinition) -> AnimationClip { AnimationClip { fps: animation.fps.max(1), frames: animation.frames, one_shot: animation.one_shot.unwrap_or(false), } } fn attach_window_handle_once( primary: Query>, platform: Res, snapshot: Res, mut attached: Local, #[cfg(target_os = "windows")] winit_windows: NonSend, ) { if *attached { return; } let Ok(entity) = primary.get_single() else { return; }; #[cfg(target_os = "windows")] { if let Some(window) = winit_windows.get_window(entity) { if let Ok(handle) = window.window_handle() { if let RawWindowHandle::Win32(win32) = handle.as_raw() { let hwnd = win32.hwnd.get(); if let Err(err) = platform.0.attach_window_handle(hwnd) { warn!(%err, "failed to attach native window handle"); return; } let guard = snapshot .0 .read() .expect("frontend snapshot lock poisoned"); let _ = platform.0.set_always_on_top(guard.flags.always_on_top); let _ = platform.0.set_visible(guard.flags.visible); let _ = platform.0.set_window_position(guard.x, guard.y); *attached = true; } } } } #[cfg(not(target_os = "windows"))] { let _ = entity; *attached = true; } } fn poll_hotkey_recovery( ingress: NonSendMut, platform: Res, runtime_core: Res, mut pet_query: Query<&mut Visibility, With>, ) { while ingress.0.try_recv().is_ok() { info!("recovery hotkey received"); let _ = platform.0.set_always_on_top(true); let _ = platform.0.set_visible(true); if let Ok(mut visibility) = pet_query.get_single_mut() { *visibility = Visibility::Visible; } if let Err(err) = runtime_core.0.apply_command(&FrontendCommand::SetFlags { click_through: None, always_on_top: Some(true), visible: Some(true), }) { warn!(%err, "failed to persist recovery flag config"); } } } fn poll_backend_commands( ingress: NonSendMut, platform: Res, root: Res, asset_server: Res, mut images: ResMut>, mut texture_layouts: ResMut>, runtime_core: Res, snapshot: Res, mut durable_state: ResMut, mut reset: ResMut, mut animation: ResMut, mut current_pack: ResMut, mut pet_query: Query< (&mut Transform, &mut Visibility, &mut TextureAtlas, &mut Handle), With, >, mut window_query: Query<&mut Window, With>, ) { loop { let Ok(envelope) = ingress.0.try_recv() else { break; }; let command = envelope.command; let Ok((mut transform, mut visibility, mut atlas, mut image_handle)) = pet_query.get_single_mut() else { continue; }; if !matches!(command, FrontendCommand::SetSpritePack { .. }) { if let Err(err) = runtime_core.0.apply_command(&command) { warn!(%err, "failed to apply command in runtime core"); continue; } } match command { FrontendCommand::SetState { state, ttl_ms } => { if !matches!(state, FrontendState::Success | FrontendState::Error) { durable_state.state = state; } let next_animation = default_animation_for_state(state); set_animation(&mut animation, ¤t_pack.runtime.clips, next_animation); if let Some(ttl) = ttl_ms { reset.timer = Some(Timer::new(Duration::from_millis(ttl), TimerMode::Once)); reset.revert_to = durable_state.state; } else { reset.timer = None; } if let Ok(mut guard) = snapshot.0.write() { guard.state = state; guard.current_animation = next_animation.to_string(); guard.last_error = None; } if let Some(frame) = current_frame(&animation, ¤t_pack.runtime.clips) { atlas.index = frame as usize; } } FrontendCommand::PlayAnimation { name, .. } => { set_animation(&mut animation, ¤t_pack.runtime.clips, &name); if let Ok(mut guard) = snapshot.0.write() { guard.current_animation = animation.current.clone(); guard.last_error = None; } if let Some(frame) = current_frame(&animation, ¤t_pack.runtime.clips) { atlas.index = frame as usize; } } FrontendCommand::SetSpritePack { pack_id_or_path } => { let requested = pack_id_or_path.clone(); let reload = load_pack_runtime( &root.asset_root, &requested, false, &asset_server, &mut images, &mut texture_layouts, ); match reload { Ok(next_pack) => { let next_animation = choose_animation_after_reload(&animation.current, &next_pack.clips); current_pack.runtime = next_pack; image_handle.clone_from(¤t_pack.runtime.texture_handle); atlas.layout.clone_from(¤t_pack.runtime.layout_handle); set_animation( &mut animation, ¤t_pack.runtime.clips, &next_animation, ); if let Some(frame) = current_frame(&animation, ¤t_pack.runtime.clips) { atlas.index = frame as usize; } else { atlas.index = current_pack.runtime.idle_frame as usize; } if let Ok(mut window) = window_query.get_single_mut() { let scale = transform.scale.x; let size = window_size_for_pack(¤t_pack.runtime, scale); window.resolution.set(size.x, size.y); } if let Err(err) = runtime_core.0.apply_command( &FrontendCommand::SetSpritePack { pack_id_or_path: requested.clone(), }, ) { warn!(%err, "failed to persist sprite pack selection"); } if let Ok(mut guard) = snapshot.0.write() { guard.active_sprite_pack = current_pack.runtime.pack_id.clone(); guard.current_animation = animation.current.clone(); guard.last_error = None; } } Err(err) => { warn!(requested, %err, "sprite pack reload failed; keeping current pack"); if let Ok(mut guard) = snapshot.0.write() { guard.last_error = Some(format!("SetSpritePack `{}` failed: {}", requested, err)); } } } } FrontendCommand::SetTransform { x, y, scale, .. } => { if let Some(value) = scale { transform.scale = Vec3::splat(value); if let Ok(mut window) = window_query.get_single_mut() { let size = window_size_for_pack(¤t_pack.runtime, value); window.resolution.set(size.x, size.y); } } if x.is_some() || y.is_some() { if let Ok(guard) = snapshot.0.read() { let _ = platform.0.set_window_position(guard.x, guard.y); } } } FrontendCommand::SetFlags { click_through, always_on_top, visible, } => { let _ = click_through; if let Some(value) = always_on_top { let _ = platform.0.set_always_on_top(value); } if let Some(value) = visible { let _ = platform.0.set_visible(value); *visibility = if value { Visibility::Visible } else { Visibility::Hidden }; } } FrontendCommand::Toast { text, .. } => { info!(toast = text, "toast command received"); } } } } fn tick_state_reset( time: Res