diff --git a/README.md b/README.md index cf948b8..7cb1bda 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Native32 is a game format developed by Sunplus for DVD player and TV chipsets (c - **YUV & ARGB image decoding** — with packbits/RLE decompression and color space conversion - **Action bytecode VM** — 36 opcodes covering arithmetic, logic, string ops, control flow, sprites, and I/O - **Sprite/movie system** — animation, cloning, visibility control, depth-sorted rendering -- **Audio playback** — MP3 music and raw 16-bit PCM sound effects +- **Audio playback** — finite/infinite-loop MP3 music mixed with raw 16-bit PCM sound effects (11025Hz for YUV games, 22050Hz for ARGB games), output as stereo with mono sources duplicated across both channels - **MPEG-1 cutscenes** — pure-Rust MPEG-1 video + MP2 audio decoder plays `SSL_PlayNext` logo/cutscene videos (no C dependency); skippable with A/B - **ZIP archive support** — load game packages directly from `.zip` files (auto-extracts and loads FHUI.smf) - **Keyboard input** — configurable key remapping @@ -36,83 +36,24 @@ Native32 is a game format developed by Sunplus for DVD player and TV chipsets (c ### Standalone Mode -Download the latest binary from the [Releases](https://github.com/jiangxincode/Native32Emu/releases) page. - -The basic usage is listed below, but there are many more options for controlling the behavior of the emulator. See the [Command-line Options](docs/CLI-Options.md) documentation for the full list of options. +Download the latest binary from the +[Releases](https://github.com/jiangxincode/Native32Emu/releases) page and run: ```bash -# Basic usage native32-emu path/to/game.smf - -# Load from ZIP archive (auto-extracts and loads FHUI.smf) -native32-emu path/to/game.zip - -# Fullscreen mode -native32-emu --fullscreen game.smf ``` -**ZIP mode**: When loading from a `.zip` file, the emulator starts the FHUI -menu. Selecting a game launches it; pressing **ESC** during gameplay returns to -the menu. Pressing ESC on the menu itself exits the emulator. When loading a -`.smf` file directly, ESC exits as usual. - -### RetroArch Mode (Desktop: Windows / Linux / macOS) - -Native32Emu can be used as a libretro core with RetroArch, allowing you to play Native32 games with RetroArch's features like shaders, netplay, and achievements. - -**Install the core** — choose one of the following methods: - -- **Online Updater**: Open RetroArch → **Main Menu → Online Updater → Core Downloader** → select **Native32 (Native32Emu)** -- **Manual**: Download the core from the [Releases](https://github.com/jiangxincode/Native32Emu/releases) page, copy `native32emu_libretro.dll` (or `.so`/`.dylib`) to RetroArch's `cores/` directory, and `native32emu_libretro.info` to the `info/` directory - -**Load the core in RetroArch**: - -1. Open RetroArch -2. Select "Load Core" → "Native32 (Native32Emu)" -3. Select "Load Content" and choose a `.smf`, `.sgm`, `.ssl`, or `.zip` game file - -#### RetroArch on Android - -The libretro core also runs on Android and can be reused by most Android -RetroArch-based frontends. See [Android Libretro Core](docs/Android-Libretro-Core.md) -for install and build instructions. - -#### RetroArch on iOS - -The libretro core also runs on iOS (iPhone / iPad). iOS currently requires manual core injection — see -[iOS Libretro Core](docs/iOS-Libretro-Core.md) for install and build instructions. - -#### Supported Features - -- ✅ Video output (XRGB8888 pixel format) -- ✅ Audio output (looping MP3 music mixed with RAW PCM sound effects, stereo) -- ✅ Input handling (D-Pad + A/B buttons) -- ✅ Game loading (.smf, .sgm, .ssl, .zip files) -- ✅ Save states -- ✅ Core options (audio volume, key auto-repeat timing, swap A/B, auto-skip cutscenes) - -#### Core Options - -Configurable from RetroArch's *Quick Menu → Core Options* (audio volume, key -auto-repeat timing, swap A/B, auto-skip cutscenes). See -[Core Options](docs/Core-Options.md) for the full list. - -#### RetroPad Button Mapping +See the [Standalone Emulator](docs/Standalone-Emulator.md) guide for +installation, ZIP menu behavior, keyboard controls, cheats, display settings, +and all command-line options. -| RetroPad Button | Native32 Keycode | Action | -|----------------|------------------|--------| -| D-Pad Left | 0x0200 | Left | -| D-Pad Right | 0x0400 | Right | -| D-Pad Up | 0x1c00 | Up | -| D-Pad Down | 0x1e00 | Down | -| A (SNES East) | 0x8800 | B / Menu | -| B (SNES South) | 0x4000 | A | +### RetroArch Mode -#### Audio Notes +Install **Native32 (Native32Emu)** from RetroArch's Core Downloader, or install +the release files manually, then load a supported game through **Load Content**. -- **MP3**: Fully supported, including finite/infinite loops and simultaneous sound effects -- **RAW PCM**: Fully supported (11025Hz for YUV games, 22050Hz for ARGB games) -- Audio is output as stereo (mono sources are duplicated to both channels) +See the [RetroArch Core](docs/RetroArch-Core.md) guide for installation, +supported platforms and features, RetroPad mapping, core options, and cheats. ## Building diff --git a/crates/native32emu-core/src/cheats.rs b/crates/native32emu-core/src/cheats.rs new file mode 100644 index 0000000..d8c12ba --- /dev/null +++ b/crates/native32emu-core/src/cheats.rs @@ -0,0 +1,550 @@ +// Shared cheat-code support for all front-ends. + +use crate::action_vm::ActionVM; +use crate::frame_player::FramePlayer; +use crate::sprite_system::{MovieState, SpriteSystem}; +use std::collections::{BTreeMap, HashMap}; +use std::str::FromStr; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum CheatParseError { + #[error("cheat code is empty")] + Empty, + #[error("cheat code must use '=' syntax")] + MissingValue, + #[error("unknown cheat target '{0}'")] + UnknownTarget(String), + #[error("sprite cheat must use 'sprite:.='")] + InvalidSpriteTarget, + #[error("frame cheat must use 'frame:goto=' or 'frame:playing='")] + InvalidFrameTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CheatRule { + Variable { + name: String, + value: String, + }, + Sprite { + name: String, + field: SpriteField, + value: String, + }, + Frame { + field: FrameField, + value: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpriteField { + X, + Y, + Depth, + Frame, + Visible, + Playing, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameField { + Goto, + Playing, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheatSlot { + pub enabled: bool, + pub code: String, + pub rule: CheatRule, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheatDebugConfig { + pub enabled: bool, + pub interval_frames: u64, + pub variable_filter: Option, +} + +impl Default for CheatDebugConfig { + fn default() -> Self { + Self { + enabled: false, + interval_frames: CheatManager::DEFAULT_DEBUG_INTERVAL_FRAMES, + variable_filter: None, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CheatManager { + slots: BTreeMap, + debug: CheatDebugConfig, +} + +impl CheatManager { + pub const DEFAULT_DEBUG_INTERVAL_FRAMES: u64 = 30; + const DEBUG_LIST_LIMIT: usize = 64; + + pub fn new() -> Self { + Self::default() + } + + pub fn clear(&mut self) { + self.slots.clear(); + } + + pub fn set_slot( + &mut self, + index: u32, + enabled: bool, + code: &str, + ) -> Result<(), CheatParseError> { + let code = code.trim(); + if code.is_empty() { + self.slots.remove(&index); + return Ok(()); + } + + let rule = CheatRule::from_str(code)?; + self.slots.insert( + index, + CheatSlot { + enabled, + code: code.to_string(), + rule, + }, + ); + Ok(()) + } + + pub fn add_code(&mut self, code: &str) -> Result { + let index = self + .slots + .keys() + .next_back() + .map_or(0, |highest| highest.saturating_add(1)); + self.set_slot(index, true, code)?; + Ok(index) + } + + pub fn len(&self) -> usize { + self.slots.len() + } + + pub fn is_empty(&self) -> bool { + self.slots.is_empty() + } + + pub fn set_debug_logging(&mut self, enabled: bool, interval_frames: u64) { + self.debug = CheatDebugConfig { + enabled, + interval_frames: interval_frames.max(1), + variable_filter: self.debug.variable_filter.clone(), + }; + } + + pub fn set_debug_variable_filter(&mut self, filter: Option<&str>) { + self.debug.variable_filter = filter + .map(str::trim) + .filter(|filter| !filter.is_empty()) + .map(str::to_owned); + } + + pub fn debug_logging_enabled(&self) -> bool { + self.debug.enabled + } + + pub fn debug_interval_frames(&self) -> u64 { + self.debug.interval_frames + } + + pub fn debug_variable_filter(&self) -> Option<&str> { + self.debug.variable_filter.as_deref() + } + + pub fn maybe_log_targets( + &self, + tick_count: u64, + vm: &ActionVM, + sprites: &SpriteSystem, + frame: &FramePlayer, + ) { + if !self.debug.enabled || !tick_count.is_multiple_of(self.debug.interval_frames) { + return; + } + + log::info!( + "Cheat targets @ tick {}: frame current={} playing={} next={}", + tick_count, + frame.current_frame, + frame.playing, + format_option(frame.next_frame) + ); + let filter = self.debug.variable_filter.as_deref(); + let filter_label = filter + .map(|pattern| format!(" matching '{pattern}'")) + .unwrap_or_default(); + log::info!( + "Cheat targets @ tick {}: vars{} {}", + tick_count, + filter_label, + format_vars(&vm.vars, filter, Self::DEBUG_LIST_LIMIT) + ); + log::info!( + "Cheat targets @ tick {}: sprites {}", + tick_count, + format_sprites(sprites, Self::DEBUG_LIST_LIMIT) + ); + } + + pub fn apply(&self, vm: &mut ActionVM, sprites: &mut SpriteSystem, frame: &mut FramePlayer) { + for slot in self.slots.values().filter(|slot| slot.enabled) { + slot.rule.apply(vm, sprites, frame); + } + } +} + +impl FromStr for CheatRule { + type Err = CheatParseError; + + fn from_str(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err(CheatParseError::Empty); + } + + let (target, value) = input.split_once('=').ok_or(CheatParseError::MissingValue)?; + let target = target.trim(); + let value = value.trim().to_string(); + + if let Some(name) = target.strip_prefix("var:") { + let name = name.trim(); + if name.is_empty() { + return Err(CheatParseError::UnknownTarget(target.to_string())); + } + return Ok(Self::Variable { + name: name.to_lowercase(), + value, + }); + } + + if let Some(spec) = target + .strip_prefix("sprite:") + .or_else(|| target.strip_prefix("movie:")) + { + let (name, field) = spec + .rsplit_once('.') + .ok_or(CheatParseError::InvalidSpriteTarget)?; + let name = name.trim(); + if name.is_empty() { + return Err(CheatParseError::InvalidSpriteTarget); + } + return Ok(Self::Sprite { + name: name.to_string(), + field: SpriteField::from_str(field.trim())?, + value, + }); + } + + if let Some(field) = target.strip_prefix("frame:") { + let field = FrameField::from_str(field.trim())?; + return Ok(Self::Frame { field, value }); + } + + Err(CheatParseError::UnknownTarget(target.to_string())) + } +} + +impl CheatRule { + fn apply(&self, vm: &mut ActionVM, sprites: &mut SpriteSystem, frame: &mut FramePlayer) { + match self { + Self::Variable { name, value } => { + vm.vars.insert(name.clone(), value.clone()); + } + Self::Sprite { name, field, value } => { + if let Some(movie) = sprites.get_mut(name) { + field.apply(movie, value); + } + } + Self::Frame { field, value } => field.apply(frame, value), + } + } +} + +impl FromStr for SpriteField { + type Err = CheatParseError; + + fn from_str(input: &str) -> Result { + match input.to_ascii_lowercase().as_str() { + "x" => Ok(Self::X), + "y" => Ok(Self::Y), + "depth" => Ok(Self::Depth), + "frame" | "currentframe" | "current_frame" => Ok(Self::Frame), + "visible" => Ok(Self::Visible), + "playing" => Ok(Self::Playing), + _ => Err(CheatParseError::InvalidSpriteTarget), + } + } +} + +impl SpriteField { + fn apply(self, movie: &mut MovieState, value: &str) { + match self { + Self::X => movie.x = parse_i16(value), + Self::Y => movie.y = parse_i16(value), + Self::Depth => movie.depth = parse_u16(value), + Self::Frame => movie.next_frame = Some(parse_usize(value) as isize), + Self::Visible => movie.visible = parse_bool(value), + Self::Playing => movie.playing = parse_bool(value), + } + } +} + +impl FromStr for FrameField { + type Err = CheatParseError; + + fn from_str(input: &str) -> Result { + match input.to_ascii_lowercase().as_str() { + "goto" | "current" | "currentframe" | "current_frame" => Ok(Self::Goto), + "playing" => Ok(Self::Playing), + _ => Err(CheatParseError::InvalidFrameTarget), + } + } +} + +impl FrameField { + fn apply(self, frame: &mut FramePlayer, value: &str) { + match self { + Self::Goto => frame.goto(parse_u32(value), frame.playing), + Self::Playing => frame.playing = parse_bool(value), + } + } +} + +fn format_vars(vars: &HashMap, filter: Option<&str>, limit: usize) -> String { + if vars.is_empty() { + return "[]".to_string(); + } + + let mut entries: Vec<_> = vars + .iter() + .filter(|(name, _)| filter.is_none_or(|pattern| glob_matches(pattern, name))) + .collect(); + entries.sort_by_key(|(name, _)| *name); + let omitted = entries.len().saturating_sub(limit); + let mut parts: Vec = entries + .into_iter() + .take(limit) + .map(|(name, value)| format!("{name}={value}")) + .collect(); + if omitted > 0 { + parts.push(format!("... {omitted} more")); + } + format!("[{}]", parts.join(", ")) +} + +fn glob_matches(pattern: &str, value: &str) -> bool { + let pattern = pattern.as_bytes(); + let value = value.as_bytes(); + let (mut pattern_index, mut value_index) = (0, 0); + let (mut star_index, mut star_value_index) = (None, 0); + + while value_index < value.len() { + if pattern_index < pattern.len() + && (pattern[pattern_index] == b'?' || pattern[pattern_index] == value[value_index]) + { + pattern_index += 1; + value_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star_index = Some(pattern_index); + pattern_index += 1; + star_value_index = value_index; + } else if let Some(star) = star_index { + pattern_index = star + 1; + star_value_index += 1; + value_index = star_value_index; + } else { + return false; + } + } + + pattern[pattern_index..].iter().all(|byte| *byte == b'*') +} + +fn format_sprites(sprites: &SpriteSystem, limit: usize) -> String { + let mut entries: Vec<_> = sprites.iter().collect(); + if entries.is_empty() { + return "[]".to_string(); + } + + entries.sort_by_key(|(name, _)| *name); + let omitted = entries.len().saturating_sub(limit); + let mut parts: Vec = entries + .into_iter() + .take(limit) + .map(|(name, movie)| { + format!( + "{}(movie={} x={} y={} depth={} frame={} visible={} playing={} next={})", + name, + movie.movie, + movie.x, + movie.y, + movie.depth, + movie.frame, + movie.visible, + movie.playing, + format_option(movie.next_frame) + ) + }) + .collect(); + if omitted > 0 { + parts.push(format!("... {omitted} more")); + } + format!("[{}]", parts.join(", ")) +} + +fn format_option(value: Option) -> String { + value.map_or_else(|| "none".to_string(), |value| value.to_string()) +} +fn parse_bool(value: &str) -> bool { + match value.trim().to_ascii_lowercase().as_str() { + "" | "0" | "false" | "off" | "no" => false, + "1" | "true" | "on" | "yes" => true, + _ => parse_i64(value) != 0, + } +} + +fn parse_i64(value: &str) -> i64 { + value.trim().parse::().unwrap_or(0.0) as i64 +} + +fn parse_i16(value: &str) -> i16 { + parse_i64(value).clamp(i16::MIN as i64, i16::MAX as i64) as i16 +} + +fn parse_u16(value: &str) -> u16 { + parse_i64(value).clamp(0, u16::MAX as i64) as u16 +} + +fn parse_u32(value: &str) -> u32 { + parse_i64(value).clamp(0, u32::MAX as i64) as u32 +} + +fn parse_usize(value: &str) -> usize { + parse_i64(value).max(0) as usize +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_variable_cheat() { + let rule = CheatRule::from_str("var:Lives=99").unwrap(); + assert_eq!( + rule, + CheatRule::Variable { + name: "lives".to_string(), + value: "99".to_string() + } + ); + } + + #[test] + fn parses_sprite_cheat() { + let rule = CheatRule::from_str("sprite:player.visible=0").unwrap(); + assert_eq!( + rule, + CheatRule::Sprite { + name: "player".to_string(), + field: SpriteField::Visible, + value: "0".to_string() + } + ); + } + + #[test] + fn configures_debug_logging() { + let mut cheats = CheatManager::new(); + assert!(!cheats.debug_logging_enabled()); + assert_eq!( + cheats.debug_interval_frames(), + CheatManager::DEFAULT_DEBUG_INTERVAL_FRAMES + ); + + cheats.set_debug_logging(true, 0); + assert!(cheats.debug_logging_enabled()); + assert_eq!(cheats.debug_interval_frames(), 1); + + cheats.set_debug_variable_filter(Some(" p_* ")); + assert_eq!(cheats.debug_variable_filter(), Some("p_*")); + cheats.set_debug_variable_filter(Some("")); + assert_eq!(cheats.debug_variable_filter(), None); + } + + #[test] + fn formats_discoverable_targets() { + let mut vars = HashMap::new(); + vars.insert("lives".to_string(), "3".to_string()); + vars.insert("p_hp".to_string(), "10".to_string()); + assert_eq!(format_vars(&vars, None, 64), "[lives=3, p_hp=10]"); + assert_eq!(format_vars(&vars, Some("p_*"), 64), "[p_hp=10]"); + + let mut sprites = SpriteSystem::new(); + sprites.insert("hero".to_string(), MovieState::new(7, 12, 34, 5)); + let formatted = format_sprites(&sprites, 64); + assert!(formatted.contains("hero(movie=7")); + assert!(formatted.contains("x=12")); + assert!(formatted.contains("y=34")); + } + + #[test] + fn matches_debug_variable_globs() { + assert!(glob_matches("p_*", "p_hp")); + assert!(glob_matches("e_hp?", "e_hp0")); + assert!(glob_matches("*score*", "highscore")); + assert!(!glob_matches("p_*", "player_hp")); + assert!(!glob_matches("e_hp?", "e_hp10")); + } + + #[test] + fn applies_enabled_slots() { + let mut cheats = CheatManager::new(); + cheats.set_slot(0, true, "var:score=1000").unwrap(); + cheats.set_slot(1, false, "var:score=0").unwrap(); + + let mut vm = ActionVM::new(); + let mut sprites = SpriteSystem::new(); + let mut frame = FramePlayer::new(); + cheats.apply(&mut vm, &mut sprites, &mut frame); + + assert_eq!(vm.vars.get("score").map(String::as_str), Some("1000")); + } + + #[test] + fn applies_sprite_and_frame_cheats() { + let mut cheats = CheatManager::new(); + cheats.set_slot(0, true, "sprite:hero.x=42").unwrap(); + cheats + .set_slot(1, true, "sprite:hero.playing=false") + .unwrap(); + cheats.set_slot(2, true, "frame:goto=7").unwrap(); + cheats.set_slot(3, true, "frame:playing=on").unwrap(); + + let mut vm = ActionVM::new(); + let mut sprites = SpriteSystem::new(); + sprites.insert("hero".to_string(), MovieState::new(1, 0, 0, 0)); + let mut frame = FramePlayer::new(); + cheats.apply(&mut vm, &mut sprites, &mut frame); + + let hero = sprites.get("hero").unwrap(); + assert_eq!(hero.x, 42); + assert!(!hero.playing); + assert_eq!(frame.next_frame, Some(7)); + assert!(frame.playing); + assert!(frame.playing); + } +} diff --git a/crates/native32emu-core/src/emulator.rs b/crates/native32emu-core/src/emulator.rs index 62c3639..72ed059 100644 --- a/crates/native32emu-core/src/emulator.rs +++ b/crates/native32emu-core/src/emulator.rs @@ -2,6 +2,7 @@ use crate::action_vm::{ActionProp, ActionVM, VmHost}; use crate::audio_engine::AudioEngine; +use crate::cheats::CheatManager; use crate::content_loader::ContentLoader; use crate::file_loader::{FrameObject, Native32Reader, ObjectType}; use crate::frame_player::FramePlayer; @@ -40,6 +41,7 @@ pub struct Emulator { pub renderer: Renderer, pub input: InputHandler, pub save_manager: SaveManager, + pub cheats: CheatManager, pub content_loader: ContentLoader, /// Front-end menu (FHUI) directory browser for the game-list host calls. pub file_browser: crate::file_browser::FileBrowser, @@ -116,6 +118,7 @@ impl Emulator { renderer: Renderer::new(resolution.0, resolution.1), input: InputHandler::new(), save_manager, + cheats: CheatManager::new(), content_loader: ContentLoader::new(), file_browser: crate::file_browser::FileBrowser::new(), menu_context: None, @@ -218,6 +221,7 @@ impl Emulator { // of the normal timeline. if self.is_cutscene_active() { self.cutscene_tick(); + self.apply_cheats(); self.time_ms += 1000 / 30; return; } @@ -356,9 +360,25 @@ impl Emulator { } } + self.apply_cheats(); self.time_ms += 1000 / 30; } + pub fn set_cheat_debug_logging(&mut self, enabled: bool, interval_frames: u64) { + self.cheats.set_debug_logging(enabled, interval_frames); + } + + pub fn set_cheat_debug_variable_filter(&mut self, filter: Option<&str>) { + self.cheats.set_debug_variable_filter(filter); + } + + fn apply_cheats(&mut self) { + self.cheats + .apply(&mut self.vm, &mut self.sprites, &mut self.frame_player); + self.cheats + .maybe_log_targets(self.tick_count, &self.vm, &self.sprites, &self.frame_player); + } + /// Whether a cutscene video is currently playing or queued. pub fn is_cutscene_active(&self) -> bool { self.video_player.is_some() || !self.pending_videos.is_empty() diff --git a/crates/native32emu-core/src/lib.rs b/crates/native32emu-core/src/lib.rs index a19ea45..e002f69 100644 --- a/crates/native32emu-core/src/lib.rs +++ b/crates/native32emu-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod action_vm; pub mod actions; pub mod archive_loader; pub mod audio_engine; +pub mod cheats; pub mod content_loader; pub mod dat_loader; pub mod des_constants; diff --git a/crates/native32emu-core/src/sprite_system.rs b/crates/native32emu-core/src/sprite_system.rs index 52d8fc7..371aeb4 100644 --- a/crates/native32emu-core/src/sprite_system.rs +++ b/crates/native32emu-core/src/sprite_system.rs @@ -108,7 +108,7 @@ impl SpriteSystem { /// Advance movie frames. Movies run at half the main framerate. pub fn tick(&mut self, tick_count: u64) { - for (_, movie) in self.sprites.iter_mut() { + for movie in self.sprites.values_mut() { if !movie.playing || movie.next_frame.is_some() { continue; } diff --git a/crates/native32emu-libretro/native32emu_libretro.info b/crates/native32emu-libretro/native32emu_libretro.info index a828ef5..da26a4e 100644 --- a/crates/native32emu-libretro/native32emu_libretro.info +++ b/crates/native32emu-libretro/native32emu_libretro.info @@ -18,7 +18,7 @@ database = "Native32" supports_no_game = "false" firmware_count = 0 savestate = "true" -cheats = "false" +cheats = "true" input_descriptors = "true" memory_descriptors = "false" libretro_saves = "false" diff --git a/crates/native32emu-libretro/src/libretro/api.rs b/crates/native32emu-libretro/src/libretro/api.rs index e62272c..aacdb4e 100644 --- a/crates/native32emu-libretro/src/libretro/api.rs +++ b/crates/native32emu-libretro/src/libretro/api.rs @@ -344,19 +344,48 @@ pub extern "C" fn retro_get_memory_size(_id: u32) -> usize { } // ============================================================ -// Cheat functions (not implemented) +// Cheat functions // ============================================================ /// Reset cheat codes #[no_mangle] pub extern "C" fn retro_cheat_reset() { - // Not implemented + unsafe { + if let Some(emu) = EMULATOR.as_mut() { + emu.cheats.clear(); + } + } } /// Set a cheat code #[no_mangle] -pub extern "C" fn retro_cheat_set(_index: u32, _enabled: bool, _code: *const std::ffi::c_char) { - // Not implemented +pub extern "C" fn retro_cheat_set(index: u32, enabled: bool, code: *const std::ffi::c_char) { + unsafe { + let Some(emu) = EMULATOR.as_mut() else { + return; + }; + + let code = if code.is_null() { + "" + } else { + match CStr::from_ptr(code).to_str() { + Ok(code) => code, + Err(e) => { + log::warn!("Ignoring invalid UTF-8 cheat at slot {}: {}", index, e); + return; + } + } + }; + + if let Err(e) = emu.cheats.set_slot(index, enabled, code) { + log::warn!( + "Ignoring invalid cheat at slot {} ('{}'): {}", + index, + code, + e + ); + } + } } /// Load a special game (not used) diff --git a/crates/native32emu/src/main.rs b/crates/native32emu/src/main.rs index 1b1c0ec..553a81c 100644 --- a/crates/native32emu/src/main.rs +++ b/crates/native32emu/src/main.rs @@ -118,6 +118,14 @@ fn main() -> Result<()> { let key_remappings = cli.parse_key_remappings(); emu.input.remap(&key_remappings); + for cheat in &cli.cheats { + if let Err(e) = emu.cheats.add_code(cheat) { + log::warn!("Ignoring invalid cheat '{}': {}", cheat, e); + } + } + + emu.set_cheat_debug_logging(cli.debug_cheats, cli.cheat_debug_interval); + emu.set_cheat_debug_variable_filter(cli.cheat_debug_filter.as_deref()); // Apply typematic key-repeat timing (matches the hardware keypad driver). emu.input .set_repeat_timing(cli.repeat_delay, cli.repeat_period); diff --git a/crates/native32emu/src/standalone/cli.rs b/crates/native32emu/src/standalone/cli.rs index 23d5a79..7b9e687 100644 --- a/crates/native32emu/src/standalone/cli.rs +++ b/crates/native32emu/src/standalone/cli.rs @@ -41,6 +41,22 @@ pub struct Cli { #[arg(long = "remap", value_name = "KEYCODE:KEY")] pub key_remappings: Vec, + /// Cheat rule to enable, e.g. "var:lives=99" or "sprite:player.visible=0" + #[arg(long = "cheat", value_name = "RULE")] + pub cheats: Vec, + + /// Log VM variables and sprite targets periodically to help build cheat rules + #[arg(long = "debug-cheats")] + pub debug_cheats: bool, + + /// Frames between cheat target debug logs + #[arg(long = "cheat-debug-interval", default_value = "30", value_parser = clap::value_parser!(u64).range(1..))] + pub cheat_debug_interval: u64, + + /// Only log VM variable names matching this glob pattern (supports '*' and '?') + #[arg(long = "cheat-debug-filter", value_name = "GLOB")] + pub cheat_debug_filter: Option, + /// Take a screenshot after N frames and exit (saves as PNG) #[arg(short = 'S', long = "screenshot", value_name = "PATH")] pub screenshot: Option, diff --git a/docs/Cheat-Codes.md b/docs/Cheat-Codes.md new file mode 100644 index 0000000..07d3f67 --- /dev/null +++ b/docs/Cheat-Codes.md @@ -0,0 +1,33 @@ +# Game Cheat Codes + +This document lists verified game-specific cheat rules. The same codes work in +the [standalone emulator](Standalone-Emulator.md#cheats) and the +[RetroArch core](RetroArch-Core.md#cheats). + +## Blades of Red (赤刃) + +| Effect | Code | Notes | +|---|---|---| +| Infinite health | `var:p_hp=96` | Keeps health at its normal maximum value of 96 | + +## Gun Fire (枪火) + +| Effect | Code | Notes | +|---|---|---| +| Infinite health | `var:he_l=104` | Keeps the player health value at its observed maximum of 104 | +| Set score to 999999 | `var:he_score=999999` | Forces the displayed score to 999999 | + +## Metal Storm (钢铁风暴) + +| Effect | Code | Notes | +|---|---|---| +| Health lock | `var:pl_hp=60` | Keeps the player health variable at 60 | + +## Storm (风暴之翼) + +| Effect | Code | Notes | +|---|---|---| +| Health lock | `var:p_hp=9` | Keeps the player health variable at 9 | +| Set lives to 9 | `var:p_life=9` | Keeps the player life count at 9 | +| Set bombs to 9 | `var:p_bomb=9` | Keeps the bomb count at 9 | +| Set score to 999999 | `var:p_score=999999` | Forces the displayed score to 999999 | diff --git a/docs/Core-Options.md b/docs/Core-Options.md deleted file mode 100644 index 969460b..0000000 --- a/docs/Core-Options.md +++ /dev/null @@ -1,16 +0,0 @@ -# Libretro Core Options - -When running Native32Emu as a libretro core, these options are configurable -from RetroArch's *Quick Menu → Core Options*. Changes apply live without -reloading the game. - -| Option | Values | Default | Effect | -|--------|--------|---------|--------| -| Audio Volume (%) | 0–100 (steps of 10) | 100 | Output volume; 0 mutes | -| Key auto-repeat delay (frames) | 2–30 | 12 | Frames a held key waits before auto-repeat starts (~0.4s at 30fps) | -| Key auto-repeat period (frames) | 1–10 | 3 | Frames between auto-repeat pulses (~0.1s at 30fps) | -| Swap A/B buttons | disabled / enabled | disabled | Swaps the A and B button actions | -| Auto-skip cutscene videos | disabled / enabled | disabled | Skips logo/intro/cutscene videos automatically | - -The auto-repeat options reproduce the original keypad's typematic behavior that -some games rely on (e.g. walk vs. run on a held direction). diff --git a/docs/RetroArch-Core.md b/docs/RetroArch-Core.md new file mode 100644 index 0000000..6489d8f --- /dev/null +++ b/docs/RetroArch-Core.md @@ -0,0 +1,104 @@ +# RetroArch Core + +Native32Emu is available as a libretro core for RetroArch on Windows, Linux, +macOS, Android, and iOS. This guide covers installation, loading content, +supported frontend features, controls, core options, and cheats. + +## Installation + +### Online Updater + +1. Open RetroArch. +2. Go to **Main Menu > Online Updater > Core Downloader**. +3. Select **Native32 (Native32Emu)**. + +### Manual Installation + +Download the core from the +[Releases](https://github.com/jiangxincode/Native32Emu/releases) page. Copy +`native32emu_libretro.dll` (`.so` on Linux or `.dylib` on macOS) to +RetroArch's `cores/` directory, and copy `native32emu_libretro.info` to its +`info/` directory. + +## Loading Games + +1. Open RetroArch and select **Load Core > Native32 (Native32Emu)**. +2. Select **Load Content**. +3. Choose a `.smf`, `.sgm`, `.ssl`, or `.zip` file. + +## Mobile Platforms + +The same libretro core architecture is available on mobile platforms, with +platform-specific installation requirements: + +- [Android Libretro Core](Android-Libretro-Core.md) +- [iOS Libretro Core](iOS-Libretro-Core.md) + +## Supported Features + +- Video output using the XRGB8888 pixel format +- Stereo audio output with looping MP3 music and RAW PCM sound effects +- RetroPad input handling +- `.smf`, `.sgm`, `.ssl`, and `.zip` content loading +- Save states +- Live core options +- Emulator-handled cheats through RetroArch's cheat interface + +## RetroPad Button Mapping + +| RetroPad Button | Native32 Keycode | Action | +|---|---|---| +| D-Pad Left | `0x0200` | Left | +| D-Pad Right | `0x0400` | Right | +| D-Pad Up | `0x1c00` | Up | +| D-Pad Down | `0x1e00` | Down | +| A (SNES East) | `0x8800` | B / Menu | +| B (SNES South) | `0x4000` | A | + +## Core Options + +Options are available from **Quick Menu > Core Options**. Changes apply live +without reloading the game. + +| Option | Values | Default | Effect | +|---|---|---|---| +| Audio Volume (%) | 0-100 (steps of 10) | 100 | Output volume; 0 mutes | +| Key auto-repeat delay (frames) | 2-30 | 12 | Frames a held key waits before auto-repeat starts (~0.4s at 30fps) | +| Key auto-repeat period (frames) | 1-10 | 3 | Frames between auto-repeat pulses (~0.1s at 30fps) | +| Swap A/B buttons | disabled / enabled | disabled | Swaps the A and B button actions | +| Auto-skip cutscene videos | disabled / enabled | disabled | Skips logo, intro, and cutscene videos automatically | + +The auto-repeat options reproduce the original keypad's typematic behavior, +which some games use to distinguish walking from running while a direction is +held. + +## Cheats + +Native32Emu uses RetroArch's **Emulator** cheat handler. Native32Emu rules are +sent to the core through the libretro cheat interface; they are not RetroArch +memory-address cheats. The code strings use the same syntax as the standalone +emulator: + +- `var:=` - force a Native32 VM variable. +- `sprite:.=` or `movie:.=` - force a + movie sprite field. Supported fields are `x`, `y`, `depth`, `frame`, + `visible`, and `playing`. +- `frame:goto=` - force the main timeline to jump to a frame. +- `frame:playing=` - force the main timeline play/pause state. + +Boolean values accept `1`/`0`, `true`/`false`, `on`/`off`, and `yes`/`no`. + +1. Load a game and open **Quick Menu > Cheats**. +2. Select **Add New Code to Top** or **Add New Code to Bottom**. +3. Open the new entry and set **Handler** to **Emulator**. +4. Enter a Native32Emu rule in **Code**, such as `var:p_hp=96`. +5. Set **Enabled** to **On**. +6. Return to the Cheats menu and select **Apply Changes**. + +Use the standalone emulator's cheat debug options when you need to discover +variable and sprite names. Verified game-specific rules are listed in +[Game Cheat Codes](Cheat-Codes.md). + +See the official +[RetroArch cheat code guide](https://docs.libretro.com/guides/cheat-codes/) +for more information about emulator-handled and RetroArch-handled cheats. diff --git a/docs/CLI-Options.md b/docs/Standalone-Emulator.md similarity index 68% rename from docs/CLI-Options.md rename to docs/Standalone-Emulator.md index 6262cd6..ead4c1f 100644 --- a/docs/CLI-Options.md +++ b/docs/Standalone-Emulator.md @@ -1,8 +1,40 @@ -# Command-line Options +# Standalone Emulator -This document describes every command-line option of the standalone -`native32-emu` binary, the default key mappings, key remapping, and how to tune -the keypad auto-repeat (which controls mechanics such as walk → run). +This guide covers installing and running the standalone `native32-emu` binary, +loading individual games or ZIP packages, keyboard controls, cheats, display +scaling, and every command-line option. + +## Installation + +Download the latest standalone binary for your platform from the +[Releases](https://github.com/jiangxincode/Native32Emu/releases) page. + +You can also build it from source: + +```bash +cargo build -p native32emu --release +``` + +The binary is produced at `target/release/native32-emu` (`.exe` on Windows). + +## Loading Games + +The standalone emulator accepts `.smf`, `.sgm`, `.ssl`, and `.zip` files. + +```bash +# Load a game directly +native32-emu path/to/game.smf + +# Load a ZIP package containing FHUI.smf +native32-emu path/to/game.zip +``` + +### ZIP Mode + +When loading from a `.zip` file, the emulator starts the FHUI menu. Selecting a +game launches it; pressing **ESC** during gameplay returns to the menu. Pressing +**ESC** on the menu itself exits the emulator. When loading a `.smf` file +directly, **ESC** exits as usual. You can always print the built-in help with: @@ -28,6 +60,10 @@ native32-emu [OPTIONS] | `--auto-skip-cutscenes` | flag | off | Automatically skip logo/intro/cutscene videos instead of playing them. | | `--debug` | flag | off | Enable debug/development mode. | | `--remap ` | string | — | Remap a Native32 keycode to a physical key. Repeatable. | +| `--cheat ` | string | — | Enable a cheat rule. Repeatable. | +| `--debug-cheats` | flag | off | Periodically log VM variables and sprites to help build cheat rules. | +| `--cheat-debug-interval ` | integer | `30` | Frames between cheat target debug logs. | +| `--cheat-debug-filter ` | string | all | Only log VM variable names matching a case-sensitive glob (`*` and `?`). | | `-S, --screenshot ` | path | — | Render some frames, save a PNG screenshot, then exit. | | `--screenshot-frames ` | integer | `30` | Number of frames to run before the screenshot is taken. | | `--show-gamepad` | flag | off | Draw an on-screen virtual gamepad overlay showing pressed keys. | @@ -68,6 +104,31 @@ Supported key names: `a`–`z`, `0`–`9`, `space`, `enter`/`return`, `left`, `right`, `up`, `down`, `escape`/`esc`. Invalid entries are ignored with a warning. +## Cheats + +Verified game-specific rules are listed in [Game Cheat Codes](Cheat-Codes.md). + +Use `--cheat ` to apply a cheat rule. The option is repeatable, and rules +are applied every emulation tick after normal game logic. The RetroArch core +accepts the same rules through its emulator-handled cheat interface. + +Supported rule forms: + +- `var:=` — force a Native32 VM variable. +- `sprite:.=` or `movie:.=` — force a movie sprite field. Supported fields: `x`, `y`, `depth`, `frame`, `visible`, `playing`. +- `frame:goto=` — force the main timeline to jump to a frame. +- `frame:playing=` — force the main timeline play/pause state. + +Boolean values accept `1`/`0`, `true`/`false`, `on`/`off`, and `yes`/`no`. + +To discover usable targets, run with `--debug-cheats`. The log lists current VM variables and sprite names/fields that can be used in `var:` and `sprite:` rules. Use `--cheat-debug-filter` to restrict variable names with a case-sensitive glob. + +```bash +native32-emu --cheat "var:lives=99" game.smf +native32-emu --cheat "sprite:player.visible=0" game.smf +native32-emu --debug-cheats --cheat-debug-filter "p_*" game.smf +``` + ## Keypad Auto-Repeat (`--repeat-delay` / `--repeat-period`) The original hardware keypad does not report a held key as pressed on every diff --git a/site/index.html b/site/index.html index 4ab4bc4..662c6b7 100644 --- a/site/index.html +++ b/site/index.html @@ -435,8 +435,8 @@

社区

diff --git a/site/js/main.js b/site/js/main.js index 3d88f99..a767a3b 100644 --- a/site/js/main.js +++ b/site/js/main.js @@ -209,8 +209,8 @@ 'footer-contributing': '贡献指南', 'footer-community': '社区', 'footer-docs': '文档', - 'footer-cli': 'CLI 选项', - 'footer-core': '核心选项', + 'footer-cli': '独立模拟器', + 'footer-core': 'RetroArch Core', 'footer-gamelist': '游戏列表', 'footer-copy': 'BSD 3-Clause License © 2025 Aloys. Built with 🦀 Rust.' }, @@ -303,8 +303,8 @@ 'footer-contributing': 'Contributing', 'footer-community': 'Community', 'footer-docs': 'Docs', - 'footer-cli': 'CLI Options', - 'footer-core': 'Core Options', + 'footer-cli': 'Standalone Emulator', + 'footer-core': 'RetroArch Core', 'footer-gamelist': 'Game List', 'footer-copy': 'BSD 3-Clause License © 2025 Aloys. Built with 🦀 Rust.' }