feat(core): add whole-machine save states with F5/F9 window slots
Machine::save_state and load_state encode the CPU, every RAM, and every device through Snapshot impls into a versioned, checksummed format. Rejected loads leave the machine unchanged. The ROM window saves to and loads from one state file with F5 and F9.
This commit is contained in:
parent
be9c676476
commit
fd73788735
55 changed files with 5157 additions and 20 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
pub(crate) mod rtc_clock;
|
||||
pub(crate) mod save_file;
|
||||
pub(crate) mod state_file;
|
||||
pub mod suite;
|
||||
pub mod window;
|
||||
|
||||
|
|
@ -22,7 +23,7 @@ use gba_core::{
|
|||
|
||||
const MAX_STEPS: u64 = 100_000_000;
|
||||
const MAX_FRAMES: u64 = 100_000;
|
||||
const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER]); speed: integer 1..=16 (default 1, audio muted above 1); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options";
|
||||
const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER] [--state-file PATH]); speed: integer 1..=16 (default 1, audio muted above 1); steps: 1..=100000000, frames: 1..=100000; state file (window only): F5 saves and F9 loads a save state, default is the ROM path with a .state extension; do not combine with demo or help options";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
|
|
@ -39,6 +40,7 @@ pub struct Options {
|
|||
audio: bool,
|
||||
speed: u32,
|
||||
save_file: Option<PathBuf>,
|
||||
state_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
||||
|
|
@ -52,6 +54,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
|||
let mut hardware = CartridgeHardware::None;
|
||||
let mut save_device = SaveDevice::None;
|
||||
let mut save_file = None;
|
||||
let mut state_file = None;
|
||||
let mut args = args.iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--rom" && path.is_none() {
|
||||
|
|
@ -72,6 +75,10 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
|||
save_file = Some(PathBuf::from(
|
||||
args.next().filter(|v| !v.is_empty()).ok_or_else(invalid)?,
|
||||
));
|
||||
} else if arg == "--state-file" && state_file.is_none() {
|
||||
state_file = Some(PathBuf::from(
|
||||
args.next().filter(|v| !v.is_empty()).ok_or_else(invalid)?,
|
||||
));
|
||||
} else if arg == "--speed" && speed.is_none() {
|
||||
let value = args
|
||||
.next()
|
||||
|
|
@ -118,7 +125,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
|||
if save_file.is_some() && save_device == SaveDevice::None {
|
||||
return Err(invalid());
|
||||
}
|
||||
if (audio || speed.is_some()) && !window {
|
||||
if (audio || speed.is_some() || state_file.is_some()) && !window {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mode = match (window, steps, frames) {
|
||||
|
|
@ -132,6 +139,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
|||
hardware,
|
||||
save_device,
|
||||
save_file,
|
||||
state_file,
|
||||
audio,
|
||||
speed: speed.unwrap_or(1),
|
||||
})
|
||||
|
|
@ -340,7 +348,12 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
|
|||
audio: options.audio,
|
||||
speed: options.speed,
|
||||
},
|
||||
save_file.as_mut(),
|
||||
crate::desktop::RomPersistence {
|
||||
save_file: save_file.as_mut(),
|
||||
state_path: options
|
||||
.state_file
|
||||
.unwrap_or_else(|| options.path.with_extension("state")),
|
||||
},
|
||||
writer,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
101
crates/desktop/src/cartridge/state_file.rs
Normal file
101
crates/desktop/src/cartridge/state_file.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
//! Save-state files for the ROM window. The core never touches the file system.
|
||||
//!
|
||||
//! A state is replaced atomically: bytes go to a unique sibling file that is
|
||||
//! synced and renamed over the destination, so an interrupted write leaves the
|
||||
//! previous state intact.
|
||||
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
/// A state is under a megabyte. Refuse anything far larger before reading it.
|
||||
const MAX_STATE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
fn temporary_sibling(path: &Path) -> io::Result<PathBuf> {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let name = path.file_name().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "state path has no file name")
|
||||
})?;
|
||||
let mut name = name.to_os_string();
|
||||
name.push(format!(
|
||||
".tmp.{}.{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
Ok(path.with_file_name(name))
|
||||
}
|
||||
|
||||
pub(crate) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let temporary = temporary_sibling(path)?;
|
||||
let result = (|| {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(&temporary)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
fs::rename(&temporary, path)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn read(path: &Path) -> io::Result<Vec<u8>> {
|
||||
let file = File::open(path)?;
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::other("state path must be a regular file"));
|
||||
}
|
||||
if metadata.len() > MAX_STATE_BYTES {
|
||||
return Err(io::Error::other("state file is too large"));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||||
file.take(MAX_STATE_BYTES + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 > MAX_STATE_BYTES {
|
||||
return Err(io::Error::other("state file grew while reading"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("gba-state-file-{name}-{}", std::process::id()));
|
||||
fs::create_dir_all(&directory).unwrap();
|
||||
directory.join("slot.state")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_replaces_the_previous_state_and_leaves_no_temporary_files() {
|
||||
let path = scratch("replace");
|
||||
write(&path, b"first").unwrap();
|
||||
write(&path, b"second state").unwrap();
|
||||
assert_eq!(read(&path).unwrap(), b"second state");
|
||||
let leftovers = fs::read_dir(path.parent().unwrap()).unwrap().count();
|
||||
assert_eq!(leftovers, 1);
|
||||
fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_oversized_files_are_errors() {
|
||||
let path = scratch("errors");
|
||||
assert_eq!(read(&path).unwrap_err().kind(), io::ErrorKind::NotFound);
|
||||
let file = File::create(&path).unwrap();
|
||||
file.set_len(MAX_STATE_BYTES + 1).unwrap();
|
||||
assert!(read(&path).is_err());
|
||||
fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ fn options_require_both_flags_in_either_order() {
|
|||
hardware: CartridgeHardware::None,
|
||||
save_device: SaveDevice::None,
|
||||
save_file: None,
|
||||
state_file: None,
|
||||
audio: false,
|
||||
speed: 1,
|
||||
}
|
||||
|
|
@ -136,6 +137,41 @@ fn speed_rejects_invalid_values_duplicate_flags_and_nonwindow_modes() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_file_is_a_nonempty_window_only_path() {
|
||||
let accepted = parse_args(&args(&[
|
||||
"--state-file",
|
||||
"slot.state",
|
||||
"--rom",
|
||||
"test.gba",
|
||||
"--window",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(accepted.state_file, Some(PathBuf::from("slot.state")));
|
||||
assert_eq!(
|
||||
parse_args(&args(&["--rom", "test.gba", "--window"]))
|
||||
.unwrap()
|
||||
.state_file,
|
||||
None
|
||||
);
|
||||
for arguments in [
|
||||
vec!["--rom", "test.gba", "--steps", "100", "--state-file", "a"],
|
||||
vec!["--rom", "test.gba", "--window", "--state-file"],
|
||||
vec!["--rom", "test.gba", "--window", "--state-file", ""],
|
||||
vec![
|
||||
"--rom",
|
||||
"test.gba",
|
||||
"--window",
|
||||
"--state-file",
|
||||
"a",
|
||||
"--state-file",
|
||||
"b",
|
||||
],
|
||||
] {
|
||||
assert!(parse_args(&args(&arguments)).is_err(), "{arguments:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_is_opt_in_window_only_and_cannot_be_duplicated() {
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ use std::{
|
|||
io::{self, Write},
|
||||
};
|
||||
|
||||
use gba_core::{bios, input::Buttons, machine::Machine, memory::MemoryError, video::Framebuffer};
|
||||
use gba_core::{
|
||||
bios, input::Buttons, machine::Machine, memory::MemoryError, snapshot::SnapshotError,
|
||||
video::Framebuffer,
|
||||
};
|
||||
|
||||
use super::{write_state, Stats};
|
||||
|
||||
|
|
@ -62,6 +65,22 @@ impl Session {
|
|||
self.frames
|
||||
}
|
||||
|
||||
/// Encode the machine at the current step boundary. Host counters are not part of it.
|
||||
pub fn save_state(&self) -> Result<Vec<u8>, SnapshotError> {
|
||||
self.machine.save_state()
|
||||
}
|
||||
|
||||
/// Restore a state and show its last captured frame at once. Any error other than
|
||||
/// `SnapshotError::RestoreFailed` leaves the running machine untouched.
|
||||
pub fn load_state(&mut self, bytes: &[u8]) -> Result<(), SnapshotError> {
|
||||
self.machine.load_state(bytes)?;
|
||||
self.vblank = self.machine.memory().display_position().vblanks;
|
||||
self.frame_steps = 0;
|
||||
// A captured frame that failed to render is reported at the next VBlank instead.
|
||||
let _ = self.machine.memory().present_frame(&mut self.frame);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sample input, then advance at most one bounded slice or one VBlank event.
|
||||
/// STOP preserves clocks and the last image while allowing later input to wake.
|
||||
/// Diagnostics preserve the last presented image and earlier successful steps.
|
||||
|
|
|
|||
|
|
@ -301,3 +301,26 @@ fn report_contains_capture_count_and_propagates_output_errors() {
|
|||
io::ErrorKind::BrokenPipe
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_a_state_restores_the_machine_and_shows_its_frame_at_once() {
|
||||
let mut session = original();
|
||||
let pressed = Buttons::default().with(Button::A, true);
|
||||
next_frame(&mut session, pressed);
|
||||
next_frame(&mut session, pressed);
|
||||
assert_color(&session, 0x001f);
|
||||
let state = session.save_state().unwrap();
|
||||
next_frame(&mut session, Buttons::default());
|
||||
assert_color(&session, 0x7c00);
|
||||
|
||||
session.load_state(&state).unwrap();
|
||||
assert_color(&session, 0x001f);
|
||||
assert_eq!(session.save_state().unwrap(), state);
|
||||
next_frame(&mut session, Buttons::default());
|
||||
assert_color(&session, 0x7c00);
|
||||
|
||||
// A rejected state is an error and leaves the running session untouched.
|
||||
let before = session.save_state().unwrap();
|
||||
assert!(session.load_state(b"not a save state at all").is_err());
|
||||
assert_eq!(session.save_state().unwrap(), before);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub use bitmap::run as run_bitmap;
|
|||
pub use effects::run as run_effects;
|
||||
pub use mosaic::run as run_mosaic;
|
||||
pub use raster::run as run_raster;
|
||||
pub use rom::{run as run_rom, Playback as RomPlayback};
|
||||
pub use rom::{run as run_rom, Persistence as RomPersistence, Playback as RomPlayback};
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
//! Main-thread ROM presentation, physical keyboard input, and optional host audio.
|
||||
|
||||
use std::io::Write;
|
||||
use std::{
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::cartridge::window::{Session, Update};
|
||||
use crate::cartridge::{
|
||||
state_file,
|
||||
window::{Session, Update},
|
||||
};
|
||||
use gba_core::snapshot::SnapshotError;
|
||||
|
||||
/// Host-only playback policy. The CLI validates integer speeds from 1 through 16.
|
||||
pub struct Playback {
|
||||
|
|
@ -11,6 +18,12 @@ pub struct Playback {
|
|||
pub speed: u32,
|
||||
}
|
||||
|
||||
/// Host files for one ROM window: the optional Flash save and the save-state slot.
|
||||
pub struct Persistence<'a> {
|
||||
pub save_file: Option<&'a mut crate::cartridge::save_file::SaveFile>,
|
||||
pub state_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Playback {
|
||||
fn audio_enabled(&self) -> bool {
|
||||
self.audio && self.speed == 1
|
||||
|
|
@ -23,6 +36,49 @@ impl Playback {
|
|||
}
|
||||
}
|
||||
|
||||
/// Write the current state to `path`. A failed save is reported and play continues.
|
||||
fn save_state_slot(session: &Session, path: &Path, writer: &mut impl Write) -> io::Result<()> {
|
||||
let saved = session
|
||||
.save_state()
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|bytes| {
|
||||
state_file::write(path, &bytes)
|
||||
.map(|()| bytes.len())
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
match saved {
|
||||
Ok(length) => writeln!(writer, "State saved: {path:?} ({length} bytes)"),
|
||||
Err(error) => writeln!(writer, "State not saved: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore `path`. Returns whether the state was loaded. Rejected states leave the
|
||||
/// machine running unchanged; only a failure after restoration began is fatal.
|
||||
fn load_state_slot(
|
||||
session: &mut Session,
|
||||
path: &Path,
|
||||
writer: &mut impl Write,
|
||||
) -> Result<bool, Box<dyn Error>> {
|
||||
let bytes = match state_file::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
writeln!(writer, "State not loaded: {path:?}: {error}")?;
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
match session.load_state(&bytes) {
|
||||
Ok(()) => {
|
||||
writeln!(writer, "State loaded: {path:?}")?;
|
||||
Ok(true)
|
||||
}
|
||||
Err(error @ SnapshotError::RestoreFailed(_)) => Err(Box::new(error)),
|
||||
Err(error) => {
|
||||
writeln!(writer, "State not loaded: {error}")?;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_frame_start(previous: Instant, now: Instant, period: Duration, audio: bool) -> Instant {
|
||||
let target = previous + period;
|
||||
if audio && now.saturating_duration_since(target) <= period {
|
||||
|
|
@ -38,9 +94,13 @@ pub fn run(
|
|||
hardware: gba_core::cartridge::CartridgeHardware,
|
||||
save_device: gba_core::cartridge::SaveDevice,
|
||||
playback: Playback,
|
||||
save_file: Option<&mut crate::cartridge::save_file::SaveFile>,
|
||||
persistence: Persistence<'_>,
|
||||
writer: &mut impl Write,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let Persistence {
|
||||
save_file,
|
||||
state_path,
|
||||
} = persistence;
|
||||
let audio_enabled = playback.audio_enabled();
|
||||
let mut session = Session::new(bytes)?;
|
||||
session.set_cartridge_hardware(hardware);
|
||||
|
|
@ -57,11 +117,11 @@ pub fn run(
|
|||
};
|
||||
writeln!(
|
||||
writer,
|
||||
"ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; Escape exits."
|
||||
"ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; F5=save state; F9=load state; Escape exits."
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"Input releases and audio mutes on focus loss. Save files require --save-file and clean exit. Startup frames are not suppressed."
|
||||
"Input releases and audio mutes on focus loss. Save files require --save-file and clean exit; state file: {state_path:?}. Startup frames are not suppressed."
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
|
|
@ -114,6 +174,19 @@ pub fn run(
|
|||
let reported_active = window.is_active();
|
||||
let focused = is_focused(reported_active);
|
||||
let buttons = read_buttons(reported_active, |key| window.is_key_down(key));
|
||||
if focused && window.is_key_pressed(Key::F5, KeyRepeat::No) {
|
||||
save_state_slot(&session, &state_path, writer)?;
|
||||
writer.flush()?;
|
||||
}
|
||||
if focused
|
||||
&& window.is_key_pressed(Key::F9, KeyRepeat::No)
|
||||
&& load_state_slot(&mut session, &state_path, writer)?
|
||||
{
|
||||
writer.flush()?;
|
||||
if let Some(output) = audio.as_ref() {
|
||||
output.clear()?;
|
||||
}
|
||||
}
|
||||
let update = session.update(buttons)?;
|
||||
let stopped = update == Update::Stopped;
|
||||
if let Some(output) = audio.as_ref() {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
|
|||
"--speed",
|
||||
"--save-type",
|
||||
"--save-file",
|
||||
"--state-file",
|
||||
]
|
||||
.iter()
|
||||
.any(|flag| arg == flag)
|
||||
|
|
@ -90,7 +91,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
|
|||
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
|
||||
_ => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER]); or gameboy-rust --test-suite PATH.json",
|
||||
"usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER] [--state-file PATH]); or gameboy-rust --test-suite PATH.json",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +142,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
|
||||
--save-type flash64|flash128 Select nominal Macronix Flash emulation\n\
|
||||
--save-file PATH Load raw Flash image; persist only on clean exit\n\
|
||||
--state-file PATH ROM window save state; F5 saves, F9 loads (default: ROM path + .state)\n\
|
||||
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\n\
|
||||
--cpu-demo Run the terminal-only ARM/Thumb instruction demo\n\
|
||||
--timer-demo Run the timer IRQ demo with nominal cycle costs\n\
|
||||
|
|
@ -175,7 +177,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||
Bitmap pages: Arrows pan; Q rotates; W zooms; Z forces page1; Enter resets; Escape exits.\n\
|
||||
All demos use original test content. ROM mode uses our limited BIOS replacement.\n\
|
||||
Terminal ROM mode has no window, input, or audio. ROM windows offer --audio on macOS. Saves require --save-type and --save-file.\n\
|
||||
ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; Escape exits.\n\
|
||||
ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; F5=save state; F9=load state; Escape exits.\n\
|
||||
Commercial games are not supported."
|
||||
);
|
||||
Ok(())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue