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:
jsgroth 2025-12-05 14:00:41 -06:00
parent d89b1588e8
commit cd87b50c64
12 changed files with 1381 additions and 337 deletions

13
Cargo.lock generated
View File

@ -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",

View File

@ -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)?;

View File

@ -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 {

View File

@ -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 {

View File

@ -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"] }

View File

@ -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");

View 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();
};
});
}

View File

@ -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}

View File

@ -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()
}

View File

@ -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;
}

View File

@ -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()
}

View 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(())
}