The setup
- Unraid with RomM running in Docker, library mounted at
roms/dos/ - DOS games as plain
.zipfiles, one per game, with the game's files at the archive root (preservation-style naming such asDark Seed v1.5 (1992)(Cyberdreams, Inc.) [Adventure].zip) - Nginx Proxy Manager (NPM) in front of everything, with Let's Encrypt certificates
Problem 1: "Error for site owner, check console"
Pressing Play on any DOS game showed the EmulatorJS player with the message Error for site owner – Check console. The console was full of noise ("Translation not found for…", 404s on platform icons), but the line that mattered was at the bottom:
Threads is set to true, but the SharedArrayBuffer function is not exposed. Threads requires 2 headers to be set when sending your html page.
dosbox-pure runs multi-threaded and needs SharedArrayBuffer. Browsers only expose that in a secure context (HTTPS or localhost) and when the page is served with these two headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
RomM already sends the headers. My problem was that I was browsing to http://tower:6802, a plain-HTTP LAN hostname, which is not a secure context no matter what headers you set.
Quick confirmation
Before touching the proxy, I confirmed the diagnosis with a browser flag. In Chrome or Edge, chrome://flags/#unsafely-treat-insecure-origin-as-secure lets you whitelist http://tower:6802; in Firefox the equivalent is dom.securecontext.allowlist in about:config. The emulator loaded immediately. That's a hack for one browser, though, not a fix.
The real fix: HTTPS via Nginx Proxy Manager
- Move the Unraid web UI off ports 80/443 (Settings → Management Access) so NPM can bind them.
- In NPM, add a proxy host:
- Domain:
romm.yourdomain.tld - Forward to:
http://<unraid-ip>:6802 - Websockets Support: on (more on this below)
- SSL: Let's Encrypt certificate, Force SSL, HTTP/2
- Advanced tab:
client_max_body_size 0;so large uploads aren't rejected
- Domain:
- Point the hostname at the NPM box with a local DNS record (router, Pi-hole, AdGuard) if you want it LAN-only.
Two side notes from this step:
- Lost NPM password? Users live in the
userandauthtables ofdatabase.sqlitein NPM's/datafolder. Settingis_deleted = 1on the user row makes NPM recreate the defaultadmin@example.com/changemeon next start, without losing your proxy hosts. Back the file up first. - Enable Websockets Support. Without it RomM's scan progress never reaches the UI, the console fills with
wss://…/ws/socket.ioconnection errors, and the scan looks stuck when it's actually running fine on the server. Jellyfin and the Unraid UI want it too; it's harmless everywhere else.
With HTTPS working, the player booted into the dosbox-pure start menu. Progress. Except…
Problem 2: "No executable file found"
The start menu listed one entry, named after some random file from the archive, and said No executable file found. Choosing Go to Command Line and typing dir on C: returned nothing. D: and E: didn't exist.
I checked the zip on my PC: 593 files, DARKSEED.EXE right at the root, nothing wrong with it. RomM's own docs say to upload DOS games as .zip because dosbox-pure can mount zips directly. So why was the emulator empty?
What's actually happening
EmulatorJS, the in-browser emulator RomM uses, extracts every zip it downloads before starting the core. It then picks one file from the extracted contents as the "game" to load. For most consoles that's fine. For DOS it's a disaster: dosbox-pure was being started with a single random file from the archive as its content, and no idea the other 592 existed.
The giveaway was the start-menu title. It was the first file in the zip, alphabetically.
EmulatorJS already knows some cores need the archive intact. In emulator.min.js there's a branch that skips extraction for arcade/MAME:
if(["arcade","mame"].includes(this.getCore(!0)))
return this.fileName=this.getBaseFileName(!0),
this.gameManager.FS.writeFile(this.fileName,new Uint8Array(i)),
void e();
dosbox-pure should be in that list. It isn't.
Workaround without touching anything
The extracted files are in the emulator's filesystem, just not mounted. From Go to Command Line:
mount -u c
mount c ".."
c:
dir
DARKSEED.EXE
mount d / lets you browse the whole virtual filesystem if .. isn't the right spot. Boot the game once this way, take a save state, and you never type it again for that title. Fine for a handful of games, not for 20,000.
Options I rejected
- Rename every
.zipto.dosz..doszisdosbox-pure's own extension for a zip, and EmulatorJS doesn't try to extract it. But renaming means RomM sees 20,000 new files, a rescan that takes hours (every new file goes through metadata identification, which is rate-limited), and probably orphaned saves and favourites. - Add a
.confwith an[autoexec]block to each archive. This is what RomM's docs recommend. It works, but it's one hand-built file per game. - Patch the file inside the container. Works, but gets overwritten on every image update unless you bind-mount the patched copy over the original.
The fix: rewrite the JS at the proxy
Nginx can modify a response body on the way through with sub_filter. Since NPM already sits in front of RomM, I added this to the proxy host's Advanced tab (the gear icon → Custom Nginx Configuration):
location = /assets/emulatorjs/data/emulator.min.js {
proxy_pass http://<unraid-ip>:6802;
proxy_set_header Host $host;
proxy_set_header Accept-Encoding "";
sub_filter_types *;
sub_filter_once on;
sub_filter '["arcade","mame"].includes(this.getCore(!0))' '["arcade","mame","dos"].includes(this.getCore(!0))||"dosbox_pure"===this.getCore()';
}
What each line does:
proxy_set_header Accept-Encoding "";makes RomM send the file uncompressed so Nginx can edit it.sub_filter_types *;is important. The default only matchestext/html, andapplication/javascriptdidn't match the content type RomM actually sends. With*it just works.sub_filter_once on;because the string appears once, and a second similar string (["arcade","mame"].includes(this.getControlScheme())) handles controller labels and must not be touched. Including.includes(this.getCore(!0))in the match makes it unique.- The replacement adds
dosto the no-extract list, with a fallback check on the core name.
Save, hard-refresh (Ctrl+F5) so the browser drops the cached JS, and verify by opening /assets/emulatorjs/data/emulator.min.js and searching for "arcade","mame","dos".
Result: Play on the plain .zip lands straight in the dosbox-pure start menu with DARKSEED.EXE listed. Every other DOS zip in the library behaves the same. No renames, no rescan, no per-game config files.
Caveats
- Check after updates. A new RomM image may ship a newer EmulatorJS. If that line changes, the
sub_filtersilently stops matching and you're back to the empty C: drive. Pressing Play on one DOS game after each update is enough to know. - Retail CD games still need work. Titles that need a CD image mounted alongside the installed files (
.bin/.cue) usually want a.confwithimgmountin[autoexec]. RomM's MS-DOS docs have a worked example. - GOG DOS releases don't work in
dosbox-pure, according to RomM's docs. Their custom wrappers don't translate. - The
.svg404s are cosmetic. RomM asks for<platform>.svg, falls back to<platform>.ico, and only ships the.icofor some platforms. Ignore them.