feat: include mount/imgmount in jsdos.json autoexec script + remove fix-paths endpoint
Build & Deploy / build-and-deploy (push) Successful in 36s

js-dos v8 uses the jsdos.json autoexec.script instead of the
dosbox.conf [autoexec] section. Without mount/imgmount in the jsdos.json
script, the CD image was never mounted — games loaded base data from C:
(bundle root) but crashed when trying to access CD-ROM content (videos,
audio).

Also removes the now-unnecessary fix-paths endpoint (both endpoint and
GameService implementation) since config path fixing is automatic during
upload via the game-agnostic scanner.
This commit is contained in:
David Alvarez
2026-06-08 12:49:33 +02:00
parent 9a5ed5bc03
commit 554347ac0e
3 changed files with 49 additions and 234 deletions
@@ -164,25 +164,6 @@ public class GameResource {
}
}
@POST
@Path("/{id}/fix-paths")
@Produces(MediaType.APPLICATION_JSON)
public Response fixPaths(@PathParam("id") String id) {
try {
boolean patched = svc.fixBundleConfigPaths(id);
if (patched) {
return Response.ok(Map.of("status", "patched",
"message", "Fixed hardcoded DOS paths in game config")).build();
}
return Response.ok(Map.of("status", "noop",
"message", "No known config files needed patching")).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();
}
}
private Integer asInt(Object v) {
if (v instanceof Number n) return n.intValue();
return null;
+17 -208
View File
@@ -169,7 +169,7 @@ public class GameService {
// Build new config entries — preserve any CD images in the bundle
List<String> cdImages = findCdImagesInBundle(bundlePath);
byte[] newDosboxConf = buildDosboxConfBytes(executable, cdImages);
byte[] newJsdosJson = buildJsdosJsonBytes(executable);
byte[] newJsdosJson = buildJsdosJsonBytes(executable, cdImages);
// Patch the ZIP: read old, write new with modified config entries
Path tmpPath = bundlePath.resolveSibling(bundlePath.getFileName() + ".tmp");
@@ -204,210 +204,6 @@ public class GameService {
save(game);
}
/**
* Patch hardcoded absolute DOS paths (C:\dir\ → .\) in game config files
* within a .jsdos bundle. Game-agnostic — scans all small text entries
* for drive-letter paths and rewrites stale directory prefixes.
*/
public boolean fixBundleConfigPaths(String id) throws IOException {
Game game = load(id);
String bundleFile = game.getBundleFile();
if (bundleFile == null) return false;
Path bundlePath = gamesDir.resolve(bundleFile);
if (!Files.exists(bundlePath)) return false;
// Phase 1: build a set of all entry paths for existence checking
java.util.Set<String> allEntries = new java.util.HashSet<>();
java.util.Set<String> dirs = new java.util.HashSet<>();
java.util.List<EntryData> textEntries = new java.util.ArrayList<>();
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) {
java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName();
allEntries.add(name);
// Add all parent directories too
int idx = name.lastIndexOf('/');
while (idx >= 0) {
dirs.add(name.substring(0, idx + 1));
idx = name.lastIndexOf('/', idx - 1);
}
if (!entry.isDirectory() && isSmallTextEntry(entry, zis)) {
byte[] data = zis.readAllBytes();
String text = new String(data, java.nio.charset.StandardCharsets.ISO_8859_1);
if (hasDrivePath(text)) {
textEntries.add(new EntryData(name, data));
}
}
zis.closeEntry();
}
}
if (textEntries.isEmpty()) return false;
// Build the existence checker using ZIP entry paths
var existsChecker = new ZipExistenceChecker(allEntries, dirs);
// Phase 2: patch matching entries and write new ZIP
Path tmpPath = bundlePath.resolveSibling(bundlePath.getFileName() + ".tmp");
boolean patched = false;
java.util.regex.Pattern drvPattern = java.util.regex.Pattern.compile(
"[A-Za-z]:(\\\\|/)[^\\s\"\\r\\n]+");
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath));
var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(tmpPath))) {
java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName();
zos.putNextEntry(new java.util.zip.ZipEntry(name));
// Check if this entry needs patching
var ed = findEntryData(textEntries, name);
if (ed != null) {
String text = new String(ed.data, java.nio.charset.StandardCharsets.ISO_8859_1);
String patchedText = patchZipPaths(text, existsChecker, drvPattern);
byte[] result = patchedText.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
if (!java.util.Arrays.equals(result, ed.data)) {
patched = true;
}
zos.write(result);
} else {
zis.transferTo(zos);
}
zos.closeEntry();
zis.closeEntry();
}
}
if (patched) {
Files.move(tmpPath, bundlePath, StandardCopyOption.REPLACE_EXISTING);
} else {
Files.deleteIfExists(tmpPath);
}
return patched;
}
private static boolean isSmallTextEntry(java.util.zip.ZipEntry entry, java.util.zip.ZipInputStream zis) {
if (entry.isDirectory()) return false;
long size = entry.getSize();
if (size > 100 * 1024 || size == 0) return true; // unknown size is OK
return true;
}
private static boolean hasDrivePath(String text) {
return java.util.regex.Pattern.compile("[A-Za-z]:\\\\").matcher(text).find();
}
private static EntryData findEntryData(java.util.List<EntryData> list, String name) {
for (var ed : list) {
if (ed.name.equals(name)) return ed;
}
return null;
}
private static class EntryData {
final String name;
final byte[] data;
EntryData(String name, byte[] data) { this.name = name; this.data = data; }
}
/** Case-insensitive existence check for paths inside a ZIP bundle. */
private static class ZipExistenceChecker {
final java.util.Set<String> entries; // all entries in original case
final java.util.Set<String> dirs; // all directory prefixes
ZipExistenceChecker(java.util.Set<String> entries, java.util.Set<String> dirs) {
this.entries = entries;
this.dirs = dirs;
}
boolean exists(String path) {
// Check both forward and backslash variants
String fwd = path.replace('\\', '/');
String bwd = path.replace('/', '\\');
// Check exact match
if (entries.contains(fwd) || entries.contains(bwd)) return true;
// Check case-insensitively
for (String e : entries) {
if (e.equalsIgnoreCase(fwd) || e.equalsIgnoreCase(bwd)) return true;
}
// Check as directory (trailing slash variants)
String fwdDir = fwd.endsWith("/") ? fwd : fwd + "/";
String bwdDir = bwd.endsWith("\\") ? bwd : bwd + "\\";
if (dirs.contains(fwdDir) || dirs.contains(bwdDir)) return true;
for (String d : dirs) {
if (d.equalsIgnoreCase(fwdDir) || d.equalsIgnoreCase(bwdDir)) return true;
}
return false;
}
}
/** Patch absolute DOS paths in ZIP entry text using the ZIP's own entry index. */
private static String patchZipPaths(String text, ZipExistenceChecker checker,
java.util.regex.Pattern drvPattern) {
var m = drvPattern.matcher(text);
var sb = new StringBuilder();
int lastEnd = 0;
java.util.Set<String> seenPrefixes = new java.util.HashSet<>();
while (m.find()) {
sb.append(text, lastEnd, m.start());
String fullPath = m.group();
String drive = fullPath.substring(0, 2);
if (!"C:".equalsIgnoreCase(drive)) {
sb.append(fullPath);
lastEnd = m.end();
continue;
}
String sep = fullPath.substring(2, 3);
String relPath = fullPath.substring(3);
String separator = "\\".equals(sep) ? "\\\\" : "/";
String[] parts = relPath.split(separator, -1);
boolean found = false;
for (int skip = 0; skip < parts.length; skip++) {
StringBuilder suffix = new StringBuilder();
for (int j = skip; j < parts.length; j++) {
if (j > skip) suffix.append(sep);
suffix.append(parts[j]);
}
String suffixStr = suffix.toString();
if (suffixStr.isEmpty()) continue;
if (checker.exists(suffixStr)) {
if (skip == 0) {
sb.append(fullPath); // path valid
} else {
StringBuilder stale = new StringBuilder(drivePrefix(parts, skip, sep));
String staleStr = stale.toString();
if (!seenPrefixes.contains(staleStr)) {
seenPrefixes.add(staleStr);
}
sb.append(".").append(sep).append(suffixStr);
}
found = true;
break;
}
}
if (!found) sb.append(fullPath);
lastEnd = m.end();
}
sb.append(text.substring(lastEnd));
return sb.toString();
}
private static String drivePrefix(String[] parts, int skip, String sep) {
StringBuilder sb = new StringBuilder("C:").append(sep);
for (int k = 0; k < skip; k++) {
if (k > 0) sb.append(sep);
sb.append(parts[k]);
}
sb.append(sep);
return sb.toString();
}
/** Scan a .jsdos bundle for CD image files (preserves the case from the ZIP entry). */
private List<String> findCdImagesInBundle(Path bundlePath) throws IOException {
Set<String> cdExt = Set.of(".iso", ".cue", ".img", ".ccd", ".bin");
@@ -474,10 +270,23 @@ public class GameService {
return sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
/** Build jsdos.json for a given executable path. */
private byte[] buildJsdosJsonBytes(String relExe) {
/** Build jsdos.json for a given executable path and CD images. */
private byte[] buildJsdosJsonBytes(String relExe, List<String> cdImages) {
var parts = splitExePath(relExe);
StringBuilder script = new StringBuilder();
// js-dos v8 uses the jsdos.json autoexec.script instead of the
// dosbox.conf [autoexec] section, so mount/imgmount MUST be here.
script.append("mount c .\n");
char driveLetter = 'D';
for (String img : cdImages) {
String imgLower = img.toLowerCase();
String flags = (imgLower.endsWith(".bin") && !imgLower.endsWith(".cue"))
? " -t cdrom -fs iso" : " -t cdrom";
script.append("imgmount ").append(driveLetter).append(" \"")
.append(img).append("\"").append(flags).append("\n");
driveLetter++;
}
script.append("c:\n");
if (parts[0] != null) {
script.append("path=c:\\\n");
script.append("cd ").append(parts[0]).append("\n");
@@ -553,7 +362,7 @@ public class GameService {
// Build new config entries — preserve any CD images in the bundle
List<String> cdImages = findCdImagesInBundle(bundlePath);
byte[] newDosboxConf = buildDosboxConfBytes(setupExe, cdImages);
byte[] newJsdosJson = buildJsdosJsonBytes(setupExe);
byte[] newJsdosJson = buildJsdosJsonBytes(setupExe, cdImages);
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath));
var zos = new java.util.zip.ZipOutputStream(out)) {
@@ -712,11 +712,11 @@ public class UploadResource {
if (exePath != null) {
String relExe = extractDir.relativize(Path.of(exePath)).toString();
Files.writeString(jsdos.resolve("dosbox.conf"), buildDosboxConf(relExe, cdImages));
Files.write(jsdos.resolve("jsdos.json"), buildJsdosJson(relExe));
Files.write(jsdos.resolve("jsdos.json"), buildJsdosJson(relExe, cdImages));
} else {
// CD-only game — no executable, mount CD and show a DOS prompt
Files.writeString(jsdos.resolve("dosbox.conf"), buildCdOnlyDosboxConf(cdImages));
Files.write(jsdos.resolve("jsdos.json"), buildCdOnlyJsdosJson());
Files.write(jsdos.resolve("jsdos.json"), buildCdOnlyJsdosJson(cdImages));
}
// ZIP it up directly from extractDir (no temp copy)
@@ -804,13 +804,25 @@ public class UploadResource {
return new String[]{null, relExe};
}
private byte[] buildJsdosJson(String relExe) {
private byte[] buildJsdosJson(String relExe, List<String> cdImages) {
// DOS uses \\ as path separator; / is a switch character.
// If the executable is in a subdirectory, cd into it first.
// js-dos v8 uses the jsdos.json autoexec.script instead of the
// dosbox.conf [autoexec] section, so mount/imgmount MUST be here.
var parts = splitDirExe(relExe);
StringBuilder script = new StringBuilder();
script.append("mount c .\n");
// Mount CD images as D:, E:, … so games can find their CD.
char driveLetter = 'D';
for (String img : resolveCdImages(cdImages)) {
String imgLower = img.toLowerCase();
String flags = (imgLower.endsWith(".bin") && !imgLower.endsWith(".cue"))
? " -t cdrom -fs iso" : " -t cdrom";
script.append("imgmount ").append(driveLetter).append(" \"")
.append(img).append("\"").append(flags).append("\n");
driveLetter++;
}
script.append("c:\n");
if (parts[0] != null) {
// Set PATH to include root so DOS extenders (DOS4GW etc.) are findable
script.append("path=c:\\\n");
script.append("cd ").append(parts[0]).append("\n");
}
@@ -892,10 +904,23 @@ public class UploadResource {
return sb.toString();
}
private byte[] buildCdOnlyJsdosJson() {
private byte[] buildCdOnlyJsdosJson(List<String> cdImages) {
StringBuilder script = new StringBuilder();
script.append("mount c .\n");
char driveLetter = 'D';
for (String img : resolveCdImages(cdImages)) {
String imgLower = img.toLowerCase();
String flags = (imgLower.endsWith(".bin") && !imgLower.endsWith(".cue"))
? " -t cdrom -fs iso" : " -t cdrom";
script.append("imgmount ").append(driveLetter).append(" \"")
.append(img).append("\"").append(flags).append("\n");
driveLetter++;
}
script.append("c:\n");
String json = "{"
+ "\"autoexec\":{"
+ "\"options\":{\"script\":{\"value\":\"c:\\\\\"}}"
+ "\"options\":{\"script\":{\"value\":\""
+ escapeJson(script.toString()) + "\"}}"
+ "},"
+ "\"output\":{"
+ "\"options\":{\"autolock\":{\"value\":true}}}"