From 42c235e76f2ad6342810d8646250ba2d23005138 Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:45:32 +0300 Subject: [PATCH 01/12] feat(app): shelve the folder OXGBC_ROMS_DIR names on a fresh install --- crates/app/src/library/mod.rs | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/app/src/library/mod.rs b/crates/app/src/library/mod.rs index 7e74ca06..cf384da6 100644 --- a/crates/app/src/library/mod.rs +++ b/crates/app/src/library/mod.rs @@ -9,8 +9,13 @@ use crate::PlatformFileSystem; use indexmap::IndexSet; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; +use std::env; use std::path::{Path, PathBuf}; +/// The folder a fresh install shelves and browses first: only a launcher knows +/// where a device keeps its ROMs. +const ROMS_DIR_ENV: &str = "OXGBC_ROMS_DIR"; + #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct RomsState { pub last_browse_dir_path: Option, @@ -92,14 +97,9 @@ impl RomsState { let path = Self::path(); let mut obj = if path.exists() { - let res: Result = core::read_json_file(&path); - let Ok(lib) = res else { - return Default::default(); - }; - - lib + core::read_json_file(&path).unwrap_or_else(|_| Self::seeded()) } else { - Default::default() + Self::seeded() }; // Paths written before they were stored absolute, and any that have since @@ -120,6 +120,21 @@ impl RomsState { obj } + /// A library with nothing on disk behind it: whatever [`ROMS_DIR_ENV`] names, or + /// empty. A starting point only — the app keeps what the user picks next. + fn seeded() -> Self { + let Some(dir) = env::var_os(ROMS_DIR_ENV).filter(|dir| !dir.is_empty()) else { + return Default::default(); + }; + let dir = PathBuf::from(dir); + + Self { + last_browse_dir_path: Some(dir.clone()), + selected_dir_path: Some(dir), + ..Default::default() + } + } + /// Returns an iterator over the full paths of loaded ROM files. pub fn iter_loaded( &self, From 5bdc5322ab7622bd421f9a03f644741326151bf7 Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:45:52 +0300 Subject: [PATCH 02/12] fix(app): list zipped ROMs on the shelf and in both browsers --- crates/app/src/frontend/modern/browse.rs | 2 +- crates/app/src/frontend/retro/menu/files.rs | 3 +- crates/app/src/library/meta.rs | 13 +++++- crates/app/src/library/mod.rs | 4 +- crates/app/src/storage/mod.rs | 30 ++++++++++++++ crates/app/src/storage/zip.rs | 45 +++++++++++++++++---- 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/crates/app/src/frontend/modern/browse.rs b/crates/app/src/frontend/modern/browse.rs index d1d04eb2..f4dcff3c 100644 --- a/crates/app/src/frontend/modern/browse.rs +++ b/crates/app/src/frontend/modern/browse.rs @@ -4,10 +4,10 @@ use crate::frontend::BrowseTarget; use crate::storage::browser::{FileBrowser, FILE_BROWSER_BACK_ITEM}; +use crate::storage::ROM_EXTENSIONS; use std::path::{Path, PathBuf}; /// A walk shows only what it can pick: games, or pictures, or nothing but folders. -const ROM_EXTENSIONS: &[&str] = &["gb", "gbc", "zip"]; const COVER_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg"]; const FOLDERS_ONLY: &[&str] = &[]; /// The walk keeps its own selection for the text menu's paging; the modern screen diff --git a/crates/app/src/frontend/retro/menu/files.rs b/crates/app/src/frontend/retro/menu/files.rs index a3ed6cfa..d836c3eb 100644 --- a/crates/app/src/frontend/retro/menu/files.rs +++ b/crates/app/src/frontend/retro/menu/files.rs @@ -2,6 +2,7 @@ use super::{SubMenu, MAX_MENU_ITEMS_PER_PAGE, MAX_MENU_ITEM_CHARS}; use crate::cmd::AppCmd; use crate::config::AppConfig; use crate::storage::browser::{FileBrowser, FILE_BROWSER_BACK_ITEM}; +use crate::storage::ROM_EXTENSIONS; use crate::video::truncate_text; use std::path::Path; @@ -12,7 +13,7 @@ pub struct FilesMenu { impl FilesMenu { pub fn new(last_path: Option>) -> Self { - let extensions = &["gb", "gbc"]; + let extensions = ROM_EXTENSIONS; Self { fb: if let Some(last_path) = last_path { diff --git a/crates/app/src/library/meta.rs b/crates/app/src/library/meta.rs index 0c2fae18..3a063f3b 100644 --- a/crates/app/src/library/meta.rs +++ b/crates/app/src/library/meta.rs @@ -5,7 +5,7 @@ //! collection often sits on a read-only or shared disk, and nothing of ours //! belongs in it. Keyed by file name, like every other save. -use crate::storage::base_dir; +use crate::storage::{base_dir, zip}; use core::cart::header::{CartHeader, CgbFlag}; use core::cart::Cart; use serde::{Deserialize, Serialize}; @@ -121,8 +121,17 @@ impl RomMeta { } fn read_header(path: &Path) -> Option<[u8; CartHeader::END]> { + let mut file = File::open(path).ok()?; + + if zip::is_zip(path) { + return zip::unzip_rom_prefix(file, CartHeader::END) + .ok()? + .try_into() + .ok(); + } + let mut header = [0; CartHeader::END]; - File::open(path).ok()?.read_exact(&mut header).ok()?; + file.read_exact(&mut header).ok()?; Some(header) } diff --git a/crates/app/src/library/mod.rs b/crates/app/src/library/mod.rs index cf384da6..e29400ad 100644 --- a/crates/app/src/library/mod.rs +++ b/crates/app/src/library/mod.rs @@ -4,7 +4,7 @@ pub mod cover; pub mod meta; -use crate::storage::base_dir; +use crate::storage::{base_dir, is_rom_file}; use crate::PlatformFileSystem; use indexmap::IndexSet; use serde::{Deserialize, Serialize}; @@ -58,7 +58,7 @@ impl RomsState { let can_split_paths = filesystem.can_split_paths(); for file in files { - if file.ends_with(".gb") || file.ends_with(".gbc") { + if is_rom_file(&file) { if can_split_paths { let path = PathBuf::from(file); if let Some(name) = filesystem.file_name(&path) { diff --git a/crates/app/src/storage/mod.rs b/crates/app/src/storage/mod.rs index 5ce3b56c..8bd2e3d7 100644 --- a/crates/app/src/storage/mod.rs +++ b/crates/app/src/storage/mod.rs @@ -7,6 +7,21 @@ pub mod zip; use std::path::PathBuf; +/// What counts as a game, for the shelf and both browsers — they disagreed once +/// and a folder of zips came out empty. [`zip::unzip_rom`] opens the zips. +pub const ROM_EXTENSIONS: &[&str] = &["gb", "gbc", "zip"]; + +/// By name rather than path: Android hands out `content://` URIs. +pub fn is_rom_file(file: &str) -> bool { + let Some((_, extension)) = file.rsplit_once('.') else { + return false; + }; + + ROM_EXTENSIONS + .iter() + .any(|rom| rom.eq_ignore_ascii_case(extension)) +} + /// The one directory the app writes into: config, palettes, save states, the /// library's sidecars. Everything else derives its path from here. pub fn base_dir() -> PathBuf { @@ -14,3 +29,18 @@ pub fn base_dir() -> PathBuf { PathBuf::from(path) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_game_is_recognized_however_its_extension_is_spelled() { + assert!(is_rom_file("Aerostar.zip")); + assert!(is_rom_file("/roms/gb/ZELDA.GB")); + assert!(is_rom_file("content://tree/primary%3ARoms%2FWario.gbc")); + + assert!(!is_rom_file("Astro Rabby.7z")); + assert!(!is_rom_file("gamelist")); + } +} diff --git a/crates/app/src/storage/zip.rs b/crates/app/src/storage/zip.rs index 22eec44d..7ba1e538 100644 --- a/crates/app/src/storage/zip.rs +++ b/crates/app/src/storage/zip.rs @@ -2,7 +2,7 @@ //! or `.gbc` in the archive is taken; whatever else it holds is not ours to guess //! about. -use std::io::{Cursor, Read}; +use std::io::{Cursor, Read, Seek}; use std::path::Path; use zip::ZipArchive; @@ -16,20 +16,49 @@ pub fn is_zip(path: &Path) -> bool { extension == "zip" } +/// The whole cartridge, to run it. pub fn unzip_rom(bytes: &[u8]) -> Result, String> { - let reader = Cursor::new(bytes); + read_rom(Cursor::new(bytes), None) +} + +/// Its first `len` bytes, inflating no further. Cataloguing a shelf of zips wants +/// headers: unpacking each cartridge whole to reach one cost a quarter of a second +/// per game on a handheld's card. +pub fn unzip_rom_prefix(reader: R, len: usize) -> Result, String> { + read_rom(reader, Some(len)) +} + +fn read_rom(reader: R, len: Option) -> Result, String> { let mut archive = ZipArchive::new(reader).map_err(|_| "Invalid zip archive".to_string())?; for i in 0..archive.len() { - let mut file = archive.by_index(i).unwrap(); - let name = file.name().to_ascii_lowercase(); + let mut file = archive.by_index(i).map_err(|err| err.to_string())?; + + if !is_rom_entry(file.name()) { + continue; + } - if name.ends_with(".gb") || name.ends_with(".gbc") { - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer).unwrap(); - return Ok(buffer); + let mut buffer = Vec::new(); + match len { + Some(len) => { + buffer.resize(len, 0); + file.read_exact(&mut buffer) + .map_err(|err| err.to_string())?; + } + None => { + file.read_to_end(&mut buffer) + .map_err(|err| err.to_string())?; + } } + + return Ok(buffer); } Err("No valid .gb or .gbc file found in zip".to_string()) } + +fn is_rom_entry(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + + name.ends_with(".gb") || name.ends_with(".gbc") +} From e84f4ec30769d9e0b5f43b3daa8857ee3cc44390 Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:45:52 +0300 Subject: [PATCH 03/12] fix(video): fall back to Passthrough when a shader will not compile --- crates/app/src/video/gl_backend.rs | 31 ++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/app/src/video/gl_backend.rs b/crates/app/src/video/gl_backend.rs index 3e0fadcf..53d1dc9c 100644 --- a/crates/app/src/video/gl_backend.rs +++ b/crates/app/src/video/gl_backend.rs @@ -14,6 +14,10 @@ use std::ptr; #[cfg(feature = "frontend-modern")] use std::sync::Arc; +/// The one shader every GL version here compiles. The richer ones use what GLSL ES +/// 1.00 left out — bitwise operators among them — and a GLES2 device refuses those. +const FALLBACK_SHADER: &str = "Passthrough"; + pub struct GlBackend { gl: GLSetup, /// The tile viewer's own window, which is not a GL one — it is the same canvas @@ -65,7 +69,7 @@ impl GlBackend { game_rect, gl, }; - obj.load_shader( + obj.load_shader_or_fallback( &render.gl.shader_name, render.gl.shader_frame_blend_mode, render.gl.shader_precision, @@ -91,12 +95,14 @@ impl GlBackend { } pub fn update_config(&mut self, config: &VideoConfig) { - self.load_shader( + if let Err(err) = self.load_shader_or_fallback( &config.render.gl.shader_name, config.render.gl.shader_frame_blend_mode, config.render.gl.shader_precision, - ) - .unwrap(); + ) { + log::error!("Failed to load shader: {err}"); + } + self.show_tiles(config.interface.show_tiles); } @@ -304,6 +310,23 @@ impl GlBackend { self.egui.destroy(); } + /// [`Self::load_shader`], falling back rather than failing: the device that + /// cannot compile a shader would otherwise take the whole app down with it. + fn load_shader_or_fallback( + &mut self, + name: &str, + frame_blend_mode: ShaderFrameBlendMode, + precision: ShaderPrecision, + ) -> Result<(), String> { + if let Err(err) = self.load_shader(name, frame_blend_mode, precision) { + log::warn!("Shader {name} unavailable ({err}); using {FALLBACK_SHADER}"); + + return self.load_shader(FALLBACK_SHADER, frame_blend_mode, precision); + } + + Ok(()) + } + /// Loads and initializes shaders + GPU resources pub fn load_shader( &mut self, From ded002c5434dfe78c27c5a87c946e434febcab5e Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:46:00 +0300 Subject: [PATCH 04/12] feat(input): open the menu with Select + Y --- crates/app/src/input/combo.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/app/src/input/combo.rs b/crates/app/src/input/combo.rs index 398107bd..560d2673 100644 --- a/crates/app/src/input/combo.rs +++ b/crates/app/src/input/combo.rs @@ -197,6 +197,9 @@ impl Default for ButtonComboBindings { bindings.add_cmd(Button::Start, Button::Back, AppCmd::ToggleMenu); bindings.add_cmd(Button::Start, Button::Guide, AppCmd::ToggleMenu); + // A handheld never sees Start + Select: there it closes whatever is running. + bindings.add_cmd(Button::Back, Button::Y, AppCmd::ToggleMenu); + bindings.add_cmd(Button::Guide, Button::Y, AppCmd::ToggleMenu); bindings.add_cmd( Button::Guide, From 2d36785c01b08d474fe531d12db922eeaedc8a36 Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:46:00 +0300 Subject: [PATCH 05/12] feat(input): put rewind on L1 and slow motion on Y --- crates/app/src/input/gamepad.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/crates/app/src/input/gamepad.rs b/crates/app/src/input/gamepad.rs index 5d820301..14cac913 100644 --- a/crates/app/src/input/gamepad.rs +++ b/crates/app/src/input/gamepad.rs @@ -116,24 +116,17 @@ pub fn default_buttons() -> InputBindings