mirror of
https://github.com/jsgroth/jgenesis.git
synced 2026-01-09 06:01:07 +08:00
web: add 32X and GBA support, and a bunch of small-ish improvements/fixes
* save files are now stored in IndexedDB instead of local storage due to IndexedDB having a significantly larger total storage limit (local storage is 5-10 MB depending on browser) * configuration is now stored in local storage (JSON-serialized) rather than resetting to default on every page refresh * input bindings are now configurable * Sega CD and GBA BIOS ROMs are now configured and stored in IndexedDB rather than needing to open them repeatedly for every game * add text noting which consoles are supported in this frontend, since NES and GB/GBC don't work (for now at least)
This commit is contained in:
parent
d89b1588e8
commit
cd87b50c64
13
Cargo.lock
generated
13
Cargo.lock
generated
@ -1112,6 +1112,9 @@ name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deflate64"
|
||||
@ -1229,6 +1232,9 @@ name = "dpi"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dsp"
|
||||
@ -2465,10 +2471,11 @@ name = "jgenesis-web"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bincode",
|
||||
"console_error_panic_hook",
|
||||
"console_log",
|
||||
"gba-config",
|
||||
"gba-core",
|
||||
"genesis-config",
|
||||
"genesis-core",
|
||||
"getrandom 0.3.3",
|
||||
@ -2479,7 +2486,10 @@ dependencies = [
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rfd",
|
||||
"s32x-core",
|
||||
"segacd-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smsgg-config",
|
||||
"smsgg-core",
|
||||
"snes-config",
|
||||
@ -5943,6 +5953,7 @@ dependencies = [
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"serde",
|
||||
"smithay-client-toolkit",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
|
||||
@ -170,6 +170,11 @@ impl GameBoyAdvanceEmulator {
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn has_save_memory(&self) -> bool {
|
||||
self.bus.cartridge.rw_memory().is_some()
|
||||
}
|
||||
|
||||
fn drain_apu<A: AudioOutput>(&mut self, audio_output: &mut A) -> Result<(), A::Err> {
|
||||
self.bus.apu.step_to(self.bus.state.cycles);
|
||||
self.bus.apu.drain_audio_output(audio_output)?;
|
||||
|
||||
@ -156,6 +156,12 @@ impl Sega32XEmulator {
|
||||
self.memory.medium().cartridge().program_title().into()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn has_sram(&self) -> bool {
|
||||
self.memory.medium().cartridge().is_ram_persistent()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn timing_mode(&self) -> TimingMode {
|
||||
|
||||
@ -416,9 +416,15 @@ impl EmulatorTrait for SmsGgEmulator {
|
||||
self.psg = Sn76489::new(self.psg.version());
|
||||
self.input = InputState::new(self.input.region());
|
||||
|
||||
self.ym2413 =
|
||||
self.config.fm_sound_unit_enabled.then(|| ym_opll::new_ym2413(YM2413_CLOCK_INTERVAL));
|
||||
|
||||
self.frame_buffer = FrameBuffer::new();
|
||||
|
||||
self.vdp_mclk_counter = 0;
|
||||
self.psg_mclk_counter = 0;
|
||||
self.frame_count = 0;
|
||||
self.reset_frames_remaining = 0;
|
||||
}
|
||||
|
||||
fn target_fps(&self) -> f64 {
|
||||
|
||||
@ -9,22 +9,24 @@ edition = "2024"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
gba-core = { path = "../../backend/gba-core" }
|
||||
genesis-core = { path = "../../backend/genesis-core" }
|
||||
s32x-core = { path = "../../backend/s32x-core" }
|
||||
segacd-core = { path = "../../backend/segacd-core" }
|
||||
smsgg-core = { path = "../../backend/smsgg-core" }
|
||||
snes-core = { path = "../../backend/snes-core" }
|
||||
|
||||
genesis-config = { path = "../../config/genesis-config" }
|
||||
smsgg-config = { path = "../../config/smsgg-config" }
|
||||
snes-config = { path = "../../config/snes-config" }
|
||||
gba-config = { path = "../../config/gba-config", features = ["serde"] }
|
||||
genesis-config = { path = "../../config/genesis-config", features = ["serde"] }
|
||||
smsgg-config = { path = "../../config/smsgg-config", features = ["serde"] }
|
||||
snes-config = { path = "../../config/snes-config", features = ["serde"] }
|
||||
|
||||
jgenesis-common = { path = "../../common/jgenesis-common" }
|
||||
jgenesis-common = { path = "../../common/jgenesis-common", features = ["serde"] }
|
||||
jgenesis-proc-macros = { path = "../../common/jgenesis-proc-macros" }
|
||||
jgenesis-renderer = { path = "../jgenesis-renderer" }
|
||||
jgenesis-renderer = { path = "../jgenesis-renderer", features = ["serde"] }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
console_error_panic_hook = { workspace = true }
|
||||
console_log = { workspace = true }
|
||||
getrandom = { workspace = true, features = ["wasm_js"] }
|
||||
@ -32,11 +34,13 @@ js-sys = { workspace = true }
|
||||
log = { workspace = true, features = ["release_max_level_info"] }
|
||||
rand = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen = { workspace = true }
|
||||
wasm-bindgen-futures = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
wgpu = { workspace = true, features = ["webgl"] }
|
||||
winit = { workspace = true }
|
||||
winit = { workspace = true, features = ["serde"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies.web-sys]
|
||||
version = "0.3"
|
||||
@ -56,3 +60,5 @@ features = [
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
[dependencies]
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
|
||||
@ -72,12 +72,16 @@
|
||||
#footer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.darken {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading-text">Loading...</div>
|
||||
<div id="header-text" class="hidden hide-fullscreen">
|
||||
Sega Genesis / SNES / Master System / Game Gear emulator
|
||||
jgenesis web
|
||||
</div>
|
||||
<div id="jgenesis" class="hidden">
|
||||
<div id="jgenesis-wasm-and-controls">
|
||||
@ -90,7 +94,8 @@
|
||||
<input type="button" id="upload-save-file" class="save-button" value="Upload save file and reset" disabled>
|
||||
</div>
|
||||
<div class="jgenesis-controls hide-fullscreen">
|
||||
<input type="button" id="open-sega-cd" value="Open Sega CD image (CHD)">
|
||||
<input type="button" id="sega-cd-bios" value="Configure Sega CD BIOS">
|
||||
<input type="button" id="gba-bios" value="Configure GBA BIOS">
|
||||
</div>
|
||||
</div>
|
||||
<div id="jgenesis-config" class="hide-fullscreen">
|
||||
@ -193,21 +198,15 @@
|
||||
<input type="checkbox" id="sms-remove-sprite-limit">
|
||||
<label for="sms-remove-sprite-limit">Remove sprite-per-scanline limit</label>
|
||||
</div>
|
||||
|
||||
<p>Controls</p>
|
||||
<ul>
|
||||
<li>Up/Left/Right/Down: Arrow keys</li>
|
||||
<li>Button 1: S key</li>
|
||||
<li>Button 2: A key</li>
|
||||
<li>Start/Pause: Return key</li>
|
||||
<li>F8: Toggle fullscreen</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="genesis-config" hidden>
|
||||
<fieldset>
|
||||
<legend>Aspect ratio</legend>
|
||||
|
||||
<input type="radio" id="gen-aspect-ntsc" name="gen-aspect-ratio" value="Ntsc" checked>
|
||||
<input type="radio" id="gen-aspect-auto" name="gen-aspect-ratio" value="Auto">
|
||||
<label for="gen-aspect-auto">Auto</label>
|
||||
|
||||
<input type="radio" id="gen-aspect-ntsc" name="gen-aspect-ratio" value="Ntsc">
|
||||
<label for="gen-aspect-ntsc">NTSC</label>
|
||||
|
||||
<input type="radio" id="gen-aspect-pal" name="gen-aspect-ratio" value="Pal">
|
||||
@ -260,20 +259,6 @@
|
||||
<input type="checkbox" id="genesis-render-horizontal-border">
|
||||
<label for="genesis-render-horizontal-border">Render horizontal border</label>
|
||||
</div>
|
||||
|
||||
<p>Controls</p>
|
||||
<ul>
|
||||
<li>Up/Left/Right/Down: Arrow keys</li>
|
||||
<li>A: A key</li>
|
||||
<li>B: S key</li>
|
||||
<li>C: D key</li>
|
||||
<li>X: Q key</li>
|
||||
<li>Y: W key</li>
|
||||
<li>Z: E key</li>
|
||||
<li>Start: Return key</li>
|
||||
<li>Mode: Right Shift key</li>
|
||||
<li>F8: Toggle fullscreen</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="snes-config" hidden>
|
||||
<fieldset>
|
||||
@ -298,20 +283,67 @@
|
||||
<input type="radio" id="snes-audio-hermite" name="snes-audio-interpolation" value="Hermite">
|
||||
<label for="snes-audio-hermite">Cubic Hermite</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div id="gba-config" hidden>
|
||||
<fieldset>
|
||||
<legend>Color correction</legend>
|
||||
|
||||
<p>Controls</p>
|
||||
<input type="radio" id="gba-color-correct-none" name="gba-color-correct" value="None">
|
||||
<label for="gba-color-correct-none">None</label>
|
||||
|
||||
<input type="radio" id="gba-color-correct-gbalcd" name="gba-color-correct" value="GbaLcd">
|
||||
<label for="gba-color-correct-gbalcd">Game Boy Advance LCD</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Audio interpolation</legend>
|
||||
|
||||
<input type="radio" id="gba-audio-interpolation-nearest" name="gba-audio-interpolation" value="NearestNeighbor">
|
||||
<label for="gba-audio-interpolation-nearest">Nearest neighbor</label>
|
||||
|
||||
<input type="radio" id="gba-audio-interpolation-cubic" name="gba-audio-interpolation" value="CubicHermite">
|
||||
<label for="gba-audio-interpolation-cubic">Cubic Hermite</label>
|
||||
|
||||
<input type="radio" id="gba-audio-interpolation-sinc" name="gba-audio-interpolation" value="WindowedSinc">
|
||||
<label for="gba-audio-interpolation-sinc">Windowed sinc</label>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" id="gba-skip-bios-animation">
|
||||
<label for="gba-skip-bios-animation">Skip BIOS intro animation</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" id="gba-frame-blending">
|
||||
<label for="gba-frame-blending">Interframe blending</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" id="gba-psg-low-pass">
|
||||
<label for="gba-psg-low-pass">Apply low-pass filter to PSG (enhanced interpolation only)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px;">
|
||||
<input type="button" id="restore-defaults" value="Restore default config">
|
||||
</div>
|
||||
<div id="supported-files-info" style="margin-top:10px;">
|
||||
<p>Supported platforms in this interface:</p>
|
||||
<ul>
|
||||
<li>Up/Left/Right/Down: Arrow keys</li>
|
||||
<li>A: S key</li>
|
||||
<li>B: X key</li>
|
||||
<li>X: A key</li>
|
||||
<li>Y: Z key</li>
|
||||
<li>L: D key</li>
|
||||
<li>R: C key</li>
|
||||
<li>Start: Return key</li>
|
||||
<li>Select: Right Shift key</li>
|
||||
<li>F8: Toggle fullscreen</li>
|
||||
<li>Sega Genesis (.md / .gen / .bin / .smd)</li>
|
||||
<li>Sega CD (.chd)</li>
|
||||
<li>Sega 32X (.32x)</li>
|
||||
<li>Sega Master System (.sms)</li>
|
||||
<li>Game Gear (.gg)</li>
|
||||
<li>SNES (.sfc / .smc)</li>
|
||||
<li>Game Boy Advance (.gba)</li>
|
||||
</ul>
|
||||
<p>Sega CD and GBA require a BIOS ROM to be configured</p>
|
||||
<p>32X is extremely CPU-intensive and may not run at full speed</p>
|
||||
</div>
|
||||
<div id="input-config" hidden>
|
||||
<p>Controls:</p>
|
||||
<div id="controls"></div>
|
||||
<div>Press F8 to toggle fullscreen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -323,7 +355,6 @@
|
||||
import init, {
|
||||
EmulatorChannel,
|
||||
WebConfigRef,
|
||||
base64_decode,
|
||||
build_commit_hash,
|
||||
run_emulator,
|
||||
} from "./pkg/jgenesis_web.js";
|
||||
@ -333,37 +364,124 @@
|
||||
let config = new WebConfigRef();
|
||||
let channel = new EmulatorChannel();
|
||||
|
||||
function setChecked(elem, value) {
|
||||
elem.checked = value;
|
||||
}
|
||||
|
||||
function initConfigUi() {
|
||||
// Common UI
|
||||
document.querySelectorAll("input[name='image-filter']").forEach((elem) => {
|
||||
setChecked(elem, elem.getAttribute("value") === config.filter_mode());
|
||||
});
|
||||
|
||||
document.querySelectorAll("input[name='blend-shader']").forEach((elem) => {
|
||||
setChecked(elem, elem.getAttribute("value") === config.preprocess_shader());
|
||||
});
|
||||
|
||||
document.querySelectorAll("input[name='prescale-factor']").forEach((elem) => {
|
||||
setChecked(elem, parseInt(elem.getAttribute("value")) === config.prescale_factor());
|
||||
});
|
||||
|
||||
// SMS/GG UI
|
||||
document.querySelectorAll("input[name='sms-timing-mode']").forEach((elem) => {
|
||||
setChecked(elem, elem.getAttribute("value") === config.sms_timing_mode());
|
||||
});
|
||||
|
||||
document.querySelectorAll("input[name='sms-aspect-ratio']").forEach((elem) => {
|
||||
setChecked(elem, elem.getAttribute("value") === config.sms_aspect_ratio());
|
||||
});
|
||||
|
||||
document.querySelectorAll("input[name='gg-aspect-ratio']").forEach((elem) => {
|
||||
setChecked(elem, elem.getAttribute("value") === config.gg_aspect_ratio());
|
||||
});
|
||||
|
||||
setChecked(document.getElementById("sms-crop-vertical-border"), config.sms_crop_vertical_border());
|
||||
setChecked(document.getElementById("sms-crop-left-border"), config.sms_crop_left_border());
|
||||
setChecked(document.getElementById("sms-fm-enabled"), config.sms_fm_enabled());
|
||||
setChecked(document.getElementById("sms-remove-sprite-limit"), config.sms_remove_sprite_limit());
|
||||
|
||||
// Genesis UI
|
||||
document.querySelectorAll("input[name='gen-aspect-ratio']").forEach((elem) => {
|
||||
setChecked(elem, elem.getAttribute("value") === config.genesis_aspect_ratio());
|
||||
});
|
||||
|
||||
document.querySelectorAll("input[name='gen-m68k-divider']").forEach((elem) => {
|
||||
setChecked(elem, parseInt(elem.getAttribute("value")) === config.genesis_m68k_divider());
|
||||
});
|
||||
|
||||
setChecked(document.getElementById("genesis-non-linear-color-scale"), config.genesis_non_linear_color_scale());
|
||||
setChecked(document.getElementById("genesis-remove-sprite-limits"), config.genesis_remove_sprite_limits());
|
||||
setChecked(document.getElementById("genesis-emulate-low-pass"), config.genesis_emulate_low_pass());
|
||||
setChecked(document.getElementById("genesis-render-vertical-border"), config.genesis_render_vertical_border());
|
||||
setChecked(document.getElementById("genesis-render-horizontal-border"), config.genesis_render_horizontal_border());
|
||||
|
||||
// SNES UI
|
||||
document.querySelectorAll("input[name='snes-aspect-ratio']").forEach((elem) => {
|
||||
setChecked(elem, elem.getAttribute("value") === config.snes_aspect_ratio());
|
||||
});
|
||||
|
||||
document.querySelectorAll("input[name='snes-audio-interpolation']").forEach((elem) => {
|
||||
setChecked(elem, elem.getAttribute("value") === config.snes_audio_interpolation());
|
||||
});
|
||||
|
||||
// GBA UI
|
||||
document.querySelectorAll("input[name='gba-color-correct']").forEach((elem) => {
|
||||
setChecked(elem, elem.getAttribute("value") === config.gba_color_correction());
|
||||
});
|
||||
|
||||
document.querySelectorAll("input[name='gba-audio-interpolation']").forEach((elem) => {
|
||||
setChecked(elem, elem.getAttribute("value") === config.gba_audio_interpolation());
|
||||
});
|
||||
|
||||
setChecked(document.getElementById("gba-skip-bios-animation"), config.gba_skip_bios_animation());
|
||||
setChecked(document.getElementById("gba-frame-blending"), config.gba_frame_blending());
|
||||
setChecked(document.getElementById("gba-psg-low-pass"), config.gba_psg_low_pass());
|
||||
}
|
||||
|
||||
initConfigUi();
|
||||
|
||||
function downloadSaveFile() {
|
||||
let currentFileName = channel.current_file_name();
|
||||
let saveBytesB64 = localStorage.getItem(currentFileName);
|
||||
if (!saveBytesB64) {
|
||||
alert(`No save file found for '${currentFileName}'`);
|
||||
return;
|
||||
}
|
||||
const currentFileName = channel.current_file_name();
|
||||
|
||||
let saveBytes = base64_decode(saveBytesB64);
|
||||
if (!saveBytes) {
|
||||
alert(`Save file for '${currentFileName}' is invalid`);
|
||||
return;
|
||||
}
|
||||
const openReq = indexedDB.open("saves");
|
||||
openReq.onsuccess = (event) => {
|
||||
const db = event.target.result;
|
||||
const tx = db.transaction(["files"]);
|
||||
const objectStore = tx.objectStore("files");
|
||||
|
||||
let saveFileName = currentFileName.replace(/\.[a-zA-Z]*$/, ".sav");
|
||||
const getReq = objectStore.get(currentFileName);
|
||||
getReq.onsuccess = (event) => {
|
||||
const value = event.target.result;
|
||||
if (!value || !value["data"] || !value["data"]["sav"]) {
|
||||
alert(`No save file found for ${currentFileName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let a = document.createElement("a");
|
||||
a.href = window.URL.createObjectURL(new Blob([saveBytes], {type: "application/octet-stream"}));
|
||||
a.download = saveFileName;
|
||||
const saveBytes = value["data"]["sav"];
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
const saveFileName = currentFileName.replace(/\.[a-zA-Z]*$/, ".sav");
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = window.URL.createObjectURL(new Blob([saveBytes], {type: "application/octet-stream"}));
|
||||
a.download = saveFileName;
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
document.getElementById("open-file").addEventListener("click", () => {
|
||||
channel.request_open_file();
|
||||
});
|
||||
|
||||
document.getElementById("open-sega-cd").addEventListener("click", () => {
|
||||
channel.request_open_sega_cd();
|
||||
document.getElementById("sega-cd-bios").addEventListener("click", () => {
|
||||
channel.request_open_sega_cd_bios();
|
||||
});
|
||||
|
||||
document.getElementById("gba-bios").addEventListener("click", () => {
|
||||
channel.request_open_gba_bios();
|
||||
});
|
||||
|
||||
document.getElementById("reset-emulator").addEventListener("click", () => {
|
||||
@ -472,6 +590,40 @@
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("input[name='gba-color-correct']").forEach((element) => {
|
||||
element.addEventListener("click", (event) => {
|
||||
config.set_gba_color_correction(event.target.value);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("input[name='gba-audio-interpolation']").forEach((element) => {
|
||||
element.addEventListener("click", (event) => {
|
||||
config.set_gba_audio_interpolation(event.target.value);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("gba-skip-bios-animation").addEventListener("click", (event) => {
|
||||
config.set_gba_skip_bios_animation(event.target.checked);
|
||||
});
|
||||
|
||||
document.getElementById("gba-frame-blending").addEventListener("click", (event) => {
|
||||
config.set_gba_frame_blending(event.target.checked);
|
||||
});
|
||||
|
||||
document.getElementById("gba-psg-low-pass").addEventListener("click", (event) => {
|
||||
config.set_gba_psg_low_pass(event.target.checked);
|
||||
});
|
||||
|
||||
window.inputClickListener = (event) => {
|
||||
let inputName = event.target.getAttribute("data-name");
|
||||
channel.request_configure_input(inputName);
|
||||
};
|
||||
|
||||
document.getElementById("restore-defaults").addEventListener("click", () => {
|
||||
config.restore_defaults();
|
||||
initConfigUi();
|
||||
});
|
||||
|
||||
(() => {
|
||||
let buildCommitHash = build_commit_hash();
|
||||
let commitLinkSpan = document.getElementById("build-commit-link");
|
||||
|
||||
160
frontend/jgenesis-web/js/idb.js
Normal file
160
frontend/jgenesis-web/js/idb.js
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @param key {string}
|
||||
* @returns {Promise<Object.<string, Uint8Array>>}
|
||||
*/
|
||||
export function loadSaveFiles(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const openReq = indexedDB.open("saves");
|
||||
|
||||
openReq.onerror = () => reject(`Failed to open IndexedDB for read, key ${key}`);
|
||||
|
||||
openReq.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
db.createObjectStore("files", { keyPath: "key" });
|
||||
};
|
||||
|
||||
openReq.onsuccess = (event) => {
|
||||
const db = event.target.result;
|
||||
const tx = db.transaction(["files"]);
|
||||
|
||||
tx.onerror = () => reject(`IndexedDB transaction error for key ${key}: ${tx.error}`);
|
||||
|
||||
const objectStore = tx.objectStore("files");
|
||||
const objectReq = objectStore.get(key);
|
||||
|
||||
objectReq.onerror = () => reject(`Failed to read from IndexedDB for key ${key}`);
|
||||
|
||||
objectReq.onsuccess = (event) => {
|
||||
const value = event.target.result;
|
||||
if (!value || !value["data"]) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
const files = {};
|
||||
Object.keys(value["data"]).forEach((extension) => {
|
||||
files[extension] = value["data"][extension];
|
||||
});
|
||||
|
||||
resolve(files);
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param key {string}
|
||||
* @param extension {string}
|
||||
* @param bytes {Uint8Array}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function writeSaveFile(key, extension, bytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const openReq = indexedDB.open("saves");
|
||||
|
||||
openReq.onerror = () => reject(`Failed to open IndexedDB for write, key ${key} extension ${extension}`);
|
||||
|
||||
openReq.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
db.createObjectStore("files", { keyPath: "key" });
|
||||
};
|
||||
|
||||
openReq.onsuccess = (event) => {
|
||||
const db = event.target.result;
|
||||
const tx = db.transaction(["files"], "readwrite");
|
||||
|
||||
tx.onerror = () => reject(`IndexedDB transaction error for key ${key} extension ${extension}: ${tx.error}`);
|
||||
|
||||
const objectStore = tx.objectStore("files");
|
||||
|
||||
const getReq = objectStore.get(key);
|
||||
|
||||
getReq.onerror = () => reject(`Failed to read from IndexedDB for key ${key} extension ${extension}`);
|
||||
|
||||
getReq.onsuccess = (event) => {
|
||||
let value = event.target.result;
|
||||
if (!value || !value["data"]) {
|
||||
value = { key, data: {} };
|
||||
}
|
||||
|
||||
value["data"][extension] = bytes;
|
||||
|
||||
const putReq = objectStore.put(value);
|
||||
|
||||
putReq.onerror = () => reject(`Failed to write to IndexedDB for key ${key} extension ${extension}`);
|
||||
|
||||
putReq.onsuccess = () => resolve();
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param key {string}
|
||||
* @returns {Promise<Uint8Array | null>}
|
||||
*/
|
||||
export function loadBios(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const openReq = indexedDB.open("bios_roms");
|
||||
|
||||
openReq.onerror = () => reject(`Failed to open IndexedDB for read, key ${key}`);
|
||||
|
||||
openReq.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
db.createObjectStore("files", { keyPath: "key" });
|
||||
};
|
||||
|
||||
openReq.onsuccess = (event) => {
|
||||
const db = event.target.result;
|
||||
const tx = db.transaction(["files"]);
|
||||
|
||||
tx.onerror = () => reject(`IndexedDB transaction error for key ${key}: ${tx.error}`);
|
||||
|
||||
const objectStore = tx.objectStore("files");
|
||||
const getReq = objectStore.get(key);
|
||||
|
||||
getReq.onerror = () => reject(`Failed to read from IndexedDB for key ${key}`);
|
||||
|
||||
getReq.onsuccess = (event) => {
|
||||
const value = event.target.result;
|
||||
if (value && value["data"]) {
|
||||
resolve(value["data"]);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param key {string}
|
||||
* @param bytes {Uint8Array}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function writeBios(key, bytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const openReq = indexedDB.open("bios_roms");
|
||||
|
||||
openReq.onerror = () => reject(`Failed to open IndexedDB for read, key ${key}`);
|
||||
|
||||
openReq.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
db.createObjectStore("files", { keyPath: "key" });
|
||||
};
|
||||
|
||||
openReq.onsuccess = (event) => {
|
||||
const db = event.target.result;
|
||||
const tx = db.transaction(["files"], "readwrite");
|
||||
|
||||
tx.onerror = () => reject(`IndexedDB transaction error for key ${key}: ${tx.error}`);
|
||||
|
||||
const objectStore = tx.objectStore("files");
|
||||
const putReq = objectStore.put({ key, data: bytes });
|
||||
|
||||
putReq.onerror = () => reject(`Failed to write to IndexedDB for key ${key}`);
|
||||
|
||||
putReq.onsuccess = () => resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -18,25 +18,89 @@ export function focusCanvas() {
|
||||
document.querySelector("canvas").focus();
|
||||
}
|
||||
|
||||
export function showSmsGgConfig() {
|
||||
document.getElementById("smsgg-config").hidden = false;
|
||||
const configIds = ["smsgg-config", "genesis-config", "snes-config", "gba-config"];
|
||||
|
||||
document.getElementById("genesis-config").hidden = true;
|
||||
document.getElementById("snes-config").hidden = true;
|
||||
/**
|
||||
* @param id {string}
|
||||
*/
|
||||
function hideAllConfigsExcept(id) {
|
||||
for (const configId of configIds) {
|
||||
document.getElementById(configId).hidden = configId !== id;
|
||||
}
|
||||
|
||||
document.getElementById("supported-files-info").hidden = true;
|
||||
document.getElementById("input-config").hidden = false;
|
||||
}
|
||||
|
||||
export function showGenesisConfig() {
|
||||
document.getElementById("genesis-config").hidden = false;
|
||||
/**
|
||||
* @param inputNames {string[]}
|
||||
* @param inputKeys {string[]}
|
||||
*/
|
||||
function renderInputs(inputNames, inputKeys) {
|
||||
const listNode = document.createElement("ul");
|
||||
|
||||
document.getElementById("smsgg-config").hidden = true;
|
||||
document.getElementById("snes-config").hidden = true;
|
||||
for (const [i, name] of inputNames.entries()) {
|
||||
const key = inputKeys[i];
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.innerText = `${name}: `;
|
||||
|
||||
const button = document.createElement("input");
|
||||
button.classList.add("input-configure");
|
||||
button.setAttribute("name", "input-configure");
|
||||
button.setAttribute("type", "button");
|
||||
button.setAttribute("value", key);
|
||||
button.setAttribute("data-name", name);
|
||||
|
||||
if (window.inputClickListener) {
|
||||
button.addEventListener("click", window.inputClickListener);
|
||||
}
|
||||
|
||||
const listItem = document.createElement("li");
|
||||
listItem.appendChild(span);
|
||||
listItem.appendChild(button);
|
||||
listNode.appendChild(listItem);
|
||||
}
|
||||
|
||||
const controls = document.getElementById("controls");
|
||||
controls.innerHTML = "";
|
||||
controls.appendChild(listNode);
|
||||
}
|
||||
|
||||
export function showSnesConfig() {
|
||||
document.getElementById("snes-config").hidden = false;
|
||||
/**
|
||||
* @param inputNames {string[]}
|
||||
* @param inputKeys {string[]}
|
||||
*/
|
||||
export function showSmsGgConfig(inputNames, inputKeys) {
|
||||
hideAllConfigsExcept("smsgg-config");
|
||||
renderInputs(inputNames, inputKeys);
|
||||
}
|
||||
|
||||
document.getElementById("smsgg-config").hidden = true;
|
||||
document.getElementById("genesis-config").hidden = true;
|
||||
/**
|
||||
* @param inputNames {string[]}
|
||||
* @param inputKeys {string[]}
|
||||
*/
|
||||
export function showGenesisConfig(inputNames, inputKeys) {
|
||||
hideAllConfigsExcept("genesis-config");
|
||||
renderInputs(inputNames, inputKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param inputNames {string[]}
|
||||
* @param inputKeys {string[]}
|
||||
*/
|
||||
export function showSnesConfig(inputNames, inputKeys) {
|
||||
hideAllConfigsExcept("snes-config");
|
||||
renderInputs(inputNames, inputKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param inputNames {string[]}
|
||||
* @param inputKeys {string[]}
|
||||
*/
|
||||
export function showGbaConfig(inputNames, inputKeys) {
|
||||
hideAllConfigsExcept("gba-config");
|
||||
renderInputs(inputNames, inputKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -74,6 +138,30 @@ export function setSaveUiEnabled(saveUiEnabled) {
|
||||
}
|
||||
}
|
||||
|
||||
export function beforeInputConfigure() {
|
||||
for (const element of document.getElementsByClassName("input-configure")) {
|
||||
element.disabled = true;
|
||||
}
|
||||
|
||||
document.getElementById("jgenesis-wasm").classList.add("darken");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param name {string}
|
||||
* @param key {string}
|
||||
*/
|
||||
export function afterInputConfigure(name, key) {
|
||||
for (const element of document.getElementsByClassName("input-configure")) {
|
||||
element.disabled = false;
|
||||
|
||||
if (element.getAttribute("data-name") === name) {
|
||||
element.setAttribute("value", key);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("jgenesis-wasm").classList.remove("darken");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param key {string}
|
||||
* @return {string | null}
|
||||
|
||||
@ -1,25 +1,34 @@
|
||||
use crate::js;
|
||||
use gba_config::{GbaAspectRatio, GbaAudioInterpolation, GbaButton, GbaColorCorrection, GbaInputs};
|
||||
use gba_core::api::{GbaAudioConfig, GbaEmulatorConfig};
|
||||
use genesis_config::{
|
||||
GenesisAspectRatio, GenesisControllerType, Opn2BusyBehavior, PcmInterpolation,
|
||||
GenesisAspectRatio, GenesisButton, GenesisControllerType, GenesisInputs, Opn2BusyBehavior,
|
||||
PcmInterpolation, S32XColorTint, S32XVideoOut, S32XVoidColor,
|
||||
};
|
||||
use genesis_core::GenesisEmulatorConfig;
|
||||
use jgenesis_common::frontend::TimingMode;
|
||||
use jgenesis_common::frontend::{MappableInputs, TimingMode};
|
||||
use jgenesis_common::input::Player;
|
||||
use jgenesis_renderer::config::{
|
||||
FilterMode, PerEmulatorRenderConfig, PreprocessShader, PrescaleFactor, PrescaleMode,
|
||||
RendererConfig, Scanlines, VSyncMode, WgpuBackend,
|
||||
ColorCorrection, FilterMode, PerEmulatorRenderConfig, PreprocessShader, PrescaleFactor,
|
||||
PrescaleMode, RendererConfig, Scanlines, VSyncMode, WgpuBackend,
|
||||
};
|
||||
use s32x_core::api::Sega32XEmulatorConfig;
|
||||
use segacd_core::api::SegaCdEmulatorConfig;
|
||||
use smsgg_config::{GgAspectRatio, SmsAspectRatio, SmsModel};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smsgg_config::{GgAspectRatio, SmsAspectRatio, SmsGgButton, SmsGgInputs, SmsModel};
|
||||
use smsgg_core::SmsGgEmulatorConfig;
|
||||
use snes_config::{AudioInterpolationMode, SnesAspectRatio};
|
||||
use snes_config::{AudioInterpolationMode, SnesAspectRatio, SnesButton};
|
||||
use snes_core::api::SnesEmulatorConfig;
|
||||
use snes_core::input::SnesInputs;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::num::{NonZeroU16, NonZeroU32, NonZeroU64};
|
||||
use std::ops::Deref;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CommonWebConfig {
|
||||
pub filter_mode: FilterMode,
|
||||
pub preprocess_shader: PreprocessShader,
|
||||
@ -37,7 +46,10 @@ impl Default for CommonWebConfig {
|
||||
}
|
||||
|
||||
impl CommonWebConfig {
|
||||
pub fn to_renderer_config(&self) -> RendererConfig {
|
||||
pub fn to_renderer_config(
|
||||
&self,
|
||||
per_emulator_config: PerEmulatorRenderConfig,
|
||||
) -> RendererConfig {
|
||||
RendererConfig {
|
||||
wgpu_backend: WgpuBackend::OpenGl,
|
||||
vsync_mode: VSyncMode::Enabled,
|
||||
@ -49,12 +61,12 @@ impl CommonWebConfig {
|
||||
filter_mode: self.filter_mode,
|
||||
preprocess_shader: self.preprocess_shader,
|
||||
use_webgl2_limits: true,
|
||||
per_emulator_config: PerEmulatorRenderConfig::default(),
|
||||
per_emulator_config,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SmsGgWebConfig {
|
||||
timing_mode: TimingMode,
|
||||
sms_aspect_ratio: SmsAspectRatio,
|
||||
@ -98,7 +110,7 @@ impl SmsGgWebConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GenesisWebConfig {
|
||||
aspect_ratio: GenesisAspectRatio,
|
||||
remove_sprite_limits: bool,
|
||||
@ -160,7 +172,7 @@ impl GenesisWebConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SnesWebConfig {
|
||||
aspect_ratio: SnesAspectRatio,
|
||||
audio_interpolation: AudioInterpolationMode,
|
||||
@ -179,15 +191,243 @@ impl SnesWebConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GbaWebConfig {
|
||||
skip_bios_intro_animation: bool,
|
||||
color_correction: GbaColorCorrection,
|
||||
frame_blending: bool,
|
||||
audio_interpolation: GbaAudioInterpolation,
|
||||
psg_low_pass: bool,
|
||||
}
|
||||
|
||||
impl Default for GbaWebConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
skip_bios_intro_animation: false,
|
||||
color_correction: GbaColorCorrection::default(),
|
||||
frame_blending: true,
|
||||
audio_interpolation: GbaAudioInterpolation::default(),
|
||||
psg_low_pass: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GbaWebConfig {
|
||||
pub fn to_emulator_config(&self) -> GbaEmulatorConfig {
|
||||
GbaEmulatorConfig {
|
||||
skip_bios_animation: self.skip_bios_intro_animation,
|
||||
aspect_ratio: GbaAspectRatio::SquarePixels,
|
||||
forced_save_memory_type: None,
|
||||
audio: GbaAudioConfig {
|
||||
audio_interpolation: self.audio_interpolation,
|
||||
psg_low_pass: self.psg_low_pass,
|
||||
..GbaAudioConfig::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn renderer_config(&self) -> PerEmulatorRenderConfig {
|
||||
PerEmulatorRenderConfig {
|
||||
color_correction: match self.color_correction {
|
||||
GbaColorCorrection::None => ColorCorrection::None,
|
||||
GbaColorCorrection::GbaLcd => ColorCorrection::GbaLcd { screen_gamma: 3.2 },
|
||||
},
|
||||
frame_blending: self.frame_blending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! define_input_config {
|
||||
(
|
||||
config = $config:ident,
|
||||
button = $button_t:ident,
|
||||
inputs = $inputs:ident,
|
||||
fields = {
|
||||
$($field:ident: name $name:literal button $button:ident default $default:ident),* $(,)?
|
||||
} $(,)?
|
||||
) => {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct $config {
|
||||
$(
|
||||
$field: KeyCode,
|
||||
)*
|
||||
}
|
||||
|
||||
impl Default for $config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
$(
|
||||
$field: KeyCode::$default,
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl $config {
|
||||
pub fn fields_iter(&self) -> impl Iterator<Item = (&'static str, KeyCode)> {
|
||||
[
|
||||
$(
|
||||
($name, self.$field),
|
||||
)*
|
||||
]
|
||||
.into_iter()
|
||||
}
|
||||
|
||||
pub fn update_field(&mut self, name: &str, key: KeyCode) {
|
||||
match name {
|
||||
$(
|
||||
$name => self.$field = key,
|
||||
)*
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_input(&self, key: KeyCode, pressed: bool, inputs: &mut $inputs) {
|
||||
$(
|
||||
if key == self.$field {
|
||||
inputs.set_field($button_t::$button, Player::One, pressed);
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn split_input_iterator<'a>(
|
||||
iter: impl Iterator<Item = (&'a str, KeyCode)>,
|
||||
) -> (Vec<String>, Vec<String>) {
|
||||
iter.map(|(name, key)| (String::from(name), format!("{key:?}"))).unzip()
|
||||
}
|
||||
|
||||
define_input_config! {
|
||||
config = SmsGgInputConfig,
|
||||
button = SmsGgButton,
|
||||
inputs = SmsGgInputs,
|
||||
fields = {
|
||||
up: name "Up" button Up default ArrowUp,
|
||||
left: name "Left" button Left default ArrowLeft,
|
||||
right: name "Right" button Right default ArrowRight,
|
||||
down: name "Down" button Down default ArrowDown,
|
||||
button1: name "Button 1" button Button1 default KeyS,
|
||||
button2: name "Button 2" button Button2 default KeyA,
|
||||
pause: name "Start/Pause" button Pause default Enter,
|
||||
}
|
||||
}
|
||||
|
||||
define_input_config! {
|
||||
config = GenesisInputConfig,
|
||||
button = GenesisButton,
|
||||
inputs = GenesisInputs,
|
||||
fields = {
|
||||
up: name "Up" button Up default ArrowUp,
|
||||
left: name "Left" button Left default ArrowLeft,
|
||||
right: name "Right" button Right default ArrowRight,
|
||||
down: name "Down" button Down default ArrowDown,
|
||||
a: name "A" button A default KeyA,
|
||||
b: name "B" button B default KeyS,
|
||||
c: name "C" button C default KeyD,
|
||||
x: name "X" button X default KeyQ,
|
||||
y: name "Y" button Y default KeyW,
|
||||
z: name "Z" button Z default KeyE,
|
||||
start: name "Start" button Start default Enter,
|
||||
mode: name "Mode" button Mode default ShiftRight,
|
||||
}
|
||||
}
|
||||
|
||||
define_input_config! {
|
||||
config = SnesInputConfig,
|
||||
button = SnesButton,
|
||||
inputs = SnesInputs,
|
||||
fields = {
|
||||
up: name "Up" button Up default ArrowUp,
|
||||
left: name "Left" button Left default ArrowLeft,
|
||||
right: name "Right" button Right default ArrowRight,
|
||||
down: name "Down" button Down default ArrowDown,
|
||||
a: name "A" button A default KeyS,
|
||||
b: name "B" button B default KeyX,
|
||||
x: name "X" button X default KeyA,
|
||||
y: name "Y" button Y default KeyZ,
|
||||
l: name "L" button L default KeyD,
|
||||
r: name "R" button R default KeyC,
|
||||
start: name "Start" button Start default Enter,
|
||||
select: name "Select" button Select default ShiftRight,
|
||||
}
|
||||
}
|
||||
|
||||
define_input_config! {
|
||||
config = GbaInputConfig,
|
||||
button = GbaButton,
|
||||
inputs = GbaInputs,
|
||||
fields = {
|
||||
up: name "Up" button Up default ArrowUp,
|
||||
left: name "Left" button Left default ArrowLeft,
|
||||
right: name "Right" button Right default ArrowRight,
|
||||
down: name "Down" button Down default ArrowDown,
|
||||
a: name "A" button A default KeyA,
|
||||
b: name "B" button B default KeyS,
|
||||
l: name "L" button L default KeyQ,
|
||||
r: name "R" button R default KeyW,
|
||||
start: name "Start" button Start default Enter,
|
||||
select: name "Select" button Select default ShiftRight,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InputConfig {
|
||||
pub smsgg: SmsGgInputConfig,
|
||||
pub genesis: GenesisInputConfig,
|
||||
pub snes: SnesInputConfig,
|
||||
pub gba: GbaInputConfig,
|
||||
}
|
||||
|
||||
impl InputConfig {
|
||||
pub fn smsgg_inputs(&self) -> (Vec<String>, Vec<String>) {
|
||||
split_input_iterator(self.smsgg.fields_iter())
|
||||
}
|
||||
|
||||
pub fn genesis_inputs(&self) -> (Vec<String>, Vec<String>) {
|
||||
split_input_iterator(self.genesis.fields_iter())
|
||||
}
|
||||
|
||||
pub fn snes_inputs(&self) -> (Vec<String>, Vec<String>) {
|
||||
split_input_iterator(self.snes.fields_iter())
|
||||
}
|
||||
|
||||
pub fn gba_inputs(&self) -> (Vec<String>, Vec<String>) {
|
||||
split_input_iterator(self.gba.fields_iter())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WebConfig {
|
||||
pub common: CommonWebConfig,
|
||||
pub smsgg: SmsGgWebConfig,
|
||||
pub genesis: GenesisWebConfig,
|
||||
pub snes: SnesWebConfig,
|
||||
pub gba: GbaWebConfig,
|
||||
pub inputs: InputConfig,
|
||||
}
|
||||
|
||||
impl WebConfig {
|
||||
const LOCAL_STORAGE_KEY: &str = "config";
|
||||
|
||||
pub fn read_from_local_storage() -> Option<Self> {
|
||||
let config_str = js::localStorageGet(Self::LOCAL_STORAGE_KEY)?;
|
||||
serde_json::from_str(&config_str).ok()
|
||||
}
|
||||
|
||||
pub fn save_to_local_storage(&self) {
|
||||
let config_str = match serde_json::to_string(self) {
|
||||
Ok(config_str) => config_str,
|
||||
Err(err) => {
|
||||
log::error!("Error serializing config: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
js::localStorageSet(Self::LOCAL_STORAGE_KEY, &config_str);
|
||||
}
|
||||
|
||||
pub fn to_sega_cd_config(&self) -> SegaCdEmulatorConfig {
|
||||
SegaCdEmulatorConfig {
|
||||
genesis: self.genesis.to_emulator_config(),
|
||||
@ -206,6 +446,32 @@ impl WebConfig {
|
||||
cd_volume_adjustment_db: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_32x_config(&self) -> Sega32XEmulatorConfig {
|
||||
Sega32XEmulatorConfig {
|
||||
genesis: self.genesis.to_emulator_config(),
|
||||
sh2_clock_multiplier: NonZeroU64::new(genesis_config::NATIVE_SH2_MULTIPLIER).unwrap(),
|
||||
video_out: S32XVideoOut::default(),
|
||||
darken_genesis_colors: true,
|
||||
color_tint: S32XColorTint::default(),
|
||||
show_high_priority: true,
|
||||
show_low_priority: true,
|
||||
void_color: S32XVoidColor::default(),
|
||||
apply_genesis_lpf_to_pwm: true,
|
||||
pwm_enabled: true,
|
||||
pwm_volume_adjustment_db: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_renderer_config(&self, running_gba: bool) -> RendererConfig {
|
||||
let per_emulator_render_config = if running_gba {
|
||||
self.gba.renderer_config()
|
||||
} else {
|
||||
PerEmulatorRenderConfig::default()
|
||||
};
|
||||
|
||||
self.common.to_renderer_config(per_emulator_render_config)
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
@ -215,7 +481,12 @@ pub struct WebConfigRef(Rc<RefCell<WebConfig>>);
|
||||
impl WebConfigRef {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self(Rc::default())
|
||||
let config = WebConfig::read_from_local_storage().unwrap_or_default();
|
||||
Self(Rc::new(RefCell::new(config)))
|
||||
}
|
||||
|
||||
pub fn filter_mode(&self) -> String {
|
||||
self.borrow().common.filter_mode.to_string()
|
||||
}
|
||||
|
||||
pub fn set_filter_mode(&self, filter_mode: &str) {
|
||||
@ -223,90 +494,210 @@ impl WebConfigRef {
|
||||
self.borrow_mut().common.filter_mode = filter_mode;
|
||||
}
|
||||
|
||||
pub fn preprocess_shader(&self) -> String {
|
||||
self.borrow().common.preprocess_shader.to_string()
|
||||
}
|
||||
|
||||
pub fn set_preprocess_shader(&self, preprocess_shader: &str) {
|
||||
let Ok(preprocess_shader) = preprocess_shader.parse() else { return };
|
||||
self.borrow_mut().common.preprocess_shader = preprocess_shader;
|
||||
}
|
||||
|
||||
pub fn prescale_factor(&self) -> u32 {
|
||||
self.borrow().common.prescale_factor.get()
|
||||
}
|
||||
|
||||
pub fn set_prescale_factor(&self, prescale_factor: u32) {
|
||||
let Ok(prescale_factor) = prescale_factor.try_into() else { return };
|
||||
self.borrow_mut().common.prescale_factor = prescale_factor;
|
||||
}
|
||||
|
||||
pub fn sms_timing_mode(&self) -> String {
|
||||
self.borrow().smsgg.timing_mode.to_string()
|
||||
}
|
||||
|
||||
pub fn set_sms_timing_mode(&self, timing_mode: &str) {
|
||||
let Ok(timing_mode) = timing_mode.parse() else { return };
|
||||
self.borrow_mut().smsgg.timing_mode = timing_mode;
|
||||
}
|
||||
|
||||
pub fn sms_aspect_ratio(&self) -> String {
|
||||
self.borrow().smsgg.sms_aspect_ratio.to_string()
|
||||
}
|
||||
|
||||
pub fn set_sms_aspect_ratio(&self, aspect_ratio: &str) {
|
||||
let Ok(aspect_ratio) = aspect_ratio.parse() else { return };
|
||||
self.borrow_mut().smsgg.sms_aspect_ratio = aspect_ratio;
|
||||
}
|
||||
|
||||
pub fn gg_aspect_ratio(&self) -> String {
|
||||
self.borrow().smsgg.gg_aspect_ratio.to_string()
|
||||
}
|
||||
|
||||
pub fn set_gg_aspect_ratio(&self, aspect_ratio: &str) {
|
||||
let Ok(aspect_ratio) = aspect_ratio.parse() else { return };
|
||||
self.borrow_mut().smsgg.gg_aspect_ratio = aspect_ratio;
|
||||
}
|
||||
|
||||
pub fn sms_remove_sprite_limit(&self) -> bool {
|
||||
self.borrow().smsgg.remove_sprite_limit
|
||||
}
|
||||
|
||||
pub fn set_sms_remove_sprite_limit(&self, remove_sprite_limit: bool) {
|
||||
self.borrow_mut().smsgg.remove_sprite_limit = remove_sprite_limit;
|
||||
}
|
||||
|
||||
pub fn sms_crop_vertical_border(&self) -> bool {
|
||||
self.borrow().smsgg.sms_crop_vertical_border
|
||||
}
|
||||
|
||||
pub fn set_sms_crop_vertical_border(&self, crop: bool) {
|
||||
self.borrow_mut().smsgg.sms_crop_vertical_border = crop;
|
||||
}
|
||||
|
||||
pub fn sms_crop_left_border(&self) -> bool {
|
||||
self.borrow().smsgg.sms_crop_left_border
|
||||
}
|
||||
|
||||
pub fn set_sms_crop_left_border(&self, crop: bool) {
|
||||
self.borrow_mut().smsgg.sms_crop_left_border = crop;
|
||||
}
|
||||
|
||||
pub fn sms_fm_enabled(&self) -> bool {
|
||||
self.borrow().smsgg.fm_unit_enabled
|
||||
}
|
||||
|
||||
pub fn set_sms_fm_enabled(&self, enabled: bool) {
|
||||
self.borrow_mut().smsgg.fm_unit_enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn genesis_m68k_divider(&self) -> u32 {
|
||||
self.borrow().genesis.m68k_divider as u32
|
||||
}
|
||||
|
||||
pub fn set_genesis_m68k_divider(&self, m68k_divider: &str) {
|
||||
let Ok(m68k_divider) = m68k_divider.parse() else { return };
|
||||
self.borrow_mut().genesis.m68k_divider = m68k_divider;
|
||||
}
|
||||
|
||||
pub fn genesis_aspect_ratio(&self) -> String {
|
||||
self.borrow().genesis.aspect_ratio.to_string()
|
||||
}
|
||||
|
||||
pub fn set_genesis_aspect_ratio(&self, aspect_ratio: &str) {
|
||||
let Ok(aspect_ratio) = aspect_ratio.parse() else { return };
|
||||
self.borrow_mut().genesis.aspect_ratio = aspect_ratio;
|
||||
}
|
||||
|
||||
pub fn genesis_remove_sprite_limits(&self) -> bool {
|
||||
self.borrow().genesis.remove_sprite_limits
|
||||
}
|
||||
|
||||
pub fn set_genesis_remove_sprite_limits(&self, remove_sprite_limits: bool) {
|
||||
self.borrow_mut().genesis.remove_sprite_limits = remove_sprite_limits;
|
||||
}
|
||||
|
||||
pub fn genesis_non_linear_color_scale(&self) -> bool {
|
||||
self.borrow().genesis.non_linear_color_scale
|
||||
}
|
||||
|
||||
pub fn set_genesis_non_linear_color_scale(&self, non_linear_color_scale: bool) {
|
||||
self.borrow_mut().genesis.non_linear_color_scale = non_linear_color_scale;
|
||||
}
|
||||
|
||||
pub fn genesis_emulate_low_pass(&self) -> bool {
|
||||
self.borrow().genesis.lpf_enabled
|
||||
}
|
||||
|
||||
pub fn set_genesis_emulate_low_pass(&self, emulate_low_pass: bool) {
|
||||
self.borrow_mut().genesis.lpf_enabled = emulate_low_pass;
|
||||
}
|
||||
|
||||
pub fn genesis_render_vertical_border(&self) -> bool {
|
||||
self.borrow().genesis.render_vertical_border
|
||||
}
|
||||
|
||||
pub fn set_genesis_render_vertical_border(&self, render_vertical_border: bool) {
|
||||
self.borrow_mut().genesis.render_vertical_border = render_vertical_border;
|
||||
}
|
||||
|
||||
pub fn genesis_render_horizontal_border(&self) -> bool {
|
||||
self.borrow().genesis.render_horizontal_border
|
||||
}
|
||||
|
||||
pub fn set_genesis_render_horizontal_border(&self, render_horizontal_border: bool) {
|
||||
self.borrow_mut().genesis.render_horizontal_border = render_horizontal_border;
|
||||
}
|
||||
|
||||
pub fn snes_aspect_ratio(&self) -> String {
|
||||
self.borrow().snes.aspect_ratio.to_string()
|
||||
}
|
||||
|
||||
pub fn set_snes_aspect_ratio(&self, aspect_ratio: &str) {
|
||||
let Ok(aspect_ratio) = aspect_ratio.parse() else { return };
|
||||
self.borrow_mut().snes.aspect_ratio = aspect_ratio;
|
||||
}
|
||||
|
||||
pub fn snes_audio_interpolation(&self) -> String {
|
||||
self.borrow().snes.audio_interpolation.to_string()
|
||||
}
|
||||
|
||||
pub fn set_snes_audio_interpolation(&self, audio_interpolation: &str) {
|
||||
let Ok(audio_interpolation) = audio_interpolation.parse() else { return };
|
||||
self.borrow_mut().snes.audio_interpolation = audio_interpolation;
|
||||
}
|
||||
|
||||
pub fn gba_skip_bios_animation(&self) -> bool {
|
||||
self.borrow().gba.skip_bios_intro_animation
|
||||
}
|
||||
|
||||
pub fn set_gba_skip_bios_animation(&self, skip_bios_animation: bool) {
|
||||
self.borrow_mut().gba.skip_bios_intro_animation = skip_bios_animation;
|
||||
}
|
||||
|
||||
pub fn gba_color_correction(&self) -> String {
|
||||
self.borrow().gba.color_correction.to_string()
|
||||
}
|
||||
|
||||
pub fn set_gba_color_correction(&self, color_correction: &str) {
|
||||
let Ok(color_correction) = color_correction.parse() else { return };
|
||||
self.borrow_mut().gba.color_correction = color_correction;
|
||||
}
|
||||
|
||||
pub fn gba_frame_blending(&self) -> bool {
|
||||
self.borrow().gba.frame_blending
|
||||
}
|
||||
|
||||
pub fn set_gba_frame_blending(&self, frame_blending: bool) {
|
||||
self.borrow_mut().gba.frame_blending = frame_blending;
|
||||
}
|
||||
|
||||
pub fn gba_audio_interpolation(&self) -> String {
|
||||
self.borrow().gba.audio_interpolation.to_string()
|
||||
}
|
||||
|
||||
pub fn set_gba_audio_interpolation(&self, audio_interpolation: &str) {
|
||||
let Ok(audio_interpolation) = audio_interpolation.parse() else { return };
|
||||
self.borrow_mut().gba.audio_interpolation = audio_interpolation;
|
||||
}
|
||||
|
||||
pub fn gba_psg_low_pass(&self) -> bool {
|
||||
self.borrow().gba.psg_low_pass
|
||||
}
|
||||
|
||||
pub fn set_gba_psg_low_pass(&self, psg_low_pass: bool) {
|
||||
self.borrow_mut().gba.psg_low_pass = psg_low_pass;
|
||||
}
|
||||
|
||||
pub fn clone(&self) -> Self {
|
||||
Self(Rc::clone(&self.0))
|
||||
}
|
||||
|
||||
pub fn restore_defaults(&self) {
|
||||
let mut config = self.borrow_mut();
|
||||
*config = WebConfig::default();
|
||||
config.save_to_local_storage();
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for WebConfigRef {
|
||||
@ -323,12 +714,14 @@ impl Default for WebConfigRef {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EmulatorCommand {
|
||||
OpenFile,
|
||||
OpenSegaCd,
|
||||
OpenSegaCdBios,
|
||||
OpenGbaBios,
|
||||
Reset,
|
||||
UploadSaveFile,
|
||||
ConfigureInput { name: String },
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
@ -349,8 +742,12 @@ impl EmulatorChannel {
|
||||
self.commands.borrow_mut().push_back(EmulatorCommand::OpenFile);
|
||||
}
|
||||
|
||||
pub fn request_open_sega_cd(&self) {
|
||||
self.commands.borrow_mut().push_back(EmulatorCommand::OpenSegaCd);
|
||||
pub fn request_open_sega_cd_bios(&self) {
|
||||
self.commands.borrow_mut().push_back(EmulatorCommand::OpenSegaCdBios);
|
||||
}
|
||||
|
||||
pub fn request_open_gba_bios(&self) {
|
||||
self.commands.borrow_mut().push_back(EmulatorCommand::OpenGbaBios);
|
||||
}
|
||||
|
||||
pub fn request_reset(&self) {
|
||||
@ -361,6 +758,10 @@ impl EmulatorChannel {
|
||||
self.commands.borrow_mut().push_back(EmulatorCommand::UploadSaveFile);
|
||||
}
|
||||
|
||||
pub fn request_configure_input(&self, name: &str) {
|
||||
self.commands.borrow_mut().push_back(EmulatorCommand::ConfigureInput { name: name.into() });
|
||||
}
|
||||
|
||||
pub fn current_file_name(&self) -> String {
|
||||
self.current_file_name.borrow().clone()
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use js_sys::{Promise, Uint8Array};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
@ -13,11 +14,13 @@ extern "C" {
|
||||
|
||||
pub fn focusCanvas();
|
||||
|
||||
pub fn showSmsGgConfig();
|
||||
pub fn showSmsGgConfig(input_names: Vec<String>, input_keys: Vec<String>);
|
||||
|
||||
pub fn showGenesisConfig();
|
||||
pub fn showGenesisConfig(input_names: Vec<String>, input_keys: Vec<String>);
|
||||
|
||||
pub fn showSnesConfig();
|
||||
pub fn showSnesConfig(input_names: Vec<String>, input_keys: Vec<String>);
|
||||
|
||||
pub fn showGbaConfig(input_names: Vec<String>, input_keys: Vec<String>);
|
||||
|
||||
pub fn setCursorVisible(visible: bool);
|
||||
|
||||
@ -25,7 +28,26 @@ extern "C" {
|
||||
|
||||
pub fn setSaveUiEnabled(save_ui_enabled: bool);
|
||||
|
||||
pub fn beforeInputConfigure();
|
||||
|
||||
pub fn afterInputConfigure(name: &str, key: &str);
|
||||
|
||||
pub fn localStorageGet(key: &str) -> Option<String>;
|
||||
|
||||
pub fn localStorageSet(key: &str, value: &str);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(module = "/js/idb.js")]
|
||||
extern "C" {
|
||||
// Promise<Object<String, Uint8Array>>
|
||||
pub fn loadSaveFiles(key: &str) -> Promise;
|
||||
|
||||
// Promise<()>
|
||||
pub fn writeSaveFile(key: &str, extension: &str, bytes: Uint8Array) -> Promise;
|
||||
|
||||
// Promise<Uint8Array | null>
|
||||
pub fn loadBios(key: &str) -> Promise;
|
||||
|
||||
// Promise<()>
|
||||
pub fn writeBios(key: &str, bytes: Uint8Array) -> Promise;
|
||||
}
|
||||
|
||||
@ -3,12 +3,13 @@
|
||||
mod audio;
|
||||
mod config;
|
||||
mod js;
|
||||
mod save;
|
||||
|
||||
use crate::audio::AudioQueue;
|
||||
use crate::config::{EmulatorChannel, EmulatorCommand, WebConfig, WebConfigRef};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose;
|
||||
use crate::config::{EmulatorChannel, EmulatorCommand, InputConfig, WebConfig, WebConfigRef};
|
||||
use bincode::{Decode, Encode};
|
||||
use gba_config::GbaInputs;
|
||||
use gba_core::api::GameBoyAdvanceEmulator;
|
||||
use genesis_core::{GenesisEmulator, GenesisInputs};
|
||||
use jgenesis_common::audio::DynamicResamplingRate;
|
||||
use jgenesis_common::frontend::{
|
||||
@ -16,7 +17,9 @@ use jgenesis_common::frontend::{
|
||||
TickEffect,
|
||||
};
|
||||
use jgenesis_renderer::renderer::{WgpuRenderer, WindowSize};
|
||||
use js_sys::Uint8Array;
|
||||
use rfd::AsyncFileDialog;
|
||||
use s32x_core::api::Sega32XEmulator;
|
||||
use segacd_core::api::SegaCdEmulator;
|
||||
use smsgg_config::SmsGgInputs;
|
||||
use smsgg_core::{SmsGgEmulator, SmsGgHardware};
|
||||
@ -24,11 +27,13 @@ use snes_core::api::{CoprocessorRoms, SnesEmulator};
|
||||
use snes_core::input::SnesInputs;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::fmt::{Debug, Display, Formatter};
|
||||
use std::mem;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{AudioContext, AudioContextOptions, Performance};
|
||||
use web_time::Instant;
|
||||
use winit::event::{ElementState, Event, KeyEvent, WindowEvent};
|
||||
@ -75,47 +80,6 @@ impl AudioOutput for WebAudioOutput {
|
||||
// 1MB should be big enough for any save file
|
||||
const SERIALIZATION_BUFFER_LEN: usize = 1024 * 1024;
|
||||
|
||||
struct LocalStorageSaveWriter {
|
||||
file_name: Rc<str>,
|
||||
extension_to_file_name: HashMap<String, Rc<str>>,
|
||||
serialization_buffer: Box<[u8]>,
|
||||
}
|
||||
|
||||
impl LocalStorageSaveWriter {
|
||||
fn new() -> Self {
|
||||
let serialization_buffer = vec![0; SERIALIZATION_BUFFER_LEN].into_boxed_slice();
|
||||
Self {
|
||||
file_name: String::new().into(),
|
||||
extension_to_file_name: HashMap::new(),
|
||||
serialization_buffer,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_file_name(&mut self, file_name: String) {
|
||||
self.file_name = file_name.into();
|
||||
self.extension_to_file_name.clear();
|
||||
}
|
||||
|
||||
fn get_file_name(&mut self, extension: &str) -> Rc<str> {
|
||||
if extension == "sav" {
|
||||
return Rc::clone(&self.file_name);
|
||||
}
|
||||
|
||||
match self.extension_to_file_name.get(extension) {
|
||||
Some(file_name) => Rc::clone(file_name),
|
||||
None => {
|
||||
let mut file_name = self.file_name.to_string();
|
||||
file_name.push('.');
|
||||
file_name.push_str(extension);
|
||||
|
||||
let file_name: Rc<str> = file_name.into();
|
||||
self.extension_to_file_name.insert(extension.into(), Rc::clone(&file_name));
|
||||
file_name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! bincode_config {
|
||||
() => {
|
||||
bincode::config::standard()
|
||||
@ -125,52 +89,86 @@ macro_rules! bincode_config {
|
||||
};
|
||||
}
|
||||
|
||||
impl SaveWriter for LocalStorageSaveWriter {
|
||||
struct IndexedDbSaveWriter {
|
||||
file_name: Rc<str>,
|
||||
extension_to_bytes: HashMap<String, Vec<u8>>,
|
||||
serialization_buffer: Box<[u8]>,
|
||||
}
|
||||
|
||||
impl IndexedDbSaveWriter {
|
||||
fn new() -> Self {
|
||||
let serialization_buffer = vec![0; SERIALIZATION_BUFFER_LEN].into_boxed_slice();
|
||||
Self {
|
||||
file_name: String::new().into(),
|
||||
extension_to_bytes: HashMap::new(),
|
||||
serialization_buffer,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_file_name(
|
||||
&mut self,
|
||||
file_name: String,
|
||||
extension_to_bytes: HashMap<String, Vec<u8>>,
|
||||
) {
|
||||
self.file_name = file_name.into();
|
||||
self.extension_to_bytes = extension_to_bytes;
|
||||
}
|
||||
}
|
||||
|
||||
impl SaveWriter for IndexedDbSaveWriter {
|
||||
type Err = String;
|
||||
|
||||
fn load_bytes(&mut self, extension: &str) -> Result<Vec<u8>, Self::Err> {
|
||||
let file_name = self.get_file_name(extension);
|
||||
let bytes = read_save_file(&file_name)?;
|
||||
|
||||
Ok(bytes)
|
||||
match self.extension_to_bytes.get(extension) {
|
||||
Some(bytes) => Ok(bytes.clone()),
|
||||
None => Err(format!("No save file found for extension {extension}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_bytes(&mut self, extension: &str, bytes: &[u8]) -> Result<(), Self::Err> {
|
||||
let file_name = self.get_file_name(extension);
|
||||
let bytes_b64 = general_purpose::STANDARD.encode(bytes);
|
||||
js::localStorageSet(&file_name, &bytes_b64);
|
||||
self.extension_to_bytes.insert(extension.into(), bytes.to_vec());
|
||||
|
||||
let file_name = Rc::clone(&self.file_name);
|
||||
let extension = extension.to_string();
|
||||
let bytes = bytes.to_vec();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
save::write(&file_name, &extension, &bytes).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_serialized<D: Decode<()>>(&mut self, extension: &str) -> Result<D, Self::Err> {
|
||||
let file_name = self.get_file_name(extension);
|
||||
let bytes = read_save_file(&file_name)?;
|
||||
let (value, _) = bincode::decode_from_slice(&bytes, bincode_config!())
|
||||
.map_err(|err| format!("Error serializing value into {file_name}: {err}"))?;
|
||||
|
||||
Ok(value)
|
||||
match self.extension_to_bytes.get(extension) {
|
||||
Some(bytes) => {
|
||||
let (value, _) =
|
||||
bincode::decode_from_slice(bytes, bincode_config!()).map_err(|err| {
|
||||
format!("Error deserializing value for {}: {err}", self.file_name)
|
||||
})?;
|
||||
Ok(value)
|
||||
}
|
||||
None => Err(format!("No save file found for extension {extension}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_serialized<E: Encode>(&mut self, extension: &str, data: E) -> Result<(), Self::Err> {
|
||||
let bytes_len =
|
||||
bincode::encode_into_slice(data, &mut self.serialization_buffer, bincode_config!())
|
||||
.map_err(|err| format!("Error serializing value: {err}"))?;
|
||||
let bytes_b64 = general_purpose::STANDARD.encode(&self.serialization_buffer[..bytes_len]);
|
||||
|
||||
let file_name = self.get_file_name(extension);
|
||||
js::localStorageSet(&file_name, &bytes_b64);
|
||||
let bytes = self.serialization_buffer[..bytes_len].to_vec();
|
||||
self.extension_to_bytes.insert(extension.into(), bytes.clone());
|
||||
|
||||
let file_name = Rc::clone(&self.file_name);
|
||||
let extension = extension.to_string();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
save::write(&file_name, &extension, &bytes).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_save_file(file_name: &str) -> Result<Vec<u8>, String> {
|
||||
js::localStorageGet(file_name)
|
||||
.and_then(|b64_bytes| general_purpose::STANDARD.decode(b64_bytes).ok())
|
||||
.ok_or_else(|| format!("No save file found for file name {file_name}"))
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// This function will panic if it cannot initialize the console logger.
|
||||
@ -247,7 +245,9 @@ enum Emulator {
|
||||
SmsGg(SmsGgEmulator, SmsGgInputs),
|
||||
Genesis(GenesisEmulator, GenesisInputs),
|
||||
SegaCd(SegaCdEmulator, GenesisInputs),
|
||||
Sega32X(Sega32XEmulator, GenesisInputs),
|
||||
Snes(SnesEmulator, SnesInputs),
|
||||
Gba(GameBoyAdvanceEmulator, GbaInputs),
|
||||
}
|
||||
|
||||
impl Emulator {
|
||||
@ -287,6 +287,13 @@ impl Emulator {
|
||||
!= TickEffect::FrameRendered
|
||||
{}
|
||||
}
|
||||
Self::Sega32X(emulator, inputs) => {
|
||||
while emulator
|
||||
.tick(renderer, audio_output, inputs, save_writer)
|
||||
.expect("Emulator error")
|
||||
!= TickEffect::FrameRendered
|
||||
{}
|
||||
}
|
||||
Self::Snes(emulator, inputs) => {
|
||||
while emulator
|
||||
.tick(renderer, audio_output, inputs, save_writer)
|
||||
@ -294,10 +301,17 @@ impl Emulator {
|
||||
!= TickEffect::FrameRendered
|
||||
{}
|
||||
}
|
||||
Self::Gba(emulator, inputs) => {
|
||||
while emulator
|
||||
.tick(renderer, audio_output, inputs, save_writer)
|
||||
.expect("Emulator error")
|
||||
!= TickEffect::FrameRendered
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self, save_writer: &mut LocalStorageSaveWriter) {
|
||||
fn reset(&mut self, save_writer: &mut IndexedDbSaveWriter) {
|
||||
match self {
|
||||
Self::None(..) => {}
|
||||
Self::SmsGg(emulator, ..) => {
|
||||
@ -309,9 +323,15 @@ impl Emulator {
|
||||
Self::SegaCd(emulator, ..) => {
|
||||
emulator.hard_reset(save_writer);
|
||||
}
|
||||
Self::Sega32X(emulator, ..) => {
|
||||
emulator.hard_reset(save_writer);
|
||||
}
|
||||
Self::Snes(emulator, ..) => {
|
||||
emulator.hard_reset(save_writer);
|
||||
}
|
||||
Self::Gba(emulator, ..) => {
|
||||
emulator.hard_reset(save_writer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -321,21 +341,35 @@ impl Emulator {
|
||||
Self::SmsGg(emulator, ..) => emulator.target_fps(),
|
||||
Self::Genesis(emulator, ..) => emulator.target_fps(),
|
||||
Self::SegaCd(emulator, ..) => emulator.target_fps(),
|
||||
Self::Sega32X(emulator, ..) => emulator.target_fps(),
|
||||
Self::Snes(emulator, ..) => emulator.target_fps(),
|
||||
Self::Gba(emulator, ..) => emulator.target_fps(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_window_event(&mut self, event: &WindowEvent) {
|
||||
fn handle_window_event(&mut self, input_config: &InputConfig, event: &WindowEvent) {
|
||||
let WindowEvent::KeyboardInput {
|
||||
event: KeyEvent { physical_key: PhysicalKey::Code(keycode), state, .. },
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pressed = *state == ElementState::Pressed;
|
||||
|
||||
match self {
|
||||
Self::None(..) => {}
|
||||
Self::SmsGg(_, inputs) => {
|
||||
handle_smsgg_input(inputs, event);
|
||||
input_config.smsgg.handle_input(*keycode, pressed, inputs);
|
||||
}
|
||||
Self::Genesis(_, inputs) | Self::SegaCd(_, inputs) => {
|
||||
handle_genesis_input(inputs, event);
|
||||
Self::Genesis(_, inputs) | Self::SegaCd(_, inputs) | Self::Sega32X(_, inputs) => {
|
||||
input_config.genesis.handle_input(*keycode, pressed, inputs);
|
||||
}
|
||||
Self::Snes(_, inputs) => {
|
||||
handle_snes_input(inputs, event);
|
||||
input_config.snes.handle_input(*keycode, pressed, inputs);
|
||||
}
|
||||
Self::Gba(_, inputs) => {
|
||||
input_config.gba.handle_input(*keycode, pressed, inputs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -352,18 +386,25 @@ impl Emulator {
|
||||
Self::SegaCd(emulator, ..) => {
|
||||
emulator.reload_config(&config.to_sega_cd_config());
|
||||
}
|
||||
Self::Sega32X(emulator, ..) => {
|
||||
emulator.reload_config(&config.to_32x_config());
|
||||
}
|
||||
Self::Snes(emulator, ..) => {
|
||||
emulator.reload_config(&config.snes.to_emulator_config());
|
||||
}
|
||||
Self::Gba(emulator, ..) => {
|
||||
emulator.reload_config(&config.gba.to_emulator_config());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rom_title(&mut self, current_file_name: &str) -> String {
|
||||
match self {
|
||||
Self::None(..) => "(No ROM loaded)".into(),
|
||||
Self::SmsGg(..) => current_file_name.into(),
|
||||
Self::SmsGg(..) | Self::Gba(..) => current_file_name.into(),
|
||||
Self::Genesis(emulator, ..) => emulator.cartridge_title(),
|
||||
Self::SegaCd(emulator, ..) => emulator.disc_title().into(),
|
||||
Self::Sega32X(emulator, ..) => emulator.cartridge_title(),
|
||||
Self::Snes(emulator, ..) => emulator.cartridge_title(),
|
||||
}
|
||||
}
|
||||
@ -374,7 +415,9 @@ impl Emulator {
|
||||
Self::SmsGg(emulator, ..) => emulator.has_sram(),
|
||||
Self::Genesis(emulator, ..) => emulator.has_sram(),
|
||||
Self::SegaCd(..) => true,
|
||||
Self::Sega32X(emulator, ..) => emulator.has_sram(),
|
||||
Self::Snes(emulator, ..) => emulator.has_sram(),
|
||||
Self::Gba(emulator, ..) => emulator.has_save_memory(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -384,91 +427,24 @@ impl Emulator {
|
||||
Self::SmsGg(emulator, ..) => emulator.update_audio_output_frequency(output_frequency),
|
||||
Self::Genesis(emulator, ..) => emulator.update_audio_output_frequency(output_frequency),
|
||||
Self::SegaCd(emulator, ..) => emulator.update_audio_output_frequency(output_frequency),
|
||||
Self::Sega32X(emulator, ..) => emulator.update_audio_output_frequency(output_frequency),
|
||||
Self::Snes(emulator, ..) => emulator.update_audio_output_frequency(output_frequency),
|
||||
Self::Gba(emulator, ..) => emulator.update_audio_output_frequency(output_frequency),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_smsgg_input(inputs: &mut SmsGgInputs, event: &WindowEvent) {
|
||||
let WindowEvent::KeyboardInput {
|
||||
event: KeyEvent { physical_key: PhysicalKey::Code(keycode), state, .. },
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pressed = *state == ElementState::Pressed;
|
||||
|
||||
match keycode {
|
||||
KeyCode::ArrowUp => inputs.p1.up = pressed,
|
||||
KeyCode::ArrowLeft => inputs.p1.left = pressed,
|
||||
KeyCode::ArrowRight => inputs.p1.right = pressed,
|
||||
KeyCode::ArrowDown => inputs.p1.down = pressed,
|
||||
KeyCode::KeyA => inputs.p1.button2 = pressed,
|
||||
KeyCode::KeyS => inputs.p1.button1 = pressed,
|
||||
KeyCode::Enter => inputs.pause = pressed,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_genesis_input(inputs: &mut GenesisInputs, event: &WindowEvent) {
|
||||
let WindowEvent::KeyboardInput {
|
||||
event: KeyEvent { physical_key: PhysicalKey::Code(keycode), state, .. },
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pressed = *state == ElementState::Pressed;
|
||||
|
||||
match keycode {
|
||||
KeyCode::ArrowUp => inputs.p1.up = pressed,
|
||||
KeyCode::ArrowLeft => inputs.p1.left = pressed,
|
||||
KeyCode::ArrowRight => inputs.p1.right = pressed,
|
||||
KeyCode::ArrowDown => inputs.p1.down = pressed,
|
||||
KeyCode::KeyA => inputs.p1.a = pressed,
|
||||
KeyCode::KeyS => inputs.p1.b = pressed,
|
||||
KeyCode::KeyD => inputs.p1.c = pressed,
|
||||
KeyCode::KeyQ => inputs.p1.x = pressed,
|
||||
KeyCode::KeyW => inputs.p1.y = pressed,
|
||||
KeyCode::KeyE => inputs.p1.z = pressed,
|
||||
KeyCode::Enter => inputs.p1.start = pressed,
|
||||
KeyCode::ShiftRight => inputs.p1.mode = pressed,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_snes_input(inputs: &mut SnesInputs, event: &WindowEvent) {
|
||||
let WindowEvent::KeyboardInput {
|
||||
event: KeyEvent { physical_key: PhysicalKey::Code(keycode), state, .. },
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pressed = *state == ElementState::Pressed;
|
||||
|
||||
match keycode {
|
||||
KeyCode::ArrowUp => inputs.p1.up = pressed,
|
||||
KeyCode::ArrowLeft => inputs.p1.left = pressed,
|
||||
KeyCode::ArrowRight => inputs.p1.right = pressed,
|
||||
KeyCode::ArrowDown => inputs.p1.down = pressed,
|
||||
KeyCode::KeyS => inputs.p1.a = pressed,
|
||||
KeyCode::KeyX => inputs.p1.b = pressed,
|
||||
KeyCode::KeyA => inputs.p1.x = pressed,
|
||||
KeyCode::KeyZ => inputs.p1.y = pressed,
|
||||
KeyCode::KeyD => inputs.p1.l = pressed,
|
||||
KeyCode::KeyC => inputs.p1.r = pressed,
|
||||
KeyCode::Enter => inputs.p1.start = pressed,
|
||||
KeyCode::ShiftRight => inputs.p1.select = pressed,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum JgenesisUserEvent {
|
||||
FileOpen { rom: Vec<u8>, bios: Option<Vec<u8>>, rom_file_name: String },
|
||||
UploadSaveFile { contents_base64: String },
|
||||
FileOpen {
|
||||
rom: Vec<u8>,
|
||||
bios_rom: Option<Vec<u8>>,
|
||||
rom_file_name: String,
|
||||
save_files: HashMap<String, Vec<u8>>,
|
||||
},
|
||||
UploadSaveFile {
|
||||
contents: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
const CANVAS_WIDTH: u32 = 878;
|
||||
@ -496,7 +472,7 @@ pub async fn run_emulator(config_ref: WebConfigRef, emulator_channel: EmulatorCh
|
||||
})
|
||||
.expect("Unable to append canvas to document");
|
||||
|
||||
let renderer_config = config_ref.borrow().common.to_renderer_config();
|
||||
let renderer_config = config_ref.borrow().to_renderer_config(false);
|
||||
let mut renderer = WgpuRenderer::new(window, CANVAS_SIZE, renderer_config)
|
||||
.await
|
||||
.expect("Unable to create wgpu renderer");
|
||||
@ -517,7 +493,7 @@ pub async fn run_emulator(config_ref: WebConfigRef, emulator_channel: EmulatorCh
|
||||
.await
|
||||
.expect("Unable to initialize audio worklet");
|
||||
|
||||
let save_writer = LocalStorageSaveWriter::new();
|
||||
let save_writer = IndexedDbSaveWriter::new();
|
||||
|
||||
js::showUi();
|
||||
|
||||
@ -530,7 +506,7 @@ pub async fn run_emulator(config_ref: WebConfigRef, emulator_channel: EmulatorCh
|
||||
struct AppState {
|
||||
renderer: WgpuRenderer<Window>,
|
||||
audio_output: WebAudioOutput,
|
||||
save_writer: LocalStorageSaveWriter,
|
||||
save_writer: IndexedDbSaveWriter,
|
||||
config_ref: WebConfigRef,
|
||||
current_config: WebConfig,
|
||||
emulator_channel: EmulatorChannel,
|
||||
@ -539,13 +515,14 @@ struct AppState {
|
||||
queued_frame: QueuedFrame,
|
||||
performance: Performance,
|
||||
next_frame_time_ms: f64,
|
||||
waiting_for_input: Option<String>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new(
|
||||
renderer: WgpuRenderer<Window>,
|
||||
audio_output: WebAudioOutput,
|
||||
save_writer: LocalStorageSaveWriter,
|
||||
save_writer: IndexedDbSaveWriter,
|
||||
config_ref: WebConfigRef,
|
||||
emulator_channel: EmulatorChannel,
|
||||
) -> Self {
|
||||
@ -571,47 +548,66 @@ impl AppState {
|
||||
queued_frame,
|
||||
performance,
|
||||
next_frame_time_ms,
|
||||
waiting_for_input: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn handle_file_open(&mut self, rom: Vec<u8>, bios: Option<Vec<u8>>, rom_file_name: String) {
|
||||
fn handle_file_open(
|
||||
&mut self,
|
||||
rom: Vec<u8>,
|
||||
bios_rom: Option<Vec<u8>>,
|
||||
save_files: HashMap<String, Vec<u8>>,
|
||||
rom_file_name: String,
|
||||
) {
|
||||
self.audio_output.suspend();
|
||||
self.dynamic_resampling_rate =
|
||||
DynamicResamplingRate::new(audio::SAMPLE_RATE, audio::BUFFER_LEN_SAMPLES / 2);
|
||||
|
||||
let prev_file_name = Rc::clone(&self.save_writer.file_name);
|
||||
self.save_writer.update_file_name(rom_file_name.clone());
|
||||
self.emulator =
|
||||
match open_emulator(rom, bios, &rom_file_name, &self.config_ref, &mut self.save_writer)
|
||||
{
|
||||
Ok(emulator) => emulator,
|
||||
Err(err) => {
|
||||
js::alert(&format!("Error opening ROM file: {err}"));
|
||||
self.save_writer.update_file_name(prev_file_name.to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let prev_save_files = mem::take(&mut self.save_writer.extension_to_bytes);
|
||||
self.save_writer.update_file_name(rom_file_name.clone(), save_files);
|
||||
self.emulator = match open_emulator(
|
||||
rom,
|
||||
bios_rom,
|
||||
&rom_file_name,
|
||||
&self.config_ref,
|
||||
&mut self.save_writer,
|
||||
) {
|
||||
Ok(emulator) => emulator,
|
||||
Err(err) => {
|
||||
js::alert(&format!("Error opening ROM file: {err}"));
|
||||
self.save_writer.update_file_name(prev_file_name.to_string(), prev_save_files);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.emulator_channel.set_current_file_name(rom_file_name.clone());
|
||||
|
||||
let running_gba = matches!(self.emulator, Emulator::Gba(..));
|
||||
self.renderer.reload_config(self.config_ref.borrow().to_renderer_config(running_gba));
|
||||
|
||||
js::setRomTitle(&self.emulator.rom_title(&rom_file_name));
|
||||
js::setSaveUiEnabled(self.emulator.has_persistent_save());
|
||||
|
||||
js::focusCanvas();
|
||||
}
|
||||
|
||||
fn handle_upload_save_file(&mut self, contents_base64: &str) {
|
||||
fn handle_upload_save_file(&mut self, contents: Vec<u8>) {
|
||||
if matches!(self.emulator, Emulator::None(..)) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.audio_output.suspend();
|
||||
|
||||
self.save_writer.extension_to_bytes.insert("sav".into(), contents.clone());
|
||||
|
||||
// Immediately persist save file because it won't get written again until the game writes to SRAM
|
||||
let file_name = self.emulator_channel.current_file_name();
|
||||
js::localStorageSet(&file_name, contents_base64);
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
save::write(&file_name, "sav", &contents).await;
|
||||
});
|
||||
|
||||
self.emulator.reset(&mut self.save_writer);
|
||||
|
||||
@ -623,6 +619,13 @@ impl AppState {
|
||||
event_loop_proxy: &EventLoopProxy<JgenesisUserEvent>,
|
||||
elwt: &ActiveEventLoop,
|
||||
) {
|
||||
if self.waiting_for_input.is_some() {
|
||||
elwt.set_control_flow(ControlFlow::WaitUntil(
|
||||
Instant::now() + Duration::from_millis(1),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.queued_frame.queued {
|
||||
// No frame queued; run emulator until it renders the next frame
|
||||
self.emulator.render_frame(
|
||||
@ -654,6 +657,10 @@ impl AppState {
|
||||
.expect("Frame render error");
|
||||
self.queued_frame.queued = false;
|
||||
|
||||
// GBA may not detect persistent memory until the emulator has been running for a bit, so
|
||||
// call this after every frame
|
||||
js::setSaveUiEnabled(self.emulator.has_persistent_save());
|
||||
|
||||
self.dynamic_resampling_rate.adjust(self.audio_output.audio_queue.len().unwrap());
|
||||
self.emulator.update_audio_output_frequency(
|
||||
self.dynamic_resampling_rate.current_output_frequency().into(),
|
||||
@ -661,7 +668,10 @@ impl AppState {
|
||||
|
||||
let config = self.config_ref.borrow().clone();
|
||||
if config != self.current_config {
|
||||
self.renderer.reload_config(config.common.to_renderer_config());
|
||||
config.save_to_local_storage();
|
||||
|
||||
let running_gba = matches!(self.emulator, Emulator::Gba(..));
|
||||
self.renderer.reload_config(config.to_renderer_config(running_gba));
|
||||
self.emulator.reload_config(&config);
|
||||
self.current_config = config;
|
||||
}
|
||||
@ -675,8 +685,11 @@ impl AppState {
|
||||
EmulatorCommand::OpenFile => {
|
||||
wasm_bindgen_futures::spawn_local(open_file(event_loop_proxy.clone()));
|
||||
}
|
||||
EmulatorCommand::OpenSegaCd => {
|
||||
wasm_bindgen_futures::spawn_local(open_sega_cd(event_loop_proxy.clone()));
|
||||
EmulatorCommand::OpenSegaCdBios => {
|
||||
wasm_bindgen_futures::spawn_local(open_bios(SCD_BIOS_KEY, &["bin"]));
|
||||
}
|
||||
EmulatorCommand::OpenGbaBios => {
|
||||
wasm_bindgen_futures::spawn_local(open_bios(GBA_BIOS_KEY, &["bin"]));
|
||||
}
|
||||
EmulatorCommand::UploadSaveFile => {
|
||||
wasm_bindgen_futures::spawn_local(upload_save_file(event_loop_proxy.clone()));
|
||||
@ -688,12 +701,48 @@ impl AppState {
|
||||
|
||||
js::focusCanvas();
|
||||
}
|
||||
EmulatorCommand::ConfigureInput { name } => {
|
||||
self.waiting_for_input = Some(name);
|
||||
|
||||
js::beforeInputConfigure();
|
||||
js::focusCanvas();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_window_event(&mut self, window_event: WindowEvent, elwt: &ActiveEventLoop) {
|
||||
self.emulator.handle_window_event(&window_event);
|
||||
match &self.waiting_for_input {
|
||||
Some(button) => {
|
||||
if let WindowEvent::KeyboardInput {
|
||||
event:
|
||||
KeyEvent {
|
||||
physical_key: PhysicalKey::Code(keycode),
|
||||
state: ElementState::Pressed,
|
||||
..
|
||||
},
|
||||
..
|
||||
} = &window_event
|
||||
{
|
||||
let input_config = &mut self.config_ref.borrow_mut().inputs;
|
||||
match &self.emulator {
|
||||
Emulator::None(..) => {}
|
||||
Emulator::SmsGg(..) => input_config.smsgg.update_field(button, *keycode),
|
||||
Emulator::Genesis(..) | Emulator::SegaCd(..) | Emulator::Sega32X(..) => {
|
||||
input_config.genesis.update_field(button, *keycode);
|
||||
}
|
||||
Emulator::Snes(..) => input_config.snes.update_field(button, *keycode),
|
||||
Emulator::Gba(..) => input_config.gba.update_field(button, *keycode),
|
||||
}
|
||||
|
||||
js::afterInputConfigure(button, &format!("{keycode:?}"));
|
||||
self.waiting_for_input = None;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.emulator.handle_window_event(&self.config_ref.borrow().inputs, &window_event);
|
||||
}
|
||||
}
|
||||
|
||||
match window_event {
|
||||
WindowEvent::CloseRequested => {
|
||||
@ -740,11 +789,11 @@ fn run_event_loop(event_loop: EventLoop<JgenesisUserEvent>, mut state: AppState)
|
||||
event_loop
|
||||
.run(move |event, elwt| match event {
|
||||
Event::UserEvent(user_event) => match user_event {
|
||||
JgenesisUserEvent::FileOpen { rom, bios, rom_file_name } => {
|
||||
state.handle_file_open(rom, bios, rom_file_name);
|
||||
JgenesisUserEvent::FileOpen { rom, bios_rom, rom_file_name, save_files } => {
|
||||
state.handle_file_open(rom, bios_rom, save_files, rom_file_name);
|
||||
}
|
||||
JgenesisUserEvent::UploadSaveFile { contents_base64 } => {
|
||||
state.handle_upload_save_file(&contents_base64);
|
||||
JgenesisUserEvent::UploadSaveFile { contents } => {
|
||||
state.handle_upload_save_file(contents);
|
||||
}
|
||||
},
|
||||
Event::AboutToWait => {
|
||||
@ -762,51 +811,61 @@ fn run_event_loop(event_loop: EventLoop<JgenesisUserEvent>, mut state: AppState)
|
||||
|
||||
async fn open_file(event_loop_proxy: EventLoopProxy<JgenesisUserEvent>) {
|
||||
let file = AsyncFileDialog::new()
|
||||
.add_filter("Supported Files", &["sms", "gg", "gen", "md", "bin", "smd", "sfc", "smc"])
|
||||
.add_filter(
|
||||
"Supported Files",
|
||||
&["sms", "gg", "gen", "md", "bin", "smd", "chd", "32x", "sfc", "smc", "gba"],
|
||||
)
|
||||
.add_filter("All Types", &["*"])
|
||||
.pick_file()
|
||||
.await;
|
||||
let Some(file) = file else { return };
|
||||
|
||||
let file_name = file.file_name();
|
||||
let extension =
|
||||
Path::new(&file_name).extension().map(|ext| ext.to_string_lossy().to_ascii_lowercase());
|
||||
|
||||
let bios_rom = match extension.as_deref() {
|
||||
Some("chd") => {
|
||||
let Some(bios_rom) = read_bios_from_idb(SCD_BIOS_KEY).await else {
|
||||
js::alert("Sega CD emulation requires a Sega CD BIOS ROM to be configured");
|
||||
return;
|
||||
};
|
||||
Some(bios_rom)
|
||||
}
|
||||
Some("gba") => {
|
||||
let Some(bios_rom) = read_bios_from_idb(GBA_BIOS_KEY).await else {
|
||||
js::alert("GBA emulation requires a GBA BIOS ROM to be configured");
|
||||
return;
|
||||
};
|
||||
Some(bios_rom)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let contents = file.read().await;
|
||||
|
||||
let save_files = save::load_all(&file_name).await;
|
||||
|
||||
event_loop_proxy
|
||||
.send_event(JgenesisUserEvent::FileOpen {
|
||||
rom: contents,
|
||||
bios_rom,
|
||||
rom_file_name: file_name,
|
||||
save_files,
|
||||
})
|
||||
.expect("Unable to send file opened event");
|
||||
}
|
||||
|
||||
async fn open_bios(key: &str, supported_extensions: &[&str]) {
|
||||
let file = AsyncFileDialog::new()
|
||||
.add_filter("Supported Files", supported_extensions)
|
||||
.add_filter("All Types", &["*"])
|
||||
.pick_file()
|
||||
.await;
|
||||
let Some(file) = file else { return };
|
||||
|
||||
let contents = file.read().await;
|
||||
let file_name = file.file_name();
|
||||
|
||||
event_loop_proxy
|
||||
.send_event(JgenesisUserEvent::FileOpen {
|
||||
rom: contents,
|
||||
bios: None,
|
||||
rom_file_name: file_name,
|
||||
})
|
||||
.expect("Unable to send file opened event");
|
||||
}
|
||||
|
||||
async fn open_sega_cd(event_loop_proxy: EventLoopProxy<JgenesisUserEvent>) {
|
||||
let bios_file = AsyncFileDialog::new()
|
||||
.set_title("Sega CD BIOS")
|
||||
.add_filter("bin", &["bin"])
|
||||
.pick_file()
|
||||
.await;
|
||||
|
||||
let Some(bios_file) = bios_file else { return };
|
||||
let bios_contents = bios_file.read().await;
|
||||
|
||||
let chd_file = AsyncFileDialog::new()
|
||||
.set_title("CD-ROM image (only CHD supported)")
|
||||
.add_filter("chd", &["chd"])
|
||||
.pick_file()
|
||||
.await;
|
||||
|
||||
let Some(chd_file) = chd_file else { return };
|
||||
let chd_contents = chd_file.read().await;
|
||||
let chd_file_name = chd_file.file_name();
|
||||
|
||||
event_loop_proxy
|
||||
.send_event(JgenesisUserEvent::FileOpen {
|
||||
rom: chd_contents,
|
||||
bios: Some(bios_contents),
|
||||
rom_file_name: chd_file_name,
|
||||
})
|
||||
.expect("Unable to send Sega CD BIOS/CHD opened event");
|
||||
write_bios_to_idb(key, &contents).await;
|
||||
}
|
||||
|
||||
async fn upload_save_file(event_loop_proxy: EventLoopProxy<JgenesisUserEvent>) {
|
||||
@ -814,29 +873,77 @@ async fn upload_save_file(event_loop_proxy: EventLoopProxy<JgenesisUserEvent>) {
|
||||
let Some(file) = file else { return };
|
||||
|
||||
let contents = file.read().await;
|
||||
let contents_base64 = general_purpose::STANDARD.encode(contents);
|
||||
|
||||
event_loop_proxy
|
||||
.send_event(JgenesisUserEvent::UploadSaveFile { contents_base64 })
|
||||
.send_event(JgenesisUserEvent::UploadSaveFile { contents })
|
||||
.expect("Unable to send upload save file event");
|
||||
}
|
||||
|
||||
enum OpenEmulatorError {
|
||||
NoSegaCdBios,
|
||||
NoGbaBios,
|
||||
Other(Box<dyn Error>),
|
||||
}
|
||||
|
||||
impl Display for OpenEmulatorError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoSegaCdBios => {
|
||||
write!(f, "No Sega CD BIOS is configured; required for Sega CD emulation")
|
||||
}
|
||||
Self::NoGbaBios => write!(f, "No GBA BIOS is configured; required for GBA emulation"),
|
||||
Self::Other(err) => write!(f, "{err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SCD_BIOS_KEY: &str = "segacd-bios-rom";
|
||||
const GBA_BIOS_KEY: &str = "gba-bios-rom";
|
||||
|
||||
async fn read_bios_from_idb(key: &str) -> Option<Vec<u8>> {
|
||||
try_read_bios_from_idb(key).await.unwrap_or_else(|err| {
|
||||
log::error!(
|
||||
"Error reading BIOS ROM from IndexedDB: {}",
|
||||
err.as_string().unwrap_or_default()
|
||||
);
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
async fn try_read_bios_from_idb(key: &str) -> Result<Option<Vec<u8>>, JsValue> {
|
||||
let object = JsFuture::from(js::loadBios(key)).await?;
|
||||
if object.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let array = object.dyn_into::<Uint8Array>()?;
|
||||
Ok(Some(array.to_vec()))
|
||||
}
|
||||
|
||||
async fn write_bios_to_idb(key: &str, bytes: &[u8]) {
|
||||
let array = Uint8Array::new_from_slice(bytes);
|
||||
if let Err(err) = JsFuture::from(js::writeBios(key, array)).await {
|
||||
log::error!("Error saving BIOS ROM to IndexedDB: {}", err.as_string().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::map_unwrap_or)]
|
||||
fn open_emulator(
|
||||
rom: Vec<u8>,
|
||||
bios: Option<Vec<u8>>,
|
||||
bios_rom: Option<Vec<u8>>,
|
||||
rom_file_name: &str,
|
||||
config_ref: &WebConfigRef,
|
||||
save_writer: &mut LocalStorageSaveWriter,
|
||||
) -> Result<Emulator, Box<dyn Error>> {
|
||||
let file_ext = Path::new(rom_file_name).extension().map(|ext| ext.to_string_lossy().to_string()).unwrap_or_else(|| {
|
||||
save_writer: &mut IndexedDbSaveWriter,
|
||||
) -> Result<Emulator, OpenEmulatorError> {
|
||||
let file_ext = Path::new(rom_file_name).extension().map(|ext| ext.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| {
|
||||
log::warn!("Unable to determine file extension of uploaded file; defaulting to Genesis emulator");
|
||||
"md".into()
|
||||
});
|
||||
|
||||
match file_ext.as_str() {
|
||||
file_ext @ ("sms" | "gg") => {
|
||||
js::showSmsGgConfig();
|
||||
let inputs = config_ref.borrow().inputs.smsgg_inputs();
|
||||
js::showSmsGgConfig(inputs.0, inputs.1);
|
||||
|
||||
let hardware = match file_ext {
|
||||
"sms" => SmsGgHardware::MasterSystem,
|
||||
@ -853,7 +960,8 @@ fn open_emulator(
|
||||
Ok(Emulator::SmsGg(emulator, SmsGgInputs::default()))
|
||||
}
|
||||
"gen" | "md" | "bin" | "smd" => {
|
||||
js::showGenesisConfig();
|
||||
let inputs = config_ref.borrow().inputs.genesis_inputs();
|
||||
js::showGenesisConfig(inputs.0, inputs.1);
|
||||
|
||||
let emulator = GenesisEmulator::create(
|
||||
rom,
|
||||
@ -863,32 +971,67 @@ fn open_emulator(
|
||||
Ok(Emulator::Genesis(emulator, GenesisInputs::default()))
|
||||
}
|
||||
"chd" => {
|
||||
let Some(bios) = bios else { return Err("No SEGA CD BIOS supplied".into()) };
|
||||
let Some(bios) = bios_rom else {
|
||||
return Err(OpenEmulatorError::NoSegaCdBios);
|
||||
};
|
||||
|
||||
let emulator = SegaCdEmulator::create_in_memory(
|
||||
bios,
|
||||
rom,
|
||||
config_ref.borrow().to_sega_cd_config(),
|
||||
save_writer,
|
||||
)?;
|
||||
)
|
||||
.map_err(|err| OpenEmulatorError::Other(err.into()))?;
|
||||
|
||||
js::showGenesisConfig();
|
||||
let inputs = config_ref.borrow().inputs.genesis_inputs();
|
||||
js::showGenesisConfig(inputs.0, inputs.1);
|
||||
|
||||
Ok(Emulator::SegaCd(emulator, GenesisInputs::default()))
|
||||
}
|
||||
"32x" => {
|
||||
let emulator =
|
||||
Sega32XEmulator::create(rom, config_ref.borrow().to_32x_config(), save_writer);
|
||||
|
||||
let inputs = config_ref.borrow().inputs.genesis_inputs();
|
||||
js::showGenesisConfig(inputs.0, inputs.1);
|
||||
|
||||
Ok(Emulator::Sega32X(emulator, GenesisInputs::default()))
|
||||
}
|
||||
"sfc" | "smc" => {
|
||||
let emulator = SnesEmulator::create(
|
||||
rom,
|
||||
config_ref.borrow().snes.to_emulator_config(),
|
||||
CoprocessorRoms::none(),
|
||||
save_writer,
|
||||
)?;
|
||||
)
|
||||
.map_err(|err| OpenEmulatorError::Other(err.into()))?;
|
||||
|
||||
js::showSnesConfig();
|
||||
let inputs = config_ref.borrow().inputs.snes_inputs();
|
||||
js::showSnesConfig(inputs.0, inputs.1);
|
||||
|
||||
Ok(Emulator::Snes(emulator, SnesInputs::default()))
|
||||
}
|
||||
_ => Err(format!("Unsupported file extension: {file_ext}").into()),
|
||||
"gba" => {
|
||||
let Some(bios) = bios_rom else {
|
||||
return Err(OpenEmulatorError::NoGbaBios);
|
||||
};
|
||||
|
||||
let emulator = GameBoyAdvanceEmulator::create(
|
||||
rom,
|
||||
bios,
|
||||
config_ref.borrow().gba.to_emulator_config(),
|
||||
save_writer,
|
||||
)
|
||||
.map_err(|err| OpenEmulatorError::Other(err.into()))?;
|
||||
|
||||
let inputs = config_ref.borrow().inputs.gba_inputs();
|
||||
js::showGbaConfig(inputs.0, inputs.1);
|
||||
|
||||
Ok(Emulator::Gba(emulator, GbaInputs::default()))
|
||||
}
|
||||
_ => {
|
||||
Err(OpenEmulatorError::Other(format!("Unsupported file extension: {file_ext}").into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -897,9 +1040,3 @@ fn open_emulator(
|
||||
pub fn build_commit_hash() -> Option<String> {
|
||||
option_env!("JGENESIS_COMMIT").map(String::from)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[wasm_bindgen]
|
||||
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
general_purpose::STANDARD.decode(s).ok()
|
||||
}
|
||||
|
||||
50
frontend/jgenesis-web/src/save.rs
Normal file
50
frontend/jgenesis-web/src/save.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use crate::js;
|
||||
use js_sys::{Array, Object, Uint8Array};
|
||||
use std::collections::HashMap;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
pub async fn load_all(file_name: &str) -> HashMap<String, Vec<u8>> {
|
||||
try_load_all(file_name).await.unwrap_or_else(|err| {
|
||||
log::error!(
|
||||
"Error reading save files for file {file_name}: {}",
|
||||
err.as_string().unwrap_or_default()
|
||||
);
|
||||
HashMap::new()
|
||||
})
|
||||
}
|
||||
|
||||
async fn try_load_all(file_name: &str) -> Result<HashMap<String, Vec<u8>>, JsValue> {
|
||||
let files = JsFuture::from(js::loadSaveFiles(file_name)).await?;
|
||||
let files = files.dyn_into::<Object>()?;
|
||||
|
||||
let mut files_map: HashMap<String, Vec<u8>> = HashMap::new();
|
||||
for file in Object::entries(&files) {
|
||||
let file = file.dyn_into::<Array>()?;
|
||||
|
||||
let Some(extension) = file.get(0).as_string() else {
|
||||
return Err(JsValue::from_str("Invalid data in IndexedDB"));
|
||||
};
|
||||
let bytes = file.get(1).dyn_into::<Uint8Array>()?;
|
||||
|
||||
files_map.insert(extension, bytes.to_vec());
|
||||
}
|
||||
|
||||
Ok(files_map)
|
||||
}
|
||||
|
||||
pub async fn write(file_name: &str, extension: &str, bytes: &[u8]) {
|
||||
if let Err(err) = try_write(file_name, extension, bytes).await {
|
||||
log::error!(
|
||||
"Error persisting save file for file {file_name} extension {extension}: {}",
|
||||
err.as_string().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_write(file_name: &str, extension: &str, bytes: &[u8]) -> Result<(), JsValue> {
|
||||
let array = Uint8Array::new_from_slice(bytes);
|
||||
JsFuture::from(js::writeSaveFile(file_name, extension, array)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user