commit before switching this from static site to app

This commit is contained in:
roormonger
2026-03-29 20:53:35 -04:00
parent 37e349d3e3
commit 68a1e99e78
88 changed files with 1443 additions and 62 deletions

BIN
bios/3do/goldstar.bin Normal file

Binary file not shown.

BIN
bios/3do/panafz1.bin Normal file

Binary file not shown.

BIN
bios/3do/panafz10.bin Normal file

Binary file not shown.

BIN
bios/amiga/kick34005.A500 Normal file

Binary file not shown.

BIN
bios/amiga/kick40063.A600 Normal file

Binary file not shown.

BIN
bios/amiga/kick40068.A1200 Normal file

Binary file not shown.

BIN
bios/amiga/kick40068.A4000 Normal file

Binary file not shown.

BIN
bios/arcade/neogeo.zip Normal file

Binary file not shown.

Binary file not shown.

BIN
bios/dc/dc_boot.bin Normal file

Binary file not shown.

BIN
bios/dc/dc_flash.bin Normal file

Binary file not shown.

BIN
bios/fds/disksys.rom Normal file

Binary file not shown.

BIN
bios/gba/gba_bios.bin Normal file

Binary file not shown.

BIN
bios/msx/MSX2P.ROM Normal file

Binary file not shown.

BIN
bios/msx/MSX2PEXT.ROM Normal file

Binary file not shown.

BIN
bios/msx/msx.rom Normal file

Binary file not shown.

BIN
bios/msx/msx2.rom Normal file

Binary file not shown.

BIN
bios/msx/msx2ext.rom Normal file

Binary file not shown.

BIN
bios/nds/firmware.bin Normal file

Binary file not shown.

BIN
bios/neogeoaes/neogeo.zip Normal file

Binary file not shown.

BIN
bios/neogeomvs/neogeo.zip Normal file

Binary file not shown.

Binary file not shown.

BIN
bios/odyssey-2/c52.bin Normal file

Binary file not shown.

BIN
bios/odyssey-2/g7400.bin Normal file

Binary file not shown.

BIN
bios/odyssey-2/jopac.bin Normal file

Binary file not shown.

BIN
bios/odyssey-2/o2rom.bin Normal file

Binary file not shown.

BIN
bios/psp/ppge_atlas.zim Normal file

Binary file not shown.

BIN
bios/psx/scph1001.bin Normal file

Binary file not shown.

BIN
bios/psx/scph5500.bin Normal file

Binary file not shown.

BIN
bios/psx/scph5501.bin Normal file

Binary file not shown.

BIN
bios/psx/scph5502.bin Normal file

Binary file not shown.

BIN
bios/psx/scph7001.bin Normal file

Binary file not shown.

BIN
bios/saturn/mpr-17933.bin Normal file

Binary file not shown.

BIN
bios/saturn/sega_101.bin Normal file

Binary file not shown.

BIN
bios/segacd/bios_CD_E.bin Normal file

Binary file not shown.

BIN
bios/segacd/bios_CD_J.bin Normal file

Binary file not shown.

BIN
bios/segacd/bios_CD_U.bin Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
client_history.txt Normal file

Binary file not shown.

BIN
cores.json Normal file

Binary file not shown.

BIN
cores2.json Normal file

Binary file not shown.

BIN
cores_list.json Normal file

Binary file not shown.

1
emulator.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RomM Web UI</title>
<!-- Cross-Origin-Isolation Worker: Unlocks SharedArrayBuffer multi-threading without stripping external cover art proxy streams! -->
<script src="/coi-serviceworker.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
</head>

0
latest_roms.json Normal file
View File

170
loader.js Normal file
View File

@@ -0,0 +1,170 @@
(async function() {
const scripts = [
"emulator.js",
"nipplejs.js",
"shaders.js",
"storage.js",
"gamepad.js",
"GameManager.js",
"socket.io.min.js",
"compression.js"
];
const folderPath = (path) => path.substring(0, path.length - path.split("/").pop().length);
let scriptPath = (typeof window.EJS_pathtodata === "string") ? window.EJS_pathtodata : folderPath((new URL(document.currentScript.src)).pathname);
if (!scriptPath.endsWith("/")) scriptPath += "/";
//console.log(scriptPath);
function loadScript(file) {
return new Promise(function(resolve) {
let script = document.createElement("script");
script.src = function() {
if ("undefined" != typeof EJS_paths && typeof EJS_paths[file] === "string") {
return EJS_paths[file];
} else if (file.endsWith("emulator.min.js")) {
return scriptPath + file;
} else {
return scriptPath + "src/" + file;
}
}();
script.onload = resolve;
script.onerror = () => {
filesmissing(file).then(e => resolve());
}
document.head.appendChild(script);
})
}
function loadStyle(file) {
return new Promise(function(resolve) {
let css = document.createElement("link");
css.rel = "stylesheet";
css.href = function() {
if ("undefined" != typeof EJS_paths && typeof EJS_paths[file] === "string") {
return EJS_paths[file];
} else {
return scriptPath + file;
}
}();
css.onload = resolve;
css.onerror = () => {
filesmissing(file).then(e => resolve());
}
document.head.appendChild(css);
})
}
async function filesmissing(file) {
console.error("Failed to load " + file);
let minifiedFailed = file.includes(".min.") && !file.includes("socket");
console[minifiedFailed ? "warn" : "error"]("Failed to load " + file + " beacuse it's likly that the minified files are missing.\nTo fix this you have 3 options:\n1. You can download the zip from the latest release here: https://github.com/EmulatorJS/EmulatorJS/releases/latest - Stable\n2. You can download the zip from here: https://cdn.emulatorjs.org/latest/data/emulator.min.zip and extract it to the data/ folder. (easiest option) - Beta\n3. You can build the files by running `npm i && npm run build` in the data/minify folder. (hardest option) - Beta\nNote: you will probably need to do the same for the cores, extract them to the data/cores/ folder.");
if (minifiedFailed) {
console.log("Attempting to load non-minified files");
if (file === "emulator.min.js") {
for (let i = 0; i < scripts.length; i++) {
await loadScript(scripts[i]);
}
} else {
await loadStyle("emulator.css");
}
}
}
if (("undefined" != typeof EJS_DEBUG_XX && true === EJS_DEBUG_XX)) {
for (let i = 0; i < scripts.length; i++) {
await loadScript(scripts[i]);
}
await loadStyle("emulator.css");
} else {
await loadScript("emulator.min.js");
await loadStyle("emulator.min.css");
}
const config = {};
config.gameUrl = window.EJS_gameUrl;
config.dataPath = scriptPath;
config.system = window.EJS_core;
config.biosUrl = window.EJS_biosUrl;
config.gameName = window.EJS_gameName;
config.color = window.EJS_color;
config.adUrl = window.EJS_AdUrl;
config.adMode = window.EJS_AdMode;
config.adTimer = window.EJS_AdTimer;
config.adSize = window.EJS_AdSize;
config.alignStartButton = window.EJS_alignStartButton;
config.VirtualGamepadSettings = window.EJS_VirtualGamepadSettings;
config.buttonOpts = window.EJS_Buttons;
config.volume = window.EJS_volume;
config.defaultControllers = window.EJS_defaultControls;
config.startOnLoad = window.EJS_startOnLoaded;
config.fullscreenOnLoad = window.EJS_fullscreenOnLoaded;
config.filePaths = window.EJS_paths;
config.loadState = window.EJS_loadStateURL;
config.cacheLimit = window.EJS_CacheLimit;
config.cheats = window.EJS_cheats;
config.defaultOptions = window.EJS_defaultOptions;
config.gamePatchUrl = window.EJS_gamePatchUrl;
config.gameParentUrl = window.EJS_gameParentUrl;
config.netplayUrl = window.EJS_netplayServer;
config.gameId = window.EJS_gameID;
config.backgroundImg = window.EJS_backgroundImage;
config.backgroundBlur = window.EJS_backgroundBlur;
config.backgroundColor = window.EJS_backgroundColor;
config.controlScheme = window.EJS_controlScheme;
config.threads = window.EJS_threads;
config.disableCue = window.EJS_disableCue;
config.startBtnName = window.EJS_startButtonName;
config.softLoad = window.EJS_softLoad;
config.capture = window.EJS_screenCapture;
config.externalFiles = window.EJS_externalFiles;
config.dontExtractBIOS = window.EJS_dontExtractBIOS;
config.disableDatabases = window.EJS_disableDatabases;
config.disableLocalStorage = window.EJS_disableLocalStorage;
config.forceLegacyCores = window.EJS_forceLegacyCores;
config.noAutoFocus = window.EJS_noAutoFocus;
config.videoRotation = window.EJS_videoRotation;
config.hideSettings = window.EJS_hideSettings;
config.shaders = Object.assign({}, window.EJS_SHADERS, window.EJS_shaders ? window.EJS_shaders : {});
let systemLang;
try {
systemLang = Intl.DateTimeFormat().resolvedOptions().locale;
} catch(e) {} //Ignore
if ((typeof window.EJS_language === "string" && window.EJS_language !== "en-US") || (systemLang && window.EJS_disableAutoLang !== false)) {
const language = window.EJS_language || systemLang;
try {
let path;
console.log("Loading language", language);
if ("undefined" != typeof EJS_paths && typeof EJS_paths[language] === "string") {
path = EJS_paths[language];
} else {
path = scriptPath + "localization/" + language + ".json";
}
config.language = language;
config.langJson = JSON.parse(await (await fetch(path)).text());
} catch(e) {
console.log("Missing language", language, "!!");
delete config.language;
delete config.langJson;
}
}
window.EJS_emulator = new EmulatorJS(EJS_player, config);
window.EJS_adBlocked = (url, del) => window.EJS_emulator.adBlocked(url, del);
if (typeof window.EJS_ready === "function") {
window.EJS_emulator.on("ready", window.EJS_ready);
}
if (typeof window.EJS_onGameStart === "function") {
window.EJS_emulator.on("start", window.EJS_onGameStart);
}
if (typeof window.EJS_onLoadState === "function") {
window.EJS_emulator.on("loadState", window.EJS_onLoadState);
}
if (typeof window.EJS_onSaveState === "function") {
window.EJS_emulator.on("saveState", window.EJS_onSaveState);
}
if (typeof window.EJS_onLoadSave === "function") {
window.EJS_emulator.on("loadSave", window.EJS_onLoadSave);
}
if (typeof window.EJS_onSaveSave === "function") {
window.EJS_emulator.on("saveSave", window.EJS_onSaveSave);
}
})();

BIN
openapi.json Normal file

Binary file not shown.

BIN
public/bios/3do.zip Normal file

Binary file not shown.

BIN
public/bios/amiga.zip Normal file

Binary file not shown.

BIN
public/bios/arcade.zip Normal file

Binary file not shown.

Binary file not shown.

BIN
public/bios/dc.zip Normal file

Binary file not shown.

BIN
public/bios/fds.zip Normal file

Binary file not shown.

BIN
public/bios/gba.zip Normal file

Binary file not shown.

BIN
public/bios/jaguar.zip Normal file

Binary file not shown.

BIN
public/bios/msx.zip Normal file

Binary file not shown.

BIN
public/bios/nds.zip Normal file

Binary file not shown.

BIN
public/bios/neogeoaes.zip Normal file

Binary file not shown.

BIN
public/bios/neogeomvs.zip Normal file

Binary file not shown.

Binary file not shown.

BIN
public/bios/odyssey-2.zip Normal file

Binary file not shown.

BIN
public/bios/ps2.zip Normal file

Binary file not shown.

BIN
public/bios/psp.zip Normal file

Binary file not shown.

BIN
public/bios/psx.zip Normal file

Binary file not shown.

BIN
public/bios/saturn.zip Normal file

Binary file not shown.

BIN
public/bios/segacd.zip Normal file

Binary file not shown.

Binary file not shown.

2
public/coi-serviceworker.min.js vendored Normal file
View File

@@ -0,0 +1,2 @@
/*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */
let coepCredentialless=!1;"undefined"==typeof window?(self.addEventListener("install",(()=>self.skipWaiting())),self.addEventListener("activate",(e=>e.waitUntil(self.clients.claim()))),self.addEventListener("message",(e=>{e.data&&("deregister"===e.data.type?self.registration.unregister().then((()=>self.clients.matchAll())).then((e=>{e.forEach((e=>e.navigate(e.url)))})):"coepCredentialless"===e.data.type&&(coepCredentialless=e.data.value))})),self.addEventListener("fetch",(function(e){const o=e.request;if("only-if-cached"===o.cache&&"same-origin"!==o.mode)return;const s=coepCredentialless&&"no-cors"===o.mode?new Request(o,{credentials:"omit"}):o;e.respondWith(fetch(s).then((e=>{if(0===e.status)return e;const o=new Headers(e.headers);return o.set("Cross-Origin-Embedder-Policy",coepCredentialless?"credentialless":"require-corp"),coepCredentialless||o.set("Cross-Origin-Resource-Policy","cross-origin"),o.set("Cross-Origin-Opener-Policy","same-origin"),new Response(e.body,{status:e.status,statusText:e.statusText,headers:o})})).catch((e=>console.error(e))))}))):(()=>{const e=window.sessionStorage.getItem("coiReloadedBySelf");window.sessionStorage.removeItem("coiReloadedBySelf");const o="coepdegrade"==e,s={shouldRegister:()=>!e,shouldDeregister:()=>!1,coepCredentialless:()=>!0,coepDegrade:()=>!0,doReload:()=>window.location.reload(),quiet:!1,...window.coi},r=navigator,t=r.serviceWorker&&r.serviceWorker.controller;t&&!window.crossOriginIsolated&&window.sessionStorage.setItem("coiCoepHasFailed","true");const i=window.sessionStorage.getItem("coiCoepHasFailed");if(t){const e=s.coepDegrade()&&!(o||window.crossOriginIsolated);r.serviceWorker.controller.postMessage({type:"coepCredentialless",value:!(e||i&&s.coepDegrade())&&s.coepCredentialless()}),e&&(!s.quiet&&console.log("Reloading page to degrade COEP."),window.sessionStorage.setItem("coiReloadedBySelf","coepdegrade"),s.doReload("coepdegrade")),s.shouldDeregister()&&r.serviceWorker.controller.postMessage({type:"deregister"})}!1===window.crossOriginIsolated&&s.shouldRegister()&&(window.isSecureContext?r.serviceWorker?r.serviceWorker.register(window.document.currentScript.src).then((e=>{!s.quiet&&console.log("COOP/COEP Service Worker registered",e.scope),e.addEventListener("updatefound",(()=>{!s.quiet&&console.log("Reloading page to make use of updated COOP/COEP Service Worker."),window.sessionStorage.setItem("coiReloadedBySelf","updatefound"),s.doReload()})),e.active&&!r.serviceWorker.controller&&(!s.quiet&&console.log("Reloading page to make use of COOP/COEP Service Worker."),window.sessionStorage.setItem("coiReloadedBySelf","notcontrolling"),s.doReload())}),(e=>{!s.quiet&&console.error("COOP/COEP Service Worker failed to register:",e)})):!s.quiet&&console.error("COOP/COEP Service Worker not registered, perhaps due to private mode."):!s.quiet&&console.log("COOP/COEP Service Worker not registered, a secure context is required."))})();

Binary file not shown.

Binary file not shown.

27
public/emu/cores/nes.js Normal file
View File

@@ -0,0 +1,27 @@
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404: Page not found</title>
<style>
body, html {
background-color: #000;
}
h2, p {
color: #fff;
}
a {
color: #0000EE;
text-decoration: underline;
cursor: pointer;
}
a:visited {
color: #800080;
}
</style>
</head>
<body>
<h2>404: Page not found</h2>
<p>Idk where you're trying to go, but its not here....</p>
<a herf="#" onclick="history.back()">Go Back?</a>
</body>
</html>

27
public/emu/cores/nes.wasm Normal file
View File

@@ -0,0 +1,27 @@
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404: Page not found</title>
<style>
body, html {
background-color: #000;
}
h2, p {
color: #fff;
}
a {
color: #0000EE;
text-decoration: underline;
cursor: pointer;
}
a:visited {
color: #800080;
}
</style>
</head>
<body>
<h2>404: Page not found</h2>
<p>Idk where you're trying to go, but its not here....</p>
<a herf="#" onclick="history.back()">Go Back?</a>
</body>
</html>

View File

@@ -0,0 +1 @@
{ "core": "fceumm", "buildStart": "2026-02-04T02:44:07+00:00", "buildEnd": "2026-02-04T02:45:32+00:00", "options": {} }

27
public/emu/emulator.js Normal file
View File

@@ -0,0 +1,27 @@
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404: Page not found</title>
<style>
body, html {
background-color: #000;
}
h2, p {
color: #fff;
}
a {
color: #0000EE;
text-decoration: underline;
cursor: pointer;
}
a:visited {
color: #800080;
}
</style>
</head>
<body>
<h2>404: Page not found</h2>
<p>Idk where you're trying to go, but its not here....</p>
<a herf="#" onclick="history.back()">Go Back?</a>
</body>
</html>

1
public/emu/emulator.min.css vendored Normal file

File diff suppressed because one or more lines are too long

2
public/emu/emulator.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

264
public/emu/loader.js Normal file
View File

@@ -0,0 +1,264 @@
const folderPath = (path) => {
const filename = path.split("/").pop();
return path.substring(0, path.length - filename.length);
};
function isAbsoluteUrl(path) {
return /^[a-zA-Z][\w.+-]*:\/\//i.test(path);
}
let scriptPath = (typeof window.EJS_pathtodata === "string") ? window.EJS_pathtodata : folderPath((new URL(document.currentScript.src)).pathname);
if (!scriptPath.endsWith("/")) {
scriptPath += "/";
}
if (!scriptPath.startsWith("/") && !isAbsoluteUrl(scriptPath)) {
scriptPath = "../" + scriptPath;
}
const debug = window.EJS_DEBUG_XX === true;
if (debug) {
console.log("Script Path:", scriptPath);
}
function resolvePath(path) {
if ("undefined" != typeof EJS_paths && typeof EJS_paths[path] === "string") {
return EJS_paths[path];
} else if (path.endsWith("emulator.min.js") || path.endsWith("css")) {
return scriptPath + path;
} else {
return scriptPath + "src/" + path;
}
}
async function loadScript(file) {
try {
const script = resolvePath(file);
const module = await import(script);
return module.default;
} catch(e) {
if (debug) console.error(e);
const module = await filesMissing(file);
return module.default;
}
}
function loadStyle(file) {
return new Promise(function(resolve) {
let css = document.createElement("link");
css.rel = "stylesheet";
css.href = resolvePath(file);
css.onload = resolve;
css.onerror = () => {
filesMissing(file).then(e => resolve());
}
document.head.appendChild(css);
})
}
async function filesMissing(file) {
console.error("Failed to load " + file);
let minifiedFailed = file.includes("min");
const errorMessage = `Failed to load ${file} because it's likely that the minified files are missing.
To fix this you have 3 options:
1. You can download the zip from the latest release here: https://github.com/EmulatorJS/EmulatorJS/releases/latest - Recommended
2. You can download the zip from here: https://cdn.emulatorjs.org/stable/data/emulator.min.zip and extract it to the data/ folder
3. You can build the files by running "npm i && npm run build" in the data/minify folder.`;
console[minifiedFailed ? "warn" : "error"](errorMessage);
if (minifiedFailed) {
console.log("Attempting to load non-minified files");
if (file === "emulator.min.js") {
return await loadScript("emulator.js");
} else {
await loadStyle("emulator.css");
}
}
}
function getLanguagePath(language) {
if ("undefined" != typeof EJS_paths && typeof EJS_paths[language] === "string") {
return { path: EJS_paths[language], fallback: null };
}
const base = scriptPath + "localization/" + language + ".json";
let fallback = null;
if (language.includes("-") || language.includes("_")) {
fallback = scriptPath + "localization/" + language.split(/[-_]/)[0] + ".json";
}
return { path: base, fallback };
}
async function fetchJson(path) {
try {
const response = await fetch(path);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (e) {
console.warn("Failed to fetch language file:", path, e.message);
return null;
}
}
function mergeLanguages(baseJson, overrideJson) {
if (!baseJson || !overrideJson) return baseJson || overrideJson || {};
return { ...baseJson, ...overrideJson };
}
async function loadLanguage(config) {
const defaultLangs = ["en", "en-US"];
if (!config.language || defaultLangs.includes(config.language)) return config;
console.log("Language:", config.language);
let langData = {};
const paths = getLanguagePath(config.language);
try {
const specificJson = await fetchJson(paths.path);
if (paths.fallback) {
const fallbackJson = await fetchJson(paths.fallback);
langData = mergeLanguages(fallbackJson, specificJson || {});
} else {
langData = specificJson || {};
}
config.langJson = langData;
} catch (e) {
if (paths.fallback) {
const fallbackLang = config.language.split(/[-_]/)[0];
console.warn(`Language '${config.language}' not found, trying '${fallbackLang}'`);
const fallbackJson = await fetchJson(paths.fallback);
if (fallbackJson) {
langData = fallbackJson;
config.langJson = langData;
config.language = fallbackLang;
}
} else {
console.warn(`No language file found for '${config.language}'`);
delete config.language;
delete config.langJson;
}
}
return config;
}
const config = {
debug: debug,
gameUrl: window.EJS_gameUrl,
dataPath: scriptPath,
system: window.EJS_core,
biosUrl: window.EJS_biosUrl,
gameName: window.EJS_gameName,
color: window.EJS_color,
adUrl: window.EJS_AdUrl,
adMode: window.EJS_AdMode,
adTimer: window.EJS_AdTimer,
adSize: window.EJS_AdSize,
alignStartButton: window.EJS_alignStartButton,
VirtualGamepadSettings: window.EJS_VirtualGamepadSettings,
buttonOpts: window.EJS_Buttons,
volume: window.EJS_volume,
defaultControllers: window.EJS_defaultControls,
startOnLoad: window.EJS_startOnLoaded,
fullscreenOnLoad: window.EJS_fullscreenOnLoaded,
filePaths: window.EJS_paths,
loadState: window.EJS_loadStateURL,
cacheLimit: window.EJS_CacheLimit,
cacheConfig: window.EJS_cacheConfig,
cheats: window.EJS_cheats,
cheatPath: window.EJS_cheatPath,
defaultOptions: window.EJS_defaultOptions,
gamePatchUrl: window.EJS_gamePatchUrl,
gameParentUrl: window.EJS_gameParentUrl,
netplayUrl: window.EJS_netplayServer,
netplayICEServers: window.EJS_netplayICEServers,
gameId: window.EJS_gameID,
backgroundImg: window.EJS_backgroundImage,
backgroundBlur: window.EJS_backgroundBlur,
backgroundColor: window.EJS_backgroundColor,
controlScheme: window.EJS_controlScheme,
threads: window.EJS_threads,
disableCue: window.EJS_disableCue,
startBtnName: window.EJS_startButtonName,
softLoad: window.EJS_softLoad,
capture: window.EJS_screenCapture,
externalFiles: window.EJS_externalFiles,
dontExtractRom: window.EJS_dontExtractRom,
dontExtractBIOS: window.EJS_dontExtractBIOS,
disableLocalStorage: window.EJS_disableLocalStorage,
forceLegacyCores: window.EJS_forceLegacyCores,
noAutoFocus: window.EJS_noAutoFocus,
videoRotation: window.EJS_videoRotation,
hideSettings: window.EJS_hideSettings,
browserMode: window.EJS_browserMode,
additionalShaders: window.EJS_shaders,
fixedSaveInterval: window.EJS_fixedSaveInterval,
disableAutoUnload: window.EJS_disableAutoUnload,
disableBatchBootup: window.EJS_disableBatchBootup
};
async function prepareLanguage() {
try {
const systemLang = Intl.DateTimeFormat().resolvedOptions().locale;
config.language = window.EJS_language || systemLang;
if (config.language && window.EJS_disableAutoLang !== false) {
return await loadLanguage(config);
}
} catch (e) {
console.warn("Language detection failed:", e.message);
delete config.language;
delete config.langJson;
}
}
(async function() {
let EmulatorJS;
if (debug) {
EmulatorJS = await loadScript("emulator.js");
await loadStyle("emulator.css");
} else {
EmulatorJS = await loadScript("emulator.min.js");
await loadStyle("emulator.min.css");
}
if (!EmulatorJS) {
console.error("EmulatorJS failed to load. Check for missing files.");
return;
}
await prepareLanguage();
if (debug) {
console.log("Language:", config.language);
console.log("Language JSON loaded:", !!config.langJson);
}
window.EJS_emulator = new EmulatorJS(EJS_player, config);
window.EJS_adBlocked = (url, del) => window.EJS_emulator.adBlocked(url, del);
const handlers = [
["ready", window.EJS_ready],
["start", window.EJS_onGameStart],
["loadState", window.EJS_onLoadState],
["saveState", window.EJS_onSaveState],
["loadSave", window.EJS_onLoadSave],
["saveSave", window.EJS_onSaveSave]
];
handlers.forEach(([event, callback]) => {
if (typeof callback === "function") {
window.EJS_emulator.on(event, callback);
}
});
if (typeof window.EJS_onSaveUpdate === "function") {
window.EJS_emulator.on("saveUpdate", window.EJS_onSaveUpdate);
window.EJS_emulator.enableSaveUpdateEvent();
}
})();

View File

@@ -0,0 +1,27 @@
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404: Page not found</title>
<style>
body, html {
background-color: #000;
}
h2, p {
color: #fff;
}
a {
color: #0000EE;
text-decoration: underline;
cursor: pointer;
}
a:visited {
color: #800080;
}
</style>
</head>
<body>
<h2>404: Page not found</h2>
<p>Idk where you're trying to go, but its not here....</p>
<a herf="#" onclick="history.back()">Go Back?</a>
</body>
</html>

View File

@@ -0,0 +1,340 @@
{
"0": "0",
"1": "1",
"2": "2",
"3": "3",
"4": "4",
"5": "5",
"6": "6",
"7": "7",
"8": "8",
"9": "9",
"Restart": "Restart",
"Pause": "Pause",
"Play": "Play",
"Save State": "Save State",
"Load State": "Load State",
"Control Settings": "Control Settings",
"Cheats": "Cheats",
"Cache Manager": "Cache Manager",
"Export Save File": "Export Save File",
"Import Save File": "Import Save File",
"Netplay": "Netplay",
"Mute": "Mute",
"Unmute": "Unmute",
"Settings": "Settings",
"Enter Fullscreen": "Enter Fullscreen",
"Exit Fullscreen": "Exit Fullscreen",
"Context Menu": "Context Menu",
"Reset": "Reset",
"Clear": "Clear",
"Close": "Close",
"QUICK SAVE STATE": "QUICK SAVE STATE",
"QUICK LOAD STATE": "QUICK LOAD STATE",
"CHANGE STATE SLOT": "CHANGE STATE SLOT",
"FAST FORWARD": "FAST FORWARD",
"Player": "Player",
"Connected Gamepad": "Connected Gamepad",
"Gamepad": "Gamepad",
"Keyboard": "Keyboard",
"Set": "Set",
"Add Cheat": "Add Cheat",
"Note that some cheats require a restart to disable": "Note that some cheats require a restart to disable",
"Create a Room": "Create a Room",
"Rooms": "Rooms",
"Start Game": "Start Game",
"Click to resume Emulator": "Click to resume Emulator",
"Drop save state here to load": "Drop save state here to load",
"Loading...": "Loading...",
"Download Game Core": "Download Game Core",
"Outdated graphics driver": "Outdated graphics driver",
"Decompress Game Core": "Decompress Game Core",
"Download Game Data": "Download Game Data",
"Decompress Game Data": "Decompress Game Data",
"Shaders": "Shaders",
"Disabled": "Disabled",
"2xScaleHQ": "2xScaleHQ",
"4xScaleHQ": "4xScaleHQ",
"CRT easymode": "CRT easymode",
"CRT aperture": "CRT aperture",
"CRT geom": "CRT geom",
"CRT mattias": "CRT mattias",
"FPS": "FPS",
"show": "show",
"hide": "hide",
"Fast Forward Ratio": "Fast Forward Ratio",
"Fast Forward": "Fast Forward",
"Enabled": "Enabled",
"Save State Slot": "Save State Slot",
"Save State Location": "Save State Location",
"Download": "Download",
"Keep in Browser": "Keep in Browser",
"Auto": "Auto",
"NTSC": "NTSC",
"PAL": "PAL",
"Dendy": "Dendy",
"8:7 PAR": "8:7 PAR",
"4:3": "4:3",
"Low": "Low",
"High": "High",
"Very High": "Very High",
"None": "None",
"Player 1": "Player 1",
"Player 2": "Player 2",
"Both": "Both",
"SAVED STATE TO SLOT": "SAVED STATE TO SLOT",
"LOADED STATE FROM SLOT": "LOADED STATE FROM SLOT",
"SET SAVE STATE SLOT TO": "SET SAVE STATE SLOT TO",
"Network Error": "Network Error",
"Submit": "Submit",
"Description": "Description",
"Code": "Code",
"Add Cheat Code": "Add Cheat Code",
"Leave Room": "Leave Room",
"Password": "Password",
"Password (optional)": "Password (optional)",
"Max Players": "Max Players",
"Room Name": "Room Name",
"Join": "Join",
"Player Name": "Player Name",
"Set Player Name": "Set Player Name",
"Left Handed Mode": "Left Handed Mode",
"Virtual Gamepad": "Virtual Gamepad",
"Disk": "Disk",
"Press Keyboard": "Press Keyboard",
"INSERT COIN": "INSERT COIN",
"Remove": "Remove",
"LOADED STATE FROM BROWSER": "LOADED STATE FROM BROWSER",
"SAVED STATE TO BROWSER": "SAVED STATE TO BROWSER",
"Join the discord": "Join the discord",
"View on GitHub": "View on GitHub",
"Failed to start game": "Failed to start game",
"Download Game BIOS": "Download Game BIOS",
"Decompress Game BIOS": "Decompress Game BIOS",
"Download Game Parent": "Download Game Parent",
"Decompress Game Parent": "Decompress Game Parent",
"Download Game Patch": "Download Game Patch",
"Decompress Game Patch": "Decompress Game Patch",
"Download Game State": "Download Game State",
"Check console": "Check console",
"Error for site owner": "Error for site owner",
"EmulatorJS": "EmulatorJS",
"Clear All": "Clear All",
"Take Screenshot": "Take Screenshot",
"Start Screen Recording": "Start Screen Recording",
"Stop Screen Recording": "Stop Screen Recording",
"Quick Save": "Quick Save",
"Quick Load": "Quick Load",
"REWIND": "REWIND",
"Rewind Enabled (requires restart)": "Rewind Enabled (requires restart)",
"Rewind Granularity": "Rewind Granularity",
"Slow Motion Ratio": "Slow Motion Ratio",
"Slow Motion": "Slow Motion",
"Home": "Home",
"EmulatorJS License": "EmulatorJS License",
"RetroArch License": "RetroArch License",
"This project is powered by": "This project is powered by",
"View the RetroArch license here": "View the RetroArch license here",
"SLOW MOTION": "SLOW MOTION",
"A": "A",
"B": "B",
"SELECT": "SELECT",
"START": "START",
"UP": "UP",
"DOWN": "DOWN",
"LEFT": "LEFT",
"RIGHT": "RIGHT",
"X": "X",
"Y": "Y",
"L": "L",
"R": "R",
"Z": "Z",
"STICK UP": "STICK UP",
"STICK DOWN": "STICK DOWN",
"STICK LEFT": "STICK LEFT",
"STICK RIGHT": "STICK RIGHT",
"C-PAD UP": "C-PAD UP",
"C-PAD DOWN": "C-PAD DOWN",
"C-PAD LEFT": "C-PAD LEFT",
"C-PAD RIGHT": "C-PAD RIGHT",
"MICROPHONE": "MICROPHONE",
"BUTTON 1 / START": "BUTTON 1 / START",
"BUTTON 2": "BUTTON 2",
"BUTTON": "BUTTON",
"LEFT D-PAD UP": "LEFT D-PAD UP",
"LEFT D-PAD DOWN": "LEFT D-PAD DOWN",
"LEFT D-PAD LEFT": "LEFT D-PAD LEFT",
"LEFT D-PAD RIGHT": "LEFT D-PAD RIGHT",
"RIGHT D-PAD UP": "RIGHT D-PAD UP",
"RIGHT D-PAD DOWN": "RIGHT D-PAD DOWN",
"RIGHT D-PAD LEFT": "RIGHT D-PAD LEFT",
"RIGHT D-PAD RIGHT": "RIGHT D-PAD RIGHT",
"C": "C",
"MODE": "MODE",
"FIRE": "FIRE",
"RESET": "RESET",
"LEFT DIFFICULTY A": "LEFT DIFFICULTY A",
"LEFT DIFFICULTY B": "LEFT DIFFICULTY B",
"RIGHT DIFFICULTY A": "RIGHT DIFFICULTY A",
"RIGHT DIFFICULTY B": "RIGHT DIFFICULTY B",
"COLOR": "COLOR",
"B/W": "B/W",
"PAUSE": "PAUSE",
"OPTION": "OPTION",
"OPTION 1": "OPTION 1",
"OPTION 2": "OPTION 2",
"L2": "L2",
"R2": "R2",
"L3": "L3",
"R3": "R3",
"L STICK UP": "L STICK UP",
"L STICK DOWN": "L STICK DOWN",
"L STICK LEFT": "L STICK LEFT",
"L STICK RIGHT": "L STICK RIGHT",
"R STICK UP": "R STICK UP",
"R STICK DOWN": "R STICK DOWN",
"R STICK LEFT": "R STICK LEFT",
"R STICK RIGHT": "R STICK RIGHT",
"Start": "Start",
"Select": "Select",
"Fast": "Fast",
"Slow": "Slow",
"a": "a",
"b": "b",
"c": "c",
"d": "d",
"e": "e",
"f": "f",
"g": "g",
"h": "h",
"i": "i",
"j": "j",
"k": "k",
"l": "l",
"m": "m",
"n": "n",
"o": "o",
"p": "p",
"q": "q",
"r": "r",
"s": "s",
"t": "t",
"u": "u",
"v": "v",
"w": "w",
"x": "x",
"y": "y",
"z": "z",
"enter": "enter",
"escape": "escape",
"space": "space",
"tab": "tab",
"backspace": "backspace",
"delete": "delete",
"arrowup": "arrowup",
"arrowdown": "arrowdown",
"arrowleft": "arrowleft",
"arrowright": "arrowright",
"f1": "f1",
"f2": "f2",
"f3": "f3",
"f4": "f4",
"f5": "f5",
"f6": "f6",
"f7": "f7",
"f8": "f8",
"f9": "f9",
"f10": "f10",
"f11": "f11",
"f12": "f12",
"shift": "shift",
"control": "control",
"alt": "alt",
"meta": "meta",
"capslock": "capslock",
"insert": "insert",
"home": "home",
"end": "end",
"pageup": "pageup",
"pagedown": "pagedown",
"!": "!",
"@": "@",
"#": "#",
"$": "$",
"%": "%",
"^": "^",
"&": "&",
"*": "*",
"(": "(",
")": ")",
"-": "-",
"_": "_",
"+": "+",
"=": "=",
"[": "[",
"]": "]",
"{": "{",
"}": "}",
";": ";",
":": ":",
"'": "'",
"\"": "\"",
",": ",",
".": ".",
"<": "<",
">": ">",
"/": "/",
"?": "?",
"LEFT_STICK_X": "LEFT_STICK_X",
"LEFT_STICK_Y": "LEFT_STICK_Y",
"RIGHT_STICK_X": "RIGHT_STICK_X",
"RIGHT_STICK_Y": "RIGHT_STICK_Y",
"LEFT_TRIGGER": "LEFT_TRIGGER",
"RIGHT_TRIGGER": "RIGHT_TRIGGER",
"A_BUTTON": "A_BUTTON",
"B_BUTTON": "B_BUTTON",
"X_BUTTON": "X_BUTTON",
"Y_BUTTON": "Y_BUTTON",
"START_BUTTON": "START_BUTTON",
"SELECT_BUTTON": "SELECT_BUTTON",
"L1_BUTTON": "L1_BUTTON",
"R1_BUTTON": "R1_BUTTON",
"L2_BUTTON": "L2_BUTTON",
"R2_BUTTON": "R2_BUTTON",
"LEFT_THUMB_BUTTON": "LEFT_THUMB_BUTTON",
"RIGHT_THUMB_BUTTON": "RIGHT_THUMB_BUTTON",
"DPAD_UP": "DPAD_UP",
"DPAD_DOWN": "DPAD_DOWN",
"DPAD_LEFT": "DPAD_LEFT",
"DPAD_RIGHT": "DPAD_RIGHT",
"Disks": "Disks",
"Exit EmulatorJS": "Exit EmulatorJS",
"BUTTON_1": "BUTTON_1",
"BUTTON_2": "BUTTON_2",
"BUTTON_3": "BUTTON_3",
"BUTTON_4": "BUTTON_4",
"up arrow": "up arrow",
"down arrow": "down arrow",
"left arrow": "left arrow",
"right arrow": "right arrow",
"LEFT_TOP_SHOULDER": "LEFT_TOP_SHOULDER",
"RIGHT_TOP_SHOULDER": "RIGHT_TOP_SHOULDER",
"CRT beam": "CRT beam",
"CRT caligari": "CRT caligari",
"CRT lottes": "CRT lottes",
"CRT yeetron": "CRT yeetron",
"CRT zfast": "CRT zfast",
"SABR": "SABR",
"Bicubic": "Bicubic",
"Mix frames": "Mix frames",
"WebGL2": "WebGL2",
"Requires restart": "Requires restart",
"VSync": "VSync",
"Video Rotation": "Video Rotation",
"Rewind Enabled (Requires restart)": "Rewind Enabled (Requires restart)",
"System Save interval": "System Save interval",
"Menu Bar Button": "Menu Bar Button",
"visible": "visible",
"hidden": "hidden",
"Autofire": "Autofire"
}

69
public/emulator.html Normal file
View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EmulatorJS Player</title>
<!-- Explicitly invoke COOP/COEP isolation from the sandbox boundary to guarantee SharedArrayBuffer inheritance for MSX -->
<script src="/coi-serviceworker.min.js"></script>
<style>
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: black;
overflow: hidden;
}
#game {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="game"></div>
<script>
(function() {
let config = {};
try {
config = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
} catch (e) {
console.error('EmulatorJS: Failed to parse hash config', e);
}
window.EJS_player = '#game';
window.EJS_gameUrl = config.gameUrl;
window.EJS_core = config.core || config.system;
window.EJS_pathtodata = 'https://cdn.emulatorjs.org/stable/data/';
window.EJS_startOnLoaded = true;
if (config.biosUrl) {
window.EJS_biosUrl = config.biosUrl;
}
const handleSave = (data) => {
const blobContent = data.data || data;
window.parent.postMessage({ type: 'EJS_SAVE', data: blobContent }, '*');
};
const handleExit = () => {
window.parent.postMessage({ type: 'EJS_EXIT' }, '*');
};
window.EJS_conf = {
...config,
dataPath: 'https://cdn.emulatorjs.org/stable/data/',
onSave: handleSave,
onExit: handleExit
};
})();
</script>
<!-- Official Static Loader Tag (CDN) -->
<script src="https://cdn.emulatorjs.org/stable/data/loader.js"></script>
<script>
window.parent.postMessage({ type: 'EJS_READY' }, '*');
</script>
</body>
</html>

BIN
rom.json Normal file

Binary file not shown.

View File

@@ -403,7 +403,7 @@ export const rommApiClient = {
genres: getAll('genres').map(sanitize).filter(Boolean),
franchises: getAll('franchises').map(sanitize).filter(Boolean),
releaseDate: formatDate(json.release_date || getFirst('first_release_date') || getFirst('release_date')),
fsName: json.fs_name,
fsName: json.fs_name || json.file || json.fs_path,
regions: regions.length > 0 ? regions : ['Global'],
players: (() => {
let val = json.metadatum?.player_count || json.players || getFirst('player_count') || getFirst('total_players') || getFirst('players') || getFirst('max_players');
@@ -547,7 +547,7 @@ export const rommApiClient = {
},
getPlayUrl(gameId: string): string {
return `${this.apiBase}/roms/${gameId}/play`;
return `/rom/${gameId}/ejs`;
},
getManualUrl(gameId: string, platformId?: number): string {
@@ -595,5 +595,37 @@ export const rommApiClient = {
const res = await fetch(`${this.apiBase}/roms/${id}/notes`, { headers: this.headers });
if (!res.ok) throw new Error('Failed to fetch notes.');
return res.json();
},
getRomFileUrl(romId: string, fileName: string = 'rom.zip'): string {
const encodedName = fileName.split('/').map(s => encodeURIComponent(s)).join('/');
return `${this.apiBase}/roms/${romId}/content/token/${this.token}/${encodedName}`;
},
getSaveFileUrl(saveId: number): string {
return `${this.apiBase}/saves/${saveId}/content?access_token=${this.token}`;
},
getStateFileUrl(stateId: number): string {
return `${this.apiBase}/states/${stateId}/content?access_token=${this.token}`;
},
async uploadSave(romId: string, file: Blob, emulator: string): Promise<any> {
const formData = new FormData();
formData.append('file', file, 'save.srm');
formData.append('rom_id', romId);
formData.append('emulator', emulator);
const res = await fetch(`${this.apiBase}/saves`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`,
'Accept': 'application/json'
// Do not set Content-Type, browser will set it with boundary
},
body: formData
});
if (!res.ok) throw new Error('Failed to upload save.');
return res.json();
}
};

View File

@@ -1,76 +1,215 @@
import { useEffect } from 'react';
import { useFocusable, FocusContext, pause, resume } from '@noriginmedia/norigin-spatial-navigation';
import { useState, useEffect } from 'react';
import { useFocusable, FocusContext, setFocus } from '@noriginmedia/norigin-spatial-navigation';
import { rommApiClient } from '../api/client';
import { EmulatorPlayer } from './EmulatorPlayer';
import { useQuery } from '@tanstack/react-query';
interface EmulatorOverlayProps {
gameId: string;
onClose: () => void;
}
const FocusableItem = ({ children, onClick, onEnterPress, focusKey, onFocus, className }: any) => {
const { ref, focused } = useFocusable({
onEnterPress,
focusKey
});
useEffect(() => {
if (focused && onFocus) onFocus();
}, [focused]);
return (
<div ref={ref} onClick={onClick} className={className}>
{children(focused)}
</div>
);
};
export const EmulatorOverlay = ({ gameId, onClose }: EmulatorOverlayProps) => {
const { ref, focusKey } = useFocusable({
const { ref: overlayRef, focusKey: overlayFocusKey } = useFocusable({
isFocusBoundary: true,
focusKey: 'EMULATOR_OVERLAY'
});
const playUrl = rommApiClient.getPlayUrl(gameId);
const [gameState, setGameState] = useState<'config' | 'playing'>('config');
const [selectedSaveId, setSelectedSaveId] = useState<number | undefined>();
const [selectedStateId, setSelectedStateId] = useState<number | undefined>();
const { data: game, isLoading } = useQuery({
queryKey: ['gameDetails', gameId],
queryFn: () => rommApiClient.fetchGameDetails(gameId)
});
useEffect(() => {
// Pause spatial navigation for the browser during gameplay
// This prevents d-pad inputs from moving focus behind the iframe
pause();
const handleKeyDown = (e: KeyboardEvent) => {
// Allow ESC to exit the emulator
if (e.key === 'Escape') {
onClose();
if (gameState === 'config') {
setFocus('START_SESSION_BTN');
}
}, [gameState]);
if (isLoading || !game) {
return (
<div className="fixed inset-0 z-[200] bg-black/90 backdrop-blur-3xl flex items-center justify-center">
<div className="text-center">
<div className="material-symbols-outlined text-4xl text-[#2563eb] animate-spin mb-4">progress_activity</div>
<div className="text-[0.625rem] geist-mono text-white/40 uppercase tracking-[0.3em]">Preparing Atelier Session...</div>
</div>
</div>
);
}
const handleLaunch = () => {
setGameState('playing');
};
window.addEventListener('keydown', handleKeyDown);
// Auto-focus the iframe area for immediate controller interaction
const timer = setTimeout(() => {
const iframe = document.querySelector('iframe');
iframe?.focus();
}, 500);
return () => {
window.removeEventListener('keydown', handleKeyDown);
clearTimeout(timer);
resume();
};
}, [onClose]);
return (
<FocusContext.Provider value={focusKey}>
<div
ref={ref}
className="fixed inset-0 z-[200] bg-black animate-in fade-in duration-500 flex flex-col"
>
{/* HUD - Minimalist Atelier style overlay */}
<div className="absolute top-6 right-6 z-[210] flex items-center gap-4 group">
<div className="px-4 py-2 rounded-full bg-black/60 backdrop-blur-xl border border-white/10 flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<span className="text-[10px] font-black uppercase tracking-[0.4em] text-white/40 geist-mono">Session Active</span>
<div className="w-1.5 h-1.5 rounded-full bg-[#2563eb] shadow-[0_0_8px_#2563eb] animate-pulse" />
<FocusContext.Provider value={overlayFocusKey}>
<div ref={overlayRef} className="fixed inset-0 z-[200] bg-[#0a0c14] animate-in fade-in duration-500 overflow-hidden">
{gameState === 'config' ? (
<div className="h-full flex flex-col items-center justify-center p-20 relative">
{/* Background Atmosphere */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[60rem] h-[60rem] bg-[#2563eb]/5 rounded-full blur-[120px]" />
<img src={game.coverUrl} className="absolute inset-0 w-full h-full object-cover opacity-5 blur-3xl scale-110" alt="" />
</div>
<button
onClick={onClose}
className="w-12 h-12 flex items-center justify-center rounded-[14px] bg-black/60 backdrop-blur-xl border border-white/10 text-white/40 hover:text-white hover:bg-red-500/80 hover:border-red-500/50 transition-all hover:scale-110 active:scale-95"
title="Exit to Browser (ESC)"
>
<span className="material-symbols-outlined">close</span>
<div className="max-w-[80rem] w-full grid grid-cols-12 gap-16 relative z-10">
{/* Left Side: Game Art & Meta */}
<div className="col-span-4 flex flex-col items-center">
<div className="w-[20rem] aspect-[3/4] rounded-2xl overflow-hidden shadow-[0_0_80px_rgba(0,0,0,0.5)] border border-white/10 mb-8 transform -rotate-2 hover:rotate-0 transition-transform duration-700">
<img src={game.coverUrl} className="w-full h-full object-cover" alt={game.title} />
</div>
<h1 className="text-3xl font-black text-white text-center mb-2 geist-mono uppercase tracking-tighter leading-none">
{game.title}
</h1>
<p className="text-[#2563eb] text-[0.625rem] font-black uppercase tracking-[0.4em] geist-mono mb-6">
{game.system}
</p>
<div className="flex flex-wrap justify-center gap-2 max-w-xs">
{game.genres?.slice(0, 3).map(g => (
<span key={g} className="px-3 py-1 bg-white/5 border border-white/5 rounded-full text-[0.5rem] font-black uppercase tracking-widest text-white/30 geist-mono whitespace-nowrap">{g}</span>
))}
</div>
</div>
{/* Right Side: Configuration */}
<div className="col-span-8 flex flex-col justify-center">
<div className="mb-12">
<h2 className="text-[0.75rem] font-black text-white/30 uppercase tracking-[0.4em] mb-6 geist-mono border-b border-white/5 pb-2">Archival Data Selection</h2>
<div className="grid grid-cols-2 gap-8">
{/* Saves Column */}
<div>
<div className="text-[0.625rem] font-black text-[#2563eb] uppercase tracking-widest mb-4 geist-mono">Native Saves</div>
<div className="space-y-3 max-h-[12rem] overflow-y-auto pr-2 scrollbar-hoverable scrollbar-active">
{game.saves && game.saves.length > 0 ? game.saves.map(save => (
<FocusableItem key={save.id} onClick={() => setSelectedSaveId(save.id)} onEnterPress={() => setSelectedSaveId(save.id)}>
{(focused: boolean) => (
<div className={`p-3 rounded-xl border transition-all cursor-pointer ${selectedSaveId === save.id ? 'bg-[#2563eb]/20 border-[#2563eb] scale-105' : 'bg-white/5 border-white/5 hover:bg-white/10'} ${focused ? 'ring-2 ring-white/20' : ''}`}>
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-[#2563eb] text-lg">save</span>
<div className="flex-1 min-w-0">
<div className="text-[0.625rem] font-bold text-white uppercase truncate">{save.file_name}</div>
<div className="text-[0.5rem] text-white/40 geist-mono uppercase">{new Date(save.updated_at).toLocaleDateString()}</div>
</div>
{selectedSaveId === save.id && <div className="w-2 h-2 rounded-full bg-[#2563eb] shadow-[0_0_8px_#2563eb]" />}
</div>
</div>
)}
</FocusableItem>
)) : (
<div className="p-8 rounded-xl border border-dashed border-white/5 text-center">
<span className="material-symbols-outlined text-white/10 text-3xl mb-2">inbox</span>
<div className="text-[0.5rem] font-black text-white/20 uppercase tracking-[0.2em] geist-mono">No Local Saves</div>
</div>
)}
</div>
</div>
{/* States Column */}
<div>
<div className="text-[0.625rem] font-black text-[#2563eb] uppercase tracking-widest mb-4 geist-mono">Instant States</div>
<div className="space-y-3 max-h-[12rem] overflow-y-auto pr-2 scrollbar-hoverable scrollbar-active">
{game.states && game.states.length > 0 ? game.states.map(state => (
<FocusableItem key={state.id} onClick={() => setSelectedStateId(state.id)} onEnterPress={() => setSelectedStateId(state.id)}>
{(focused: boolean) => (
<div className={`p-3 rounded-xl border transition-all cursor-pointer ${selectedStateId === state.id ? 'bg-[#2563eb]/20 border-[#2563eb] scale-105' : 'bg-white/5 border-white/5 hover:bg-white/10'} ${focused ? 'ring-2 ring-white/20' : ''}`}>
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-[#2563eb] text-lg">history</span>
<div className="flex-1 min-w-0">
<div className="text-[0.625rem] font-bold text-white uppercase truncate">{state.file_name}</div>
<div className="text-[0.5rem] text-white/40 geist-mono uppercase">{new Date(state.updated_at).toLocaleDateString()}</div>
</div>
{selectedStateId === state.id && <div className="w-2 h-2 rounded-full bg-[#2563eb] shadow-[0_0_8px_#2563eb]" />}
</div>
</div>
)}
</FocusableItem>
)) : (
<div className="p-8 rounded-xl border border-dashed border-white/5 text-center">
<span className="material-symbols-outlined text-white/10 text-3xl mb-2">timer</span>
<div className="text-[0.5rem] font-black text-white/20 uppercase tracking-[0.2em] geist-mono">No Cached States</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* Launch Controls */}
<div className="flex items-center gap-6 mt-4">
<FocusableItem focusKey="START_SESSION_BTN" onEnterPress={handleLaunch} onClick={handleLaunch}>
{(focused: boolean) => (
<button className={`px-12 h-16 rounded-2xl font-black text-[0.875rem] uppercase tracking-[0.4em] geist-mono transition-all flex items-center gap-4 ${focused ? 'bg-[#2563eb] text-white scale-110 shadow-[0_0_40px_rgba(37,99,235,0.4)] ring-4 ring-white/20' : 'bg-white text-black'}`}>
<span className="material-symbols-outlined filled" style={{ fontVariationSettings: "'FILL' 1" }}>play_arrow</span>
Start Session
</button>
)}
</FocusableItem>
<FocusableItem focusKey="CANCEL_SESSION_BTN" onEnterPress={onClose} onClick={onClose}>
{(focused: boolean) => (
<button className={`px-8 h-16 rounded-2xl font-black text-[0.75rem] uppercase tracking-[0.2em] geist-mono transition-all border ${focused ? 'bg-white/10 border-white text-white' : 'bg-transparent border-white/10 text-white/30'}`}>
Abort
</button>
)}
</FocusableItem>
</div>
</div>
</div>
{/* Emulator Iframe */}
<iframe
src={playUrl}
className="w-full h-full border-none shadow-[0_0_128px_rgba(0,0,0,1)]"
allow="autoplay; gamepad; fullscreen; keyboard"
title="Game Player"
{/* Hint bar */}
<div className="absolute bottom-12 left-12 flex items-center gap-8">
<div className="flex items-center gap-3">
<div className="w-6 h-6 rounded flex items-center justify-center bg-white/10 text-[10px] font-bold text-white/50 border border-white/5">ENTER</div>
<div className="text-[0.625rem] font-black text-white/20 uppercase tracking-widest">Select Data</div>
</div>
<div className="flex items-center gap-3">
<div className="w-6 h-6 rounded flex items-center justify-center bg-white/10 text-[10px] font-bold text-white/50 border border-white/5">ESC</div>
<div className="text-[0.625rem] font-black text-white/20 uppercase tracking-widest">Back to Archives</div>
</div>
</div>
</div>
) : (
(() => {
const platformSlug = game.system.toLowerCase().replace(/\s+/g, '-');
const defaultExt = ['nds', 'nintendo-ds'].includes(platformSlug) ? 'nds'
: ['psx', 'playstation', 'saturn', '3do', 'sega-cd'].includes(platformSlug) ? 'chd'
: 'zip';
const ext = game.fsName ? game.fsName.split('.').pop() : defaultExt;
return (
<EmulatorPlayer
romId={gameId}
romUrl={rommApiClient.getRomFileUrl(gameId, `game.${ext}`)}
platformSlug={platformSlug}
saveId={selectedSaveId}
stateId={selectedStateId}
onClose={onClose}
/>
);
})()
)}
</div>
</FocusContext.Provider>
);

View File

@@ -0,0 +1,217 @@
import { useEffect } from 'react';
import { rommApiClient } from '../api/client';
import { pause, resume } from '@noriginmedia/norigin-spatial-navigation';
interface EmulatorPlayerProps {
romId: string; // Kept for Save uploading
romUrl: string; // Exact static asset URL
platformSlug: string;
core?: string;
saveId?: number;
stateId?: number;
onClose: () => void;
}
export const EmulatorPlayer = ({ romId, romUrl, platformSlug, core, saveId, stateId, onClose }: EmulatorPlayerProps) => {
useEffect(() => {
// 1. Pause spatial navigation
pause();
// 2. Listen for messages from the iframe
const handleMessage = async (event: MessageEvent) => {
if (event.data.type === 'EJS_SAVE') {
try {
const blob = new Blob([event.data.data], { type: 'application/octet-stream' });
await rommApiClient.uploadSave(romId, blob, core || 'default');
} catch (err) {
console.error('EmulatorJS: Failed to upload save to RomM:', err);
}
} else if (event.data.type === 'EJS_EXIT') {
onClose();
} else if (event.data.type === 'EJS_ERROR') {
console.error('EmulatorJS: Error from iframe:', event.data.error);
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
resume();
};
}, [romId, platformSlug, core, saveId, stateId, onClose]);
const coreMapping: Record<string, string> = {
'nes': 'fceumm',
'nintendo-entertainment-system': 'fceumm',
'snes': 'snes9x',
'super-nintendo-entertainment-system': 'snes9x',
'gb': 'gambatte',
'game-boy': 'gambatte',
'gbc': 'gambatte',
'game-boy-color': 'gambatte',
'gba': 'mgba',
'game-boy-advance': 'mgba',
'nds': 'desmume2015',
'nintendo-ds': 'desmume2015',
'nintendo-dsi': 'desmume2015',
// Sega Systems
'genesis': 'genesis_plus_gx',
'megadrive': 'genesis_plus_gx',
'sega-genesis': 'genesis_plus_gx',
'sega-mega-drive': 'genesis_plus_gx',
'mega-drive': 'genesis_plus_gx',
'smd': 'genesis_plus_gx',
'sms': 'genesis_plus_gx',
'sega-master-system': 'genesis_plus_gx',
'master-system': 'genesis_plus_gx',
'gg': 'genesis_plus_gx',
'game-gear': 'genesis_plus_gx',
'sega-game-gear': 'genesis_plus_gx',
'sega-cd': 'genesis_plus_gx',
'mega-cd': 'genesis_plus_gx',
'sega-32x': 'picodrive',
'32x': 'picodrive',
'sega-cd-32x': 'picodrive',
'mega-cd-32x': 'picodrive',
'saturn': 'yabause',
'sega-saturn': 'yabause',
'sg-1000': 'genesis_plus_gx',
'sg1000': 'genesis_plus_gx',
// Nintendo / NEC / Others
'n64': 'mupen64plus_next',
'nintendo-64': 'mupen64plus_next',
'virtual-boy': 'mednafen_vb',
'virtualboy': 'mednafen_vb',
'vb': 'mednafen_vb',
'turbografx-16': 'mednafen_pce_fast',
'turbografx': 'mednafen_pce_fast',
'tg16': 'mednafen_pce_fast',
'pc-engine': 'mednafen_pce_fast',
'pce': 'mednafen_pce_fast',
'pc-engine-cd': 'mednafen_pce_fast',
// Sony
'psx': 'pcsx_rearmed',
'playstation': 'pcsx_rearmed',
'sony-playstation': 'pcsx_rearmed',
'psp': 'ppsspp',
'playstation-portable': 'ppsspp',
'sony-playstation-portable': 'ppsspp',
// Panasonic / 3DO
'3do': 'opera',
'3do-interactive-multiplayer': 'opera',
// Atari
'atari-2600': 'stella2014',
'atari-5200': 'a5200',
'atari-7800': 'prosystem',
'jaguar': 'virtualjaguar',
'atari-jaguar': 'virtualjaguar',
'lynx': 'handy',
'atari-lynx': 'handy',
// Misc
'arcade': 'mame2003_plus',
'mame': 'mame2003_plus',
'neogeo': 'fbneo',
'neo-geo': 'fbneo',
'colecovision': 'gearcoleco',
'vectrex': 'vecx',
'msx': 'fmsx',
'msx1': 'fmsx',
'msx2': 'fmsx',
'neo-geo-pocket': 'mednafen_ngp',
'ngp': 'mednafen_ngp',
'neo-geo-pocket-color': 'mednafen_ngp',
'ngpc': 'mednafen_ngp',
'odyssey-2': 'o2em',
'odyssey2': 'o2em',
'videopac': 'o2em'
};
const activeCore = core || coreMapping[platformSlug] || 'default';
// Map slugs to exactly match the generated bios/XXXX.zip names
const biosFolderMapping: Record<string, string> = {
'3do': '3do',
'3do-interactive-multiplayer': '3do',
'saturn': 'saturn',
'sega-saturn': 'saturn',
'sega-cd': 'segacd',
'mega-cd': 'segacd',
'sega-cd-32x': 'segacd',
'mega-cd-32x': 'segacd',
'turbografx-cd': 'turbografx-cd',
'pc-engine-cd': 'turbografx-cd',
'psx': 'psx',
'playstation': 'psx',
'sony-playstation': 'psx',
'ps2': 'ps2',
'psp': 'psp',
'playstation-portable': 'psp',
'sony-playstation-portable': 'psp',
'gba': 'gba',
'game-boy-advance': 'gba',
'nds': 'nds',
'nintendo-ds': 'nds',
'nintendo-dsi': 'nintendo-dsi',
'msx': 'msx',
'msx1': 'msx',
'msx2': 'msx',
'amiga': 'amiga',
'jaguar': 'jaguar',
'atari-jaguar': 'jaguar',
'odyssey-2': 'odyssey-2',
'odyssey2': 'odyssey-2',
'videopac': 'odyssey-2',
'colecovision': 'colecovision',
'fds': 'fds',
'neogeoaes': 'neogeoaes',
'neogeo': 'arcade', // Fallback for neogeo.zip
'neo-geo': 'arcade',
'arcade': 'arcade',
'dc': 'dc'
};
const activeBios = biosFolderMapping[platformSlug];
const config = {
windowNum: 1,
language: 'en-US',
showControls: true,
showMenu: true,
allowFullscreen: true,
screenshot: true,
cache: false,
gameUrl: romUrl,
gameId: romId,
system: activeCore,
core: activeCore,
dataPath: '/emu/',
...(activeBios && { biosUrl: `/bios/${activeBios}.zip` }),
...(saveId && { loadSaveUrl: rommApiClient.getSaveFileUrl(saveId) }),
...(stateId && { loadStateUrl: rommApiClient.getStateFileUrl(stateId) }),
};
const iframeSrc = `/emulator.html?v=${Date.now()}#${encodeURIComponent(JSON.stringify(config))}`;
return (
<div className="w-full h-full bg-black relative">
<iframe
src={iframeSrc}
className="w-full h-full border-0"
allow="autoplay; fullscreen; cross-origin-isolated"
/>
{/* Fallback exit button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-[300] w-10 h-10 flex items-center justify-center rounded-full bg-black/50 text-white/50 hover:bg-red-500 hover:text-white transition-all opacity-0 hover:opacity-100"
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
);
};

View File

@@ -9,16 +9,48 @@ export default defineConfig({
watch: {
usePolling: true,
},
cors: true,
proxy: {
'/assets/romm': {
target: 'https://retro.chieflix.com',
changeOrigin: true,
secure: false,
},
'/api': {
target: 'https://retro.chieflix.com',
changeOrigin: true,
secure: false,
configure: (proxy: any) => {
proxy.on('proxyReq', (proxyReq: any, req: any) => {
// New Token Path Strategy: Extract token from URL path to prevent EmulatorJS extension parsing bugs
// Match format: /api/roms/123/content/token/XYZ/rom.chd
const tokenMatch = req.url?.match(/\/content\/token\/([^/]+)\/(.*)/);
if (tokenMatch) {
const token = tokenMatch[1];
const restOfPath = tokenMatch[2];
// Inject the token securely
proxyReq.setHeader('Authorization', `Bearer ${token}`);
// Rewrite the proxy request path to hide the token and the false filename from the RomM backend
// E.g., /api/roms/123/content/token/TOKEN/game.nds -> /api/roms/123/content
let newPath = req.url.replace(/\/content\/token\/[^/]+\/.*/, '/content');
// EJS loader often fires HEAD requests to check content-length. RomM FastAPI natively drops HEAD methods on binary routes returning 404s.
// Mutate HEAD queries safely into GET queries so the backend responds with accurate Header structures.
if (req.method === 'HEAD') {
proxyReq.method = 'GET';
}
proxyReq.path = newPath;
} else {
// Fallback for regular query params (Saves/States)
const url = new URL(req.url || '', `http://${req.headers.host}`);
const token = url.searchParams.get('access_token');
if (token) {
proxyReq.setHeader('Authorization', `Bearer ${token}`);
}
}
});
proxy.on('proxyRes', (proxyRes: any) => {
proxyRes.headers['Cross-Origin-Resource-Policy'] = 'cross-origin';
});
}
}
}
},