fix: bypass js-dos bundleUpdateConfig by providing config + initFs separately
Build & Deploy / build-and-deploy (push) Successful in 1m16s

js-dos v8.3.8 calls bundleUpdateConfig which uses libzip WASM to
re-add .jsdos/jsdos.json into the downloaded bundle. This fails on
our bundles (Unable to add .jsdos/jsdos.json into bundle.zip).

Fix: Instead of using { url } option (which triggers bundleUpdateConfig),
fetch the config from a new API endpoint (/api/games/{id}/config) and
pass it directly via { dosboxConf, jsdosConf, initFs }. This bypasses
the broken bundleUpdateConfig entirely.

Changes:
- New GET /api/games/{id}/config endpoint (GameResource + GameService)
- Exposed resolveCdImages/pickBestCdImage as package-private
- Frontend Play.svelte: fetch config + bundle separately, use new API
- Frontend api.js: new getGameConfig() function
This commit is contained in:
Hermes Agent
2026-06-03 16:59:09 +02:00
parent 3f466c5be7
commit 27b28430a5
5 changed files with 107 additions and 6 deletions
+10
View File
@@ -111,6 +111,16 @@ export function bundleUrl(game) {
return `/games/${game.bundle_file}`;
}
/** Get game config (dosbox.conf + jsdos.json) */
export async function getGameConfig(id) {
const res = await fetch(`${BASE}/api/games/${id}/config`);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
/** Get game bundle URL for setup mode (runs SETUP.EXE instead) */
export function setupBundleUrl(game) {
if (!game) return null;
+22 -4
View File
@@ -1,5 +1,5 @@
<script>
import { getGame, bundleUrl, setupBundleUrl } from "../lib/api.js";
import { getGame, getGameConfig, bundleUrl, setupBundleUrl } from "../lib/api.js";
import { push } from "../lib/router.js";
let { id } = $props();
@@ -68,9 +68,27 @@
// Start the DOS emulator
try {
const url = isSetup ? setupBundleUrl(game) : bundleUrl(game);
if (!url || !window.Dos) throw new Error("Failed to resolve game bundle");
dosCI = await window.Dos(dosContainer, { url });
if (isSetup) {
// Setup mode: use the streaming setup-bundle endpoint
const url = setupBundleUrl(game);
if (!url || !window.Dos) throw new Error("Failed to resolve game bundle");
dosCI = await window.Dos(dosContainer, { url });
} else {
// Normal mode: fetch config + bundle separately, use dosboxConf + initFs
// This bypasses js-dos's bundleUpdateConfig which fails on our bundles
const [config, bundleResp] = await Promise.all([
getGameConfig(game.id),
fetch(bundleUrl(game)),
]);
if (!bundleResp.ok) throw new Error("Failed to download game bundle");
const bundle = new Uint8Array(await bundleResp.arrayBuffer());
const jsdosConf = config.jsdos_conf ? JSON.parse(config.jsdos_conf) : undefined;
dosCI = await window.Dos(dosContainer, {
dosboxConf: config.dosbox_conf,
jsdosConf,
initFs: bundle,
});
}
running = true;
} catch (e) {
error = e.message;
@@ -153,6 +153,20 @@ public class GameResource {
}
}
@GET
@Path("/{id}/config")
@Produces(MediaType.APPLICATION_JSON)
public Response config(@PathParam("id") String id) {
try {
var config = svc.getBundleConfig(id);
return Response.ok(config).build();
} catch (NoSuchFileException e) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
} catch (Exception e) {
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") String id) {
@@ -371,5 +371,64 @@ public class GameService {
}
}
/** Get bundle config (dosbox.conf + jsdos.json) for a game.
* Regenerates from stored metadata rather than reading from the bundle. */
public Map<String, Object> getBundleConfig(String id) throws IOException {
Game game = load(id);
String exe = game.getExecutable();
Path bundlePath = gamesDir.resolve(game.getBundleFile());
List<String> cdImages = exe != null && !exe.isBlank()
? findCdImagesInBundle(bundlePath)
: List.of();
byte[] dosboxConf;
byte[] jsdosConf;
if (exe != null && !exe.isBlank()) {
dosboxConf = buildDosboxConfBytes(exe, cdImages);
jsdosConf = buildJsdosJsonBytes(exe);
} else {
// CD-only game — generate CD-only config
dosboxConf = buildCdOnlyDosboxConfBytes(cdImages);
jsdosConf = buildCdOnlyJsdosJsonBytes();
}
Map<String, Object> config = new java.util.LinkedHashMap<>();
config.put("dosbox_conf", new String(dosboxConf, java.nio.charset.StandardCharsets.UTF_8));
config.put("jsdos_conf", new String(jsdosConf, java.nio.charset.StandardCharsets.UTF_8));
return config;
}
/** Build dosbox.conf for a CD-only game (no executable on filesystem). */
private byte[] buildCdOnlyDosboxConfBytes(List<String> cdImages) {
StringBuilder sb = new StringBuilder();
sb.append("[sdl]\nautolock=true\nusescancodes=true\noutput=surface\n\n");
sb.append("[cpu]\ncore=auto\ncycles=auto\n\n");
sb.append("[mixer]\nnosound=false\nrate=44100\n\n");
sb.append("[sblaster]\nsbtype=sb16\nirq=7\ndma=1\nhdma=5\n\n");
sb.append("[dos]\nxms=true\nems=true\numb=true\n\n");
sb.append("[autoexec]\n@echo off\nmount c .\n");
// Mount CD images as D:, E:, …
char driveLetter = 'D';
for (String img : UploadResource.resolveCdImages(cdImages)) {
String imgLower = img.toLowerCase();
String flags = (imgLower.endsWith(".bin") && !imgLower.endsWith(".cue"))
? " -t cdrom -fs iso" : " -t cdrom";
sb.append("imgmount ").append(driveLetter).append(" \"").append(img).append("\"").append(flags).append("\n");
driveLetter++;
}
sb.append("c:\n");
sb.append("echo.\n");
sb.append("echo This game runs from the CD-ROM.\n");
sb.append("echo Type D: and press Enter, then look for the game executable (e.g. DOTT.EXE).\n");
return sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
/** Build jsdos.json for a CD-only game. */
private byte[] buildCdOnlyJsdosJsonBytes() {
String json = "{\"autoexec\":{\"options\":{\"script\":{\"value\":\"c:\\\\\"}}},"
+ "\"output\":{\"options\":{\"autolock\":{\"value\":true}}}}";
return json.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
public Path getGamesDir() { return gamesDir; }
}
@@ -634,7 +634,7 @@ public class UploadResource {
/** Resolve one CD image per parent directory with best-format priority.
* Priority: .cue &gt; .iso &gt; .ccd &gt; .img &gt; .bin
* js-dos DOSBox (Emscripten) supports .cue natively, but .ccd often fails. */
private static List<String> resolveCdImages(List<String> cdImages) {
static List<String> resolveCdImages(List<String> cdImages) {
// Group by parent directory
java.util.Map<String, java.util.List<String>> byDir = new java.util.LinkedHashMap<>();
for (String img : cdImages) {
@@ -651,7 +651,7 @@ public class UploadResource {
return result;
}
private static String pickBestCdImage(java.util.List<String> images) {
static String pickBestCdImage(java.util.List<String> images) {
// Priority: .cue [5] > .iso [4] > .ccd [3] > .img [2] > .bin [1]
String best = null;
int bestScore = -1;