diff --git a/src/main/java/com/dostalgia/CdImageService.java b/src/main/java/com/dostalgia/CdImageService.java new file mode 100644 index 0000000..18ee51c --- /dev/null +++ b/src/main/java/com/dostalgia/CdImageService.java @@ -0,0 +1,106 @@ +package com.dostalgia; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; + +/** + * CD image utilities: CUE sheet repair and CD image discovery in directories and ZIPs. + */ +@ApplicationScoped +public class CdImageService { + + private static final Logger LOG = Logger.getLogger(CdImageService.class.getName()); + private static final java.util.Set CD_EXT = java.util.Set.of( + ".iso", ".cue", ".img", ".ccd", ".bin" + ); + + /** + * Fix .cue files that reference non-existent data files. + * CloneCD rips often generate a .cue referencing a .bin, but the actual file is .img. + */ + public void fixCueReferences(Path extractDir) throws IOException { + try (var walk = Files.walk(extractDir)) { + walk.filter(Files::isRegularFile) + .filter(f -> f.getFileName().toString().toLowerCase().endsWith(".cue")) + .forEach(f -> { + try { fixOneCue(f); } + catch (IOException e) { + LOG.warning("Failed to fix cue file " + f + ": " + e.getMessage()); + } + }); + } + } + + private void fixOneCue(Path cueFile) throws IOException { + String content = Files.readString(cueFile); + Path dir = cueFile.getParent(); + + var m = java.util.regex.Pattern + .compile("FILE\\s+\"([^\"]+)\"", java.util.regex.Pattern.CASE_INSENSITIVE) + .matcher(content); + if (!m.find()) return; + + String referenced = m.group(1); + Path refPath = dir.resolve(referenced); + if (Files.exists(refPath)) return; + + Path replacement = findDataFile(dir); + if (replacement == null) return; + + String actualName = replacement.getFileName().toString(); + content = content.substring(0, m.start(1)) + actualName + content.substring(m.end(1)); + Files.writeString(cueFile, content); + LOG.info("Fixed cue " + cueFile.getFileName() + ": '" + referenced + "' → '" + actualName + "'"); + } + + private Path findDataFile(Path dir) throws IOException { + try (var files = Files.list(dir)) { + return files.filter(Files::isRegularFile) + .filter(f -> { + String n = f.getFileName().toString().toLowerCase(); + return n.endsWith(".img") || n.endsWith(".bin"); + }) + .findFirst() + .orElse(null); + } + } + + /** Find mountable CD image files in a directory. Returns sorted relative paths. */ + public java.util.List findInDirectory(Path dir) throws IOException { + java.util.List cds = new java.util.ArrayList<>(); + try (var walk = Files.walk(dir)) { + walk.filter(Files::isRegularFile).forEach(f -> { + String name = f.getFileName().toString().toLowerCase(); + int dot = name.lastIndexOf('.'); + if (dot >= 0 && CD_EXT.contains(name.substring(dot))) { + cds.add(dir.relativize(f).toString().replace('\\', '/')); + } + }); + } + cds.sort(String::compareTo); + return cds; + } + + /** Find CD image files inside a .jsdos ZIP bundle. */ + public java.util.List findInBundle(Path bundlePath) throws IOException { + java.util.List cds = 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) { + if (entry.isDirectory()) continue; + String name = entry.getName().toLowerCase(); + int dot = name.lastIndexOf('.'); + if (dot >= 0 && CD_EXT.contains(name.substring(dot))) { + cds.add(entry.getName()); + } + zis.closeEntry(); + } + } + cds.sort(String::compareTo); + return cds; + } +} diff --git a/src/main/java/com/dostalgia/ConfigBuilder.java b/src/main/java/com/dostalgia/ConfigBuilder.java new file mode 100644 index 0000000..201c4c7 --- /dev/null +++ b/src/main/java/com/dostalgia/ConfigBuilder.java @@ -0,0 +1,249 @@ +package com.dostalgia; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * Single source of truth for all DOSBox configuration generation. + * Eliminates the duplication that existed between GameService and UploadResource. + * + * Uses Java 21 text blocks for readable, maintainable config templates. + * Adding a new config parameter requires a change in exactly ONE place. + */ +@ApplicationScoped +public class ConfigBuilder { + + /** Priority: .cue (5) > .iso (4) > .ccd (3) > .img (2) > .bin (1) */ + private static final Map CD_FORMAT_PRIORITY = Map.of( + ".cue", 5, ".iso", 4, ".ccd", 3, ".img", 2, ".bin", 1 + ); + + // ─── Public API ─────────────────────────────────────────────── + + /** + * Build a dosbox.conf for a game with a known executable. + * @param relExe relative path to the executable inside the ZIP (e.g. "FALLOUT.EXE" or "FALLOUT/FALLOUT.EXE") + * @param cdImages relative paths to CD image files inside the ZIP + */ + public String buildDosboxConf(String relExe, List cdImages) { + var parts = splitExePath(relExe); + String dir = parts.dir(); + String exe = parts.exe(); + + return DOSBOX_CONF_TEMPLATE + .replace("${CD_MOUNTS}", buildCdMounts(cdImages)) + .replace("${EXEC_PATH}", buildExecPath(dir, exe)); + } + + /** Build dosbox.conf as UTF-8 bytes (for ZIP stream operations). */ + public byte[] buildDosboxConfBytes(String relExe, List cdImages) { + return buildDosboxConf(relExe, cdImages).getBytes(StandardCharsets.UTF_8); + } + + /** + * Build jsdos.json for js-dos v8 with the given executable path. + * js-dos v8 uses jsdos.json autoexec.script instead of the dosbox.conf [autoexec] section. + */ + public byte[] buildJsdosJson(String relExe, List cdImages) { + var parts = splitExePath(relExe); + String script = buildAutoexecScript(parts.dir(), parts.exe(), cdImages); + return buildJsdosJsonRaw(script); + } + + /** Build dosbox.conf for a CD-only game (no filesystem executables). */ + public String buildCdOnlyDosboxConf(List cdImages) { + return CD_ONLY_DOSBOX_CONF_TEMPLATE + .replace("${CD_MOUNTS}", buildCdMounts(cdImages)); + } + + /** Build jsdos.json for a CD-only game. */ + public byte[] buildCdOnlyJsdosJson(List cdImages) { + String script = buildCdOnlyAutoexecScript(cdImages); + return buildJsdosJsonRaw(script); + } + + /** Split "dir/file.exe" into (dir, exe). Returns (null, file) if no directory separator. */ + public static ExeParts splitExePath(String relExe) { + if (relExe == null || relExe.isBlank()) { + return new ExeParts(null, ""); + } + int idx = relExe.replace('\\', '/').lastIndexOf('/'); + if (idx >= 0) { + return new ExeParts(relExe.substring(0, idx), relExe.substring(idx + 1)); + } + return new ExeParts(null, relExe); + } + + /** Escape a string for embedding inside a JSON string value. */ + public static String escapeJson(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + public record ExeParts(String dir, String exe) {} + + // ─── Config Templates ──────────────────────────────────────── + + // language=ini + private static final String DOSBOX_CONF_TEMPLATE = """ + [sdl] + autolock=true + usescancodes=true + output=surface + + [cpu] + core=auto + cycles=auto + + [mixer] + nosound=false + rate=44100 + + [sblaster] + sbtype=sb16 + irq=7 + dma=1 + hdma=5 + + [dos] + xms=true + ems=true + umb=true + + [dosbox] + memsize=64 + + [autoexec] + @echo off + mount c . + ${CD_MOUNTS}c: + ${EXEC_PATH} + """; + + // language=ini + private static final String CD_ONLY_DOSBOX_CONF_TEMPLATE = """ + [sdl] + autolock=true + usescancodes=true + output=surface + + [cpu] + core=auto + cycles=auto + + [mixer] + nosound=false + rate=44100 + + [sblaster] + sbtype=sb16 + irq=7 + dma=1 + hdma=5 + + [dos] + xms=true + ems=true + umb=true + + [dosbox] + memsize=64 + + [autoexec] + @echo off + mount c . + ${CD_MOUNTS}c: + echo. + echo This game runs from the CD-ROM. + echo Type D: and press Enter, then look for the game executable (e.g. DOTT.EXE). + """; + + // ─── Internal Helpers ──────────────────────────────────────── + + /** + * Build the CD mount lines for the autoexec section. + * Mounts ALL CD images, deduplicating by parent directory. + * Skips descriptor formats (.cue, .ccd) when a data format + * (.img, .iso, .bin) exists in the same directory. + */ + private static String buildCdMounts(List cdImages) { + if (cdImages == null || cdImages.isEmpty()) return ""; + // Deduplicate by parent directory — prefer data formats over descriptors + Set seenDirs = new HashSet<>(); + StringBuilder sb = new StringBuilder(); + char driveLetter = 'D'; + for (String img : cdImages) { + int slashIdx = img.lastIndexOf('/'); + String parentDir = slashIdx >= 0 ? img.substring(0, slashIdx) : ""; + if (seenDirs.contains(parentDir)) continue; + String imgLower = img.toLowerCase(); + // Skip descriptor formats if a data format exists in the same directory + if (imgLower.endsWith(".cue") || imgLower.endsWith(".ccd")) { + boolean hasData = cdImages.stream().anyMatch(i -> { + int is = i.lastIndexOf('/'); + String ip = is >= 0 ? i.substring(0, is) : ""; + String il = i.toLowerCase(); + return ip.equals(parentDir) + && (il.endsWith(".img") || il.endsWith(".iso") || il.endsWith(".bin")); + }); + if (hasData) continue; + } + seenDirs.add(parentDir); + String flags = imgLower.endsWith(".bin") ? " -t cdrom -fs iso" : " -t cdrom"; + sb.append("imgmount ").append(driveLetter).append(" \"") + .append(img).append("\"").append(flags).append("\n"); + driveLetter++; + } + return sb.toString(); + } + + private static String buildExecPath(String dir, String exe) { + if (dir == null) return exe + "\n"; + return "path=c:\\\ncd " + dir + "\n" + exe + "\n"; + } + + private static String buildAutoexecScript(String dir, String exe, List cdImages) { + StringBuilder script = new StringBuilder(); + 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 (dir != null) { + script.append("path=c:\\\n"); + script.append("cd ").append(dir).append("\n"); + } + script.append(exe); + return script.toString(); + } + + private static String buildCdOnlyAutoexecScript(List cdImages) { + StringBuilder script = new StringBuilder(); + 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"); + return script.toString(); + } + + private static byte[] buildJsdosJsonRaw(String script) { + String json = "{\"autoexec\":{\"options\":{\"script\":{\"value\":\"" + + escapeJson(script) + "\"}}}," + + "\"output\":{\"options\":{\"autolock\":{\"value\":true}}}}"; + return json.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/dostalgia/ConfigPatcher.java b/src/main/java/com/dostalgia/ConfigPatcher.java new file mode 100644 index 0000000..47a8df8 --- /dev/null +++ b/src/main/java/com/dostalgia/ConfigPatcher.java @@ -0,0 +1,152 @@ +package com.dostalgia; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** + * Detects and fixes hardcoded absolute DOS paths in game config files. + * Handles the common case where game archives contain paths like + * C:\\FALLOUT1\\MASTER.DAT that break after directory flattening. + */ +@ApplicationScoped +public class ConfigPatcher { + + private static final Logger LOG = Logger.getLogger(ConfigPatcher.class.getName()); + private static final Pattern DRIVE_PATH = Pattern.compile("[A-Za-z]:(\\\\|/)[^\\s\"\\r\\n]+"); + + /** Scan and fix absolute paths in all text files in the given directory. */ + public void fixAbsolutePaths(Path extractDir) throws IOException { + try (var walk = Files.walk(extractDir)) { + walk.filter(Files::isRegularFile) + .filter(f -> { + try { return isSmallTextFile(f); } + catch (IOException e) { return false; } + }) + .forEach(f -> { + try { + String text = new String(Files.readAllBytes(f), StandardCharsets.ISO_8859_1); + String patched = patchText(text, extractDir); + if (!patched.equals(text)) { + Files.write(f, patched.getBytes(StandardCharsets.ISO_8859_1)); + LOG.info("Patched absolute paths in " + f.getFileName()); + } + } catch (IOException e) { + LOG.warning("Failed to patch " + f.getFileName() + ": " + e.getMessage()); + } + }); + } + } + + private static boolean isSmallTextFile(Path f) throws IOException { + long size = Files.size(f); + if (size > 100 * 1024 || size == 0) return false; + try (var is = Files.newInputStream(f)) { + byte[] head = new byte[(int) Math.min(size, 4096)]; + int read = is.readNBytes(head, 0, head.length); + for (int i = 0; i < read; i++) { + if (head[i] == 0) return false; + } + } + return true; + } + + private static String patchText(String text, Path extractDir) { + var m = DRIVE_PATH.matcher(text); + var sb = new StringBuilder(); + int lastEnd = 0; + Set seenPrefixes = new HashSet<>(); + + while (m.find()) { + int start = m.start(); + sb.append(text, lastEnd, start); + + String fullPath = m.group(); + String drive = fullPath.substring(0, 2); + if (!"C:".equalsIgnoreCase(drive)) { + sb.append(fullPath); + lastEnd = m.end(); + continue; + } + + String replacement = findReplacement(fullPath, extractDir, seenPrefixes); + sb.append(replacement); + lastEnd = m.end(); + } + sb.append(text.substring(lastEnd)); + return sb.toString(); + } + + private static String findReplacement(String fullPath, Path extractDir, Set seenPrefixes) { + String drivePrefix = fullPath.substring(0, 2); + String sep = fullPath.substring(2, 3); + String relPath = fullPath.substring(3); + String splitter = "\\\\".equals(sep) ? "\\\\\\\\" : "/"; + String[] parts = relPath.split(splitter, -1); + + 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 (existsIgnoreCase(extractDir, suffixStr)) { + if (skip == 0) return fullPath; + + StringBuilder stale = new StringBuilder(drivePrefix + sep); + for (int k = 0; k < skip; k++) { + if (k > 0) stale.append(sep); + stale.append(parts[k]); + } + stale.append(sep); + + String staleStr = stale.toString(); + if (seenPrefixes.contains(staleStr)) { + return "." + sep + suffixStr; + } + seenPrefixes.add(staleStr); + return "." + sep + suffixStr; + } + } + return fullPath; + } + + private static boolean existsIgnoreCase(Path base, String relativePath) { + String sep = relativePath.contains("\\") ? "\\\\\\\\" : "/"; + String[] parts = relativePath.split(sep, -1); + Path current = base; + try { + for (int i = 0; i < parts.length; i++) { + String part = parts[i]; + if (part.isEmpty()) { + if (i == parts.length - 1) return Files.isDirectory(current); + continue; + } + Path found = null; + try (var dirStream = Files.list(current)) { + for (Path entry : dirStream.toList()) { + if (entry.getFileName().toString().equalsIgnoreCase(part)) { + found = entry; + break; + } + } + } + if (found == null) return false; + current = found; + } + } catch (IOException e) { + return false; + } + return true; + } +} diff --git a/src/main/java/com/dostalgia/ExecutableDetector.java b/src/main/java/com/dostalgia/ExecutableDetector.java new file mode 100644 index 0000000..a399d18 --- /dev/null +++ b/src/main/java/com/dostalgia/ExecutableDetector.java @@ -0,0 +1,143 @@ +package com.dostalgia; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * Detects game executables within extracted archives. + */ +@ApplicationScoped +public class ExecutableDetector { + + @Inject + PlatformDetector platform; + + /** Executable stems to never select as main game executable. */ + private static final Set SKIP_EXE_NAMES = Set.of( + "dos4gw", "dos32a", "dos4gw2", "pmode", "pmodew", "cwsdpmi", + "emm386", "himem", "himemx", "debug", + "uninst", "uninstall", "uninstaller", + "pksfx", "pkzip", "pkunzip", "sfx", "makesfx", + "unzip", "zip", "zip2exe", "arj", "arj32", + "lha", "lha32", "lzh", "rar", "unrar", + "ace", "unace", "zoo", "arc", "ha", "cab" + ); + + /** Collect all discoverable executables (relative paths) from the extracted directory. */ + public List findAll(Path dir) throws IOException { + List result = new ArrayList<>(); + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path f, BasicFileAttributes a) { + String name = f.getFileName().toString().toLowerCase(); + if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) { + result.add(dir.relativize(f).toString().replace('\\', '/')); + } + return FileVisitResult.CONTINUE; + } + }); + result.sort(String::compareTo); + return result; + } + + /** Find the best main executable. */ + public String findMain(Path dir) throws IOException { + record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {} + List candidates = new ArrayList<>(); + + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path f, BasicFileAttributes a) { + String name = f.getFileName().toString().toLowerCase(); + if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat")) + return FileVisitResult.CONTINUE; + + int dot = name.lastIndexOf('.'); + String stem = dot >= 0 ? name.substring(0, dot) : name; + if (SKIP_EXE_NAMES.contains(stem)) + return FileVisitResult.CONTINUE; + + if (isLikelySelfExtractor(f)) + return FileVisitResult.CONTINUE; + + boolean installer = name.contains("install") || name.contains("setup") || name.contains("config"); + int depth = f.getNameCount() - dir.getNameCount(); + String detectedPlatform = platform.detect(f); + candidates.add(new Candidate(depth, installer, a.size(), f.toString(), detectedPlatform)); + return FileVisitResult.CONTINUE; + } + }); + + if (candidates.isEmpty()) return null; + + candidates.sort(Comparator + .comparingInt((Candidate c) -> c.isInstaller() ? 1 : 0) + .thenComparingInt(c -> { + if ("dos".equals(c.platform())) return 0; + if (c.platform() == null) return 1; + return 2; + }) + .thenComparingLong(c -> -c.size()) + .thenComparingInt(Candidate::depth)); + + return candidates.getFirst().path(); + } + + /** Find a setup/install/config executable. */ + public String findSetup(Path dir) throws IOException { + record Candidate(int depth, long size, String path) {} + List candidates = new ArrayList<>(); + List patterns = List.of("setup", "install", "config", "custom"); + + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path f, BasicFileAttributes a) { + String name = f.getFileName().toString().toLowerCase(); + String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name; + if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat")) + return FileVisitResult.CONTINUE; + for (String pat : patterns) { + if (stem.equals(pat) || stem.startsWith(pat + ".") || stem.startsWith(pat + "-")) { + candidates.add(new Candidate( + f.getNameCount() - dir.getNameCount(), a.size(), f.toString())); + break; + } + } + return FileVisitResult.CONTINUE; + } + }); + + if (candidates.isEmpty()) return null; + candidates.sort(Comparator.comparingInt(Candidate::depth).thenComparingLong(Candidate::size)); + return candidates.getFirst().path(); + } + + /** Check if an executable is likely a self-extracting archive (PKSFX stub, etc.). */ + public static boolean isLikelySelfExtractor(Path exePath) { + try (var fis = Files.newInputStream(exePath)) { + byte[] buf = new byte[32768]; + int read = fis.readNBytes(buf, 0, buf.length); + if (read < 0x40) return false; + if (buf[0] != 0x4D || buf[1] != 0x5A) return false; + + String content = new String(buf, 0, read, StandardCharsets.ISO_8859_1); + if (content.contains("PKZIP") || content.contains("PKSFX")) return true; + + for (int i = 0x40; i < read - 4; i++) { + if (buf[i] == 0x50 && (buf[i+1] & 0xFF) == 0x4B) { + int sig = (buf[i+2] & 0xFF) | ((buf[i+3] & 0xFF) << 8); + if (sig == 0x0403 || sig == 0x0201 || sig == 0x0605) return true; + } + } + return false; + } catch (IOException e) { + return false; + } + } +} diff --git a/src/main/java/com/dostalgia/Game.java b/src/main/java/com/dostalgia/Game.java index b4709ac..fe34fba 100644 --- a/src/main/java/com/dostalgia/Game.java +++ b/src/main/java/com/dostalgia/Game.java @@ -17,7 +17,6 @@ public class Game { private String publisher; private String description; private Double rating; - private List platforms; private String bundleFile; // relative path within game dir @@ -26,13 +25,13 @@ public class Game { /** Game bundle file size in bytes. Populated dynamically at load time (not persisted). */ private Long bundleSize; - /** Currently selected main executable (relative path inside the bundle, e.g. "FALLOUT.EXE" or "FALLOUT/FALLOUT.EXE"). */ + /** Currently selected main executable (relative path inside the bundle). */ private String executable; /** All discoverable executables in the bundle (for the UI picker). */ private List executables; - /** Platform type: "dos" for pure DOS, "windows" for Windows 3.x/9x+ (requires emulated Windows), null if unknown. */ + /** Platform type: "dos" for pure DOS, "windows" for Windows 3.x/9x+ */ private String platform; private Integer igdbId; @@ -85,9 +84,6 @@ public class Game { public Double getRating() { return rating; } public void setRating(Double rating) { this.rating = rating; } - public List getPlatforms() { return platforms; } - public void setPlatforms(List platforms) { this.platforms = platforms; } - public String getBundleFile() { return bundleFile; } public void setBundleFile(String bundleFile) { this.bundleFile = bundleFile; } diff --git a/src/main/java/com/dostalgia/GameService.java b/src/main/java/com/dostalgia/GameService.java index 9b41360..6601d3a 100644 --- a/src/main/java/com/dostalgia/GameService.java +++ b/src/main/java/com/dostalgia/GameService.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; import java.io.IOException; @@ -15,10 +16,13 @@ import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.time.Instant; import java.util.*; +import java.util.logging.Logger; /** Manages game metadata stored as JSON files on disk. */ @ApplicationScoped -public class GameService { +public class GameService implements GameStore { + + private static final Logger LOG = Logger.getLogger(GameService.class.getName()); private final ObjectMapper mapper = new ObjectMapper() .registerModule(new JavaTimeModule()) @@ -26,6 +30,12 @@ public class GameService { .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .enable(SerializationFeature.INDENT_OUTPUT); + @Inject + ConfigBuilder config; + + @Inject + CdImageService cdImageService; + @ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data") String dataDir; @@ -111,7 +121,6 @@ public class GameService { /** Delete a game and all its files. */ public boolean delete(String id) throws IOException { - // Remove game directory Path dir = gameDir(id); if (Files.exists(dir)) { Files.walkFileTree(dir, new SimpleFileVisitor<>() { @@ -148,12 +157,10 @@ public class GameService { return result.isEmpty() ? "unknown" : result; } - /** Check if a file exists. */ - public static boolean exists(Path p) { - return Files.exists(p); - } - - /** Patch the .jsdos bundle to use a different main executable. */ + /** + * Patch the .jsdos bundle to use a different main executable. + * Transactional: patches into a temp file, then atomically replaces on success. + */ public void setExecutable(String id, String executable) throws IOException { Game game = load(id); String bundleFile = game.getBundleFile(); @@ -167,181 +174,56 @@ public class GameService { } // Build new config entries — preserve any CD images in the bundle - List cdImages = findCdImagesInBundle(bundlePath); - byte[] newDosboxConf = buildDosboxConfBytes(executable, cdImages); - byte[] newJsdosJson = buildJsdosJsonBytes(executable, cdImages); + List cdImages = cdImageService.findInBundle(bundlePath); + byte[] newDosboxConf = config.buildDosboxConfBytes(executable, cdImages); + byte[] newJsdosJson = config.buildJsdosJson(executable, cdImages); - // Patch the ZIP: read old, write new with modified config entries + // Transactional patch: write to temp, detect platform, then move Path tmpPath = bundlePath.resolveSibling(bundlePath.getFileName() + ".tmp"); - try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath)); - var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(tmpPath))) { + try { + 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(); - if (".jsdos/dosbox.conf".equals(name)) { - zos.putNextEntry(new java.util.zip.ZipEntry(name)); - zos.write(newDosboxConf); - zos.closeEntry(); - } else if (".jsdos/jsdos.json".equals(name)) { - zos.putNextEntry(new java.util.zip.ZipEntry(name)); - zos.write(newJsdosJson); - zos.closeEntry(); - } else { - zos.putNextEntry(new java.util.zip.ZipEntry(name)); - zis.transferTo(zos); - zos.closeEntry(); + java.util.zip.ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName(); + if (".jsdos/dosbox.conf".equals(name)) { + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + zos.write(newDosboxConf); + zos.closeEntry(); + } else if (".jsdos/jsdos.json".equals(name)) { + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + zos.write(newJsdosJson); + zos.closeEntry(); + } else { + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + zis.transferTo(zos); + zos.closeEntry(); + } + zis.closeEntry(); } - zis.closeEntry(); } + + // Detect platform from the new bundle before committing + String detectedPlatform = detectPlatformInBundle(executable, tmpPath); + + // Atomic move on success + Files.move(tmpPath, bundlePath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + + // Update metadata + game.setExecutable(executable); + game.setPlatform(detectedPlatform); + save(game); + + } catch (Exception e) { + // Clean up temp file on failure — leave original untouched + try { Files.deleteIfExists(tmpPath); } catch (IOException ignored) {} + throw e instanceof IOException ioe ? ioe : new IOException(e); } - - Files.move(tmpPath, bundlePath, StandardCopyOption.REPLACE_EXISTING); - - // Update metadata - game.setExecutable(executable); - game.setPlatform(detectPlatformSimple(executable, bundlePath)); - save(game); } - /** Scan a .jsdos bundle for CD image files (preserves the case from the ZIP entry). */ - private List findCdImagesInBundle(Path bundlePath) throws IOException { - Set cdExt = Set.of(".iso", ".cue", ".img", ".ccd", ".bin"); - List cds = new ArrayList<>(); - try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) { - java.util.zip.ZipEntry entry; - while ((entry = zis.getNextEntry()) != null) { - if (entry.isDirectory()) continue; - String name = entry.getName().toLowerCase(); - int dot = name.lastIndexOf('.'); - if (dot >= 0 && cdExt.contains(name.substring(dot))) { - cds.add(entry.getName()); // preserve original filename casing - } - zis.closeEntry(); - } - } - cds.sort(String::compareTo); - return cds; - } - - /** Build a dosbox.conf for a given executable path and optional CD images. */ - private byte[] buildDosboxConfBytes(String relExe, List cdImages) { - var parts = splitExePath(relExe); - String dir = parts[0]; - String exe = parts[1]; - StringBuilder sb = new StringBuilder(); - sb.append("[sdl]\n"); - sb.append("autolock=true\n"); - sb.append("usescancodes=true\n"); - sb.append("output=surface\n"); - sb.append("\n"); - sb.append("[cpu]\n"); - sb.append("core=auto\n"); - sb.append("cycles=auto\n"); - sb.append("\n"); - sb.append("[mixer]\n"); - sb.append("nosound=false\n"); - sb.append("rate=44100\n"); - sb.append("\n"); - sb.append("[sblaster]\n"); - sb.append("sbtype=sb16\n"); - sb.append("irq=7\n"); - sb.append("dma=1\n"); - sb.append("hdma=5\n"); - sb.append("\n"); - sb.append("[dos]\n"); - sb.append("xms=true\n"); - sb.append("ems=true\n"); - sb.append("umb=true\n"); - sb.append("\n"); - sb.append("[dosbox]\n"); - sb.append("memsize=64\n"); - sb.append("\n"); - sb.append("[autoexec]\n"); - sb.append("@echo off\n"); - sb.append("mount c .\n"); - // Mount CD images as D:, E:, … so games can find their CD. - // Deduplicate by parent directory — prefer data formats over descriptors. - Set seenDirs = new java.util.HashSet<>(); - char driveLetter = 'D'; - for (String img : cdImages) { - int slashIdx = img.lastIndexOf('/'); - String parentDir = slashIdx >= 0 ? img.substring(0, slashIdx) : ""; - if (seenDirs.contains(parentDir)) continue; - String imgLower = img.toLowerCase(); - // Skip descriptor formats if a data format exists in the same directory - if (imgLower.endsWith(".cue") || imgLower.endsWith(".ccd")) { - boolean hasData = cdImages.stream().anyMatch(i -> { - int is = i.lastIndexOf('/'); - String ip = is >= 0 ? i.substring(0, is) : ""; - String il = i.toLowerCase(); - return ip.equals(parentDir) - && (il.endsWith(".img") || il.endsWith(".iso") || il.endsWith(".bin")); - }); - if (hasData) continue; - } - seenDirs.add(parentDir); - String flags = imgLower.endsWith(".bin") ? " -t cdrom -fs iso" : " -t cdrom"; - sb.append("imgmount ").append(driveLetter).append(" \"").append(img).append("\"").append(flags).append("\n"); - driveLetter++; - } - sb.append("c:\n"); - if (dir != null) { - sb.append("path=c:\\\n"); - sb.append("cd ").append(dir).append("\n"); - } - sb.append(exe).append("\n"); - return sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); - } - - /** Build jsdos.json for a given executable path and CD images. */ - private byte[] buildJsdosJsonBytes(String relExe, List 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"); - } - script.append(parts[1]); - String json = "{" - + "\"autoexec\":{" - + "\"options\":{\"script\":{\"value\":\"" + escapeJsonValue(script.toString()) + "\"}}" - + "}," - + "\"output\":{" - + "\"options\":{\"autolock\":{\"value\":true}}}" - + "}"; - return json.getBytes(java.nio.charset.StandardCharsets.UTF_8); - } - - /** Split "dir/file.exe" into ["dir", "file.exe"]. Returns [null, file] if no dir. */ - private static String[] splitExePath(String relExe) { - int idx = relExe.replace('\\', '/').lastIndexOf('/'); - if (idx >= 0) { - return new String[]{relExe.substring(0, idx), relExe.substring(idx + 1)}; - } - return new String[]{null, relExe}; - } - - private static String escapeJsonValue(String s) { - return s.replace("\\", "\\\\").replace("\"", "\\\""); - } - - /** Quick platform detection for a given executable inside a .jsdos bundle. */ - private String detectPlatformSimple(String relExe, Path bundlePath) throws IOException { - // Extract the executable from the ZIP and detect its platform + /** Detect platform for an executable inside a .jsdos ZIP bundle. */ + private String detectPlatformInBundle(String relExe, Path bundlePath) throws IOException { try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) { java.util.zip.ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { @@ -351,7 +233,6 @@ public class GameService { if (read < 2) return null; if (buf[0] != 0x4D || buf[1] != 0x5A) return null; if (read < 0x40) return "dos"; - // Check for PE/NE at e_lfanew int peOffset = (buf[0x3C] & 0xFF) | ((buf[0x3D] & 0xFF) << 8) | ((buf[0x3E] & 0xFF) << 16) | ((buf[0x3F] & 0xFF) << 24); if (peOffset < 0 || peOffset + 2 > read) return "dos"; @@ -384,9 +265,9 @@ public class GameService { } // Build new config entries — preserve any CD images in the bundle - List cdImages = findCdImagesInBundle(bundlePath); - byte[] newDosboxConf = buildDosboxConfBytes(setupExe, cdImages); - byte[] newJsdosJson = buildJsdosJsonBytes(setupExe, cdImages); + List cdImages = cdImageService.findInBundle(bundlePath); + byte[] newDosboxConf = config.buildDosboxConfBytes(setupExe, cdImages); + byte[] newJsdosJson = config.buildJsdosJson(setupExe, cdImages); try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath)); var zos = new java.util.zip.ZipOutputStream(out)) { diff --git a/src/main/java/com/dostalgia/GameStore.java b/src/main/java/com/dostalgia/GameStore.java new file mode 100644 index 0000000..cb57eb2 --- /dev/null +++ b/src/main/java/com/dostalgia/GameStore.java @@ -0,0 +1,19 @@ +package com.dostalgia; + +import java.io.IOException; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.List; + +/** + * Contract for game metadata persistence. + * Decouples consumers (IgdbService, GameResource) from the concrete storage implementation. + */ +public interface GameStore { + Game load(String id) throws IOException, NoSuchFileException; + void save(Game game) throws IOException; + List list() throws IOException; + boolean delete(String id) throws IOException; + Path gameDir(String id); + Path gameJsonPath(String id); +} diff --git a/src/main/java/com/dostalgia/IgdbService.java b/src/main/java/com/dostalgia/IgdbService.java index 5a1f431..c3d16c4 100644 --- a/src/main/java/com/dostalgia/IgdbService.java +++ b/src/main/java/com/dostalgia/IgdbService.java @@ -37,7 +37,7 @@ public class IgdbService { Optional clientSecret; @Inject - GameService gameService; + GameStore gameStore; private String accessToken; private Instant tokenExpiry; @@ -403,7 +403,6 @@ public class IgdbService { if (!genres.isEmpty()) { game.setGenre(genres.getFirst()); } - game.setPlatforms(genres); // store all genres as platforms list too } if (data.containsKey("igdb_id")) { game.setIgdbId((Integer) data.get("igdb_id")); @@ -429,7 +428,7 @@ public class IgdbService { .build(); HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofFile( - gameService.gameDir(game.getId()).resolve("cover.jpg") + gameStore.gameDir(game.getId()).resolve("cover.jpg") )); if (res.statusCode() == 200) { diff --git a/src/main/java/com/dostalgia/PlatformDetector.java b/src/main/java/com/dostalgia/PlatformDetector.java new file mode 100644 index 0000000..8a39905 --- /dev/null +++ b/src/main/java/com/dostalgia/PlatformDetector.java @@ -0,0 +1,68 @@ +package com.dostalgia; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; + +/** + * Detects platform type (DOS vs Windows) by reading executable headers. + * Pure logic — no I/O beyond reading the file header bytes. + */ +@ApplicationScoped +public class PlatformDetector { + + private static final Logger LOG = Logger.getLogger(PlatformDetector.class.getName()); + + /** + * Detect whether an executable targets DOS or Windows by reading its PE/NE header. + *
    + *
  • Pure MZ, LE (DOS/4GW extender), LX (OS/2) → "dos"
  • + *
  • NE (Windows 3.x), PE (Win32) → "windows"
  • + *
  • Cannot read → null
  • + *
+ */ + public String detect(Path exePath) { + String name = exePath.getFileName().toString().toLowerCase(); + if (name.endsWith(".com") || name.endsWith(".bat")) return "dos"; + + try (var fis = Files.newInputStream(exePath)) { + byte[] buf = new byte[0x80]; + int read = fis.readNBytes(buf, 0, buf.length); + if (read < 2) return null; + if (buf[0] != 0x4D || buf[1] != 0x5A) return null; + if (read < 0x40) return "dos"; + + // Read e_lfanew at offset 0x3C + int peOffset = (buf[0x3C] & 0xFF) + | ((buf[0x3D] & 0xFF) << 8) + | ((buf[0x3E] & 0xFF) << 16) + | ((buf[0x3F] & 0xFF) << 24); + + if (peOffset < 0) return "dos"; + + byte sig1, sig2; + if (peOffset + 2 <= read) { + sig1 = buf[peOffset]; + sig2 = buf[peOffset + 1]; + } else { + try (var fis2 = Files.newInputStream(exePath)) { + fis2.skip(peOffset); + sig1 = (byte) fis2.read(); + sig2 = (byte) fis2.read(); + } + } + + if ((sig1 == 0x50 && sig2 == 0x45) // PE + || (sig1 == 0x4E && sig2 == 0x45)) // NE + return "windows"; + + return "dos"; + } catch (IOException e) { + LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/com/dostalgia/UploadResource.java b/src/main/java/com/dostalgia/UploadResource.java index 85d8799..a47a04f 100644 --- a/src/main/java/com/dostalgia/UploadResource.java +++ b/src/main/java/com/dostalgia/UploadResource.java @@ -26,6 +26,24 @@ public class UploadResource { @Inject IgdbService igdb; + @Inject + ConfigBuilder config; + + @Inject + ZipService zip; + + @Inject + ExecutableDetector exeDetector; + + @Inject + PlatformDetector platform; + + @Inject + CdImageService cdImageService; + + @Inject + ConfigPatcher configPatcher; + private static final Logger LOG = Logger.getLogger(UploadResource.class.getName()); // ─── Upload ────────────────────────────────────────────────── @@ -71,10 +89,10 @@ public class UploadResource { Files.copy(upload.uploadedFile(), zipPath, StandardCopyOption.REPLACE_EXISTING); Path extractDir = tmpDir.resolve("extracted"); - unzip(zipPath, extractDir); + zip.unzip(zipPath, extractDir); // If the ZIP has a single root directory, flatten it try { - flattenSingleDir(extractDir); + zip.flattenSingleDir(extractDir); } catch (Exception e) { LOG.warning("Flatten failed for '" + filename + "': " + e.getMessage() + " — continuing (cd in autoexec handles subdirs)"); @@ -82,68 +100,60 @@ public class UploadResource { // Fix .cue files that reference non-existent data files (e.g. CloneCD) try { - fixCueReferences(extractDir); + cdImageService.fixCueReferences(extractDir); } catch (Exception e) { LOG.warning("Failed to fix cue references: " + e.getMessage()); } // Patch game config files with hardcoded absolute paths - // Some games (Fallout, etc.) have FALLOUT.CFG with C:\dir\ paths - // that break after flattening moves files to root. try { - fixAbsoluteConfigPaths(extractDir); + configPatcher.fixAbsolutePaths(extractDir); } catch (Exception e) { LOG.warning("Failed to patch config paths: " + e.getMessage()); } // Find main executable - String mainExe = findMainExe(extractDir); + String mainExe = exeDetector.findMain(extractDir); - // Detect CD images once (used for both CD-only detection and config generation) - List cdImages = findCdImages(extractDir); + // Detect CD images + List cdImages = cdImageService.findInDirectory(extractDir); if (mainExe == null) { - // No executable on the filesystem — check if CD images exist instead if (cdImages.isEmpty()) { return Response.status(400) .entity(Map.of("error", "No executable (.exe/.com/.bat) found in the archive")) .build(); } - // CD-only game (executable is on the CD image) — proceed without one + // CD-only game — proceed without executable } - // Find setup executable (SETUP.EXE, INSTALL.EXE, CONFIG.EXE, etc.) - String setupExe = findSetupExe(extractDir); + // Find setup executable + String setupExe = exeDetector.findSetup(extractDir); - // Create game directory + // Create game directory and .jsdos bundle Path gameDir = svc.gameDir(gameId); Files.createDirectories(gameDir); - // Create .jsdos bundle inside the game directory String bundleFile = gameId + "/" + gameId + ".jsdos"; Path bundlePath = gameDir.resolve(gameId + ".jsdos"); - createBundle(extractDir, mainExe, cdImages, bundlePath); + zip.createBundle(extractDir, mainExe, cdImages, bundlePath); - // Detect platform (DOS vs Windows) by reading the main EXE header - String platform = mainExe != null ? detectPlatform(Path.of(mainExe)) : "dos"; - - // Store setup executable path if found and DOS-compatible. - // Some games (e.g. Fallout) ship a Windows SETUP.EXE even though the main - // game is DOS — that setup would fail with "This program cannot run in DOS mode". - // The .setup.jsdos bundle is now generated on-the-fly — no disk duplication needed. + // Detect platform (DOS vs Windows) + String platformType = mainExe != null ? platform.detect(Path.of(mainExe)) : "dos"; // Save metadata Game game = new Game(gameId, title); game.setBundleFile(bundleFile); - game.setPlatform(platform); + game.setPlatform(platformType); + // Store the selected executable and the full list for the UI picker String relMain = mainExe != null ? extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/') : ""; game.setExecutable(relMain); - game.setExecutables(findAllExecutables(extractDir)); + game.setExecutables(exeDetector.findAll(extractDir)); if (setupExe != null) { - String setupPlatform = detectPlatform(Path.of(setupExe)); + String setupPlatform = platform.detect(Path.of(setupExe)); if (!"windows".equals(setupPlatform)) { game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString()); } @@ -161,7 +171,7 @@ public class UploadResource { if (game.isHasCover() || game.getYear() != null || game.getGenre() != null || (game.getVideos() != null && !game.getVideos().isEmpty()) || (game.getScreenshots() != null && !game.getScreenshots().isEmpty())) { - svc.save(game); // re-save with enriched metadata + svc.save(game); } } @@ -207,873 +217,6 @@ public class UploadResource { return Response.ok(Map.of("status", "ok")).build(); } - // ─── ZIP Handling ──────────────────────────────────────────── - - private void unzip(Path zipPath, Path dest) throws IOException { - try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(zipPath))) { - java.util.zip.ZipEntry entry; - while ((entry = zis.getNextEntry()) != null) { - Path target = dest.resolve(entry.getName()).normalize(); - if (!target.startsWith(dest)) continue; // prevent traversal - if (entry.isDirectory()) { - Files.createDirectories(target); - } else { - Files.createDirectories(target.getParent()); - Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING); - } - zis.closeEntry(); - } - } - } - - /** - * If the extraction directory contains a single subdirectory, move - * its contents up (flatten). This handles ZIPs where all game files - * are inside a folder (e.g. "mastori2/ORION2.EXE" → "ORION2.EXE"). - * Works even when there are metadata files (file_id.diz, *.nfo, etc.) - * alongside the single game directory at the ZIP root. - */ - private void flattenSingleDir(Path dir) throws IOException { - Path singleDir = null; - try (var files = Files.list(dir)) { - for (Path entry : files.toList()) { - if (Files.isDirectory(entry)) { - if (singleDir != null) return; // multiple directories — abort - singleDir = entry; - } - } - } - if (singleDir == null) return; - - LOG.info("Flattening root directory: " + singleDir.getFileName()); - // Walk in reverse order so directories are empty when we delete them - try (var walk = Files.walk(singleDir)) { - var list = walk.sorted(Comparator.reverseOrder()).toList(); - for (Path f : list) { - if (f.equals(singleDir)) continue; - Path target = dir.resolve(singleDir.relativize(f)); - if (Files.isDirectory(f)) { - Files.createDirectories(target); - Files.delete(f); - } else { - Files.createDirectories(target.getParent()); - Files.move(f, target, StandardCopyOption.REPLACE_EXISTING); - } - } - } - Files.delete(singleDir); - LOG.info("Flattened " + singleDir.getFileName()); - } - - // ─── Executable Detection ──────────────────────────────────── - - /** Collect all discoverable executables (relative paths) from the extracted directory. */ - private List findAllExecutables(Path dir) throws IOException { - List result = new ArrayList<>(); - Files.walkFileTree(dir, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path f, BasicFileAttributes a) { - String name = f.getFileName().toString().toLowerCase(); - if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) { - result.add(dir.relativize(f).toString().replace('\\', '/')); - } - return FileVisitResult.CONTINUE; - } - }); - result.sort(String::compareTo); - return result; - } - - private String findMainExe(Path dir) throws IOException { - record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {} - List candidates = new ArrayList<>(); - - Files.walkFileTree(dir, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path f, BasicFileAttributes a) { - String name = f.getFileName().toString().toLowerCase(); - if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat")) - return FileVisitResult.CONTINUE; - - // Skip known extender / tool executables (DOS4GW, DEBUG, etc.) - int dot = name.lastIndexOf('.'); - String stem = dot >= 0 ? name.substring(0, dot) : name; - if (SKIP_EXE_NAMES.contains(stem)) - return FileVisitResult.CONTINUE; - - // Skip self-extracting archive executables (PKSFX, PKZIP stubs, etc.) - if (isLikelySelfExtractor(f)) - return FileVisitResult.CONTINUE; - - boolean installer = name.contains("install") || name.contains("setup") || name.contains("config"); - int depth = f.getNameCount() - dir.getNameCount(); - String platform = detectPlatform(f); - candidates.add(new Candidate(depth, installer, a.size(), f.toString(), platform)); - return FileVisitResult.CONTINUE; - } - }); - - if (candidates.isEmpty()) return null; - - // Prefer: non-installer → DOS platform → larger file → shallow depth - candidates.sort(Comparator - .comparingInt((Candidate c) -> c.isInstaller() ? 1 : 0) - .thenComparingInt(c -> { - // Prefer DOS over unknown over Windows - if ("dos".equals(c.platform())) return 0; - if (c.platform() == null) return 1; - return 2; // "windows" - }) - .thenComparingLong(c -> -c.size()) - .thenComparingInt(Candidate::depth)); - - return candidates.getFirst().path(); - } - - /** - * Check if an executable is likely a self-extracting archive (PKSFX, PKZIP stub, etc.) - * by looking for characteristic PKWARE signatures in the MS-DOS stub portion. - * Self-extractors contain embedded archive data and are not game executables. - */ - static boolean isLikelySelfExtractor(Path exePath) { - try (var fis = Files.newInputStream(exePath)) { - byte[] buf = new byte[32768]; // 32KB to cover most PKSFX stubs - int read = fis.readNBytes(buf, 0, buf.length); - if (read < 0x40) return false; - // Must start with MZ - if (buf[0] != 0x4D || buf[1] != 0x5A) return false; - // Scan for PKWARE text signatures in the MS-DOS stub area - String content = new String(buf, 0, read, java.nio.charset.StandardCharsets.ISO_8859_1); - if (content.contains("PKZIP") || content.contains("PKSFX")) - return true; - // Check for PKWARE zip signatures in appended zip data. - // Only check at reasonable offsets (past the MZ header + stub) - // to avoid false positives on code bytes that happen to be 0x50,0x4B. - for (int i = 0x40; i < read - 4; i++) { - int p = buf[i] & 0xFF; - // PK\x03\x04 = local file header, PK\x01\x02 = central dir - if (p == 0x50 && (buf[i+1] & 0xFF) == 0x4B) { - int sig = (buf[i+2] & 0xFF) | ((buf[i+3] & 0xFF) << 8); - if (sig == 0x0403 || sig == 0x0201 || sig == 0x0605) - return true; - } - } - return false; - } catch (IOException e) { - return false; - } - } - - // ─── Setup Executable Detection ───────────────────────────── - - /** - * Find a setup/install/config executable in the extracted game directory. - * Looks for files named setup.exe/com/bat, install.exe/com/bat, - * config.exe/com, customize.exe/com etc. - */ - private String findSetupExe(Path dir) throws IOException { - record Candidate(int depth, long size, String path) {} - List candidates = new ArrayList<>(); - - List patterns = List.of("setup", "install", "config", "custom"); - - Files.walkFileTree(dir, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path f, BasicFileAttributes a) { - String name = f.getFileName().toString().toLowerCase(); - String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name; - if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat")) - return FileVisitResult.CONTINUE; - for (String pat : patterns) { - if (stem.equals(pat) || stem.startsWith(pat + ".") || stem.startsWith(pat + "-")) { - int depth = f.getNameCount() - dir.getNameCount(); - candidates.add(new Candidate(depth, a.size(), f.toString())); - break; - } - } - return FileVisitResult.CONTINUE; - } - }); - - if (candidates.isEmpty()) return null; - // Prefer shallow (root-level) files, then smaller files (setup utilities tend to be small) - candidates.sort(Comparator - .comparingInt(Candidate::depth) - .thenComparingLong(Candidate::size)); - return candidates.getFirst().path(); - } - - - // ─── Bundle Creation ───────────────────────────────────────── - - /** File extensions that are never game data and should be excluded from .jsdos bundles. - * Only formats DOSBox can't mount natively are stripped. - * Mountable formats (.iso, .cue, .img, .ccd) are kept and auto-mounted via imgmount. */ - private static final java.util.Set SKIP_EXT = java.util.Set.of( - ".nrg", ".mdf", ".mds", // disk images DOSBox can't mount natively - ".sub", // subchannel data (accompanies .ccd — CCD itself is kept) - ".dmg" // Mac disk images - ); - - /** File extensions that DOSBox can mount as CD-ROM via imgmount. */ - private static final java.util.Set CD_EXT = java.util.Set.of( - ".iso", ".cue", ".img", ".ccd", ".bin" - ); - - /** - * Fix .cue files that reference non-existent data files. - * CloneCD rips often generate a .cue that says FILE "DOTT.bin" BINARY - * but the actual data file is DOTT.img. DOSBox can't load the .cue - * when the referenced file doesn't exist. - */ - private static void fixCueReferences(Path extractDir) throws IOException { - try (var walk = Files.walk(extractDir)) { - walk.filter(Files::isRegularFile) - .filter(f -> f.getFileName().toString().toLowerCase().endsWith(".cue")) - .forEach(f -> { - try { - fixOneCue(f); - } catch (IOException e) { - LOG.warning("Failed to fix cue file " + f + ": " + e.getMessage()); - } - }); - } - } - - private static void fixOneCue(Path cueFile) throws IOException { - String content = Files.readString(cueFile); - Path dir = cueFile.getParent(); - - // Find the FILE line: FILE "somefile.bin" BINARY - // The pattern: FILE "..." (anything between quotes) - java.util.regex.Matcher m = java.util.regex.Pattern - .compile("FILE\\s+\"([^\"]+)\"", java.util.regex.Pattern.CASE_INSENSITIVE) - .matcher(content); - if (!m.find()) return; // no FILE line, nothing to fix - - String referenced = m.group(1); - Path refPath = dir.resolve(referenced); - if (Files.exists(refPath)) return; // referenced file exists, cue is fine - - // Referenced file doesn't exist — look for .img or .bin in same directory - Path replacement = findDataFile(dir); - if (replacement == null) return; // nothing to replace with - - // Rewrite the FILE line with the actual file - String actualName = replacement.getFileName().toString(); - content = content.substring(0, m.start(1)) + actualName + content.substring(m.end(1)); - Files.writeString(cueFile, content); - LOG.info("Fixed cue " + cueFile.getFileName() + ": referenced '" + referenced - + "' → '" + actualName + "'"); - } - - /** Find a .img or .bin file in the directory that could be the data file. */ - private static Path findDataFile(Path dir) throws IOException { - try (var files = Files.list(dir)) { - return files.filter(Files::isRegularFile) - .filter(f -> { - String n = f.getFileName().toString().toLowerCase(); - return n.endsWith(".img") || n.endsWith(".bin"); - }) - .findFirst() - .orElse(null); - } - } - - /** - * Detect and fix hardcoded absolute DOS paths in game config files. - * - * Scans all text files in the extracted game for patterns like - * {@code master_dat=C:\FALLOUT1\MASTER.DAT} or {@code C:/FALLOUT1/MASTER.DAT}. - * If the referenced path doesn't exist in the extracted tree but IS found - * at root level (after flattening), rewrites the stale directory prefix - * from {@code C:\STALEDIR\} to {@code .\} (relative to bundle root). - * - * This is fully game-agnostic — no hardcoded game names or known config files. - * Only processes C: drive references; D:/E:/etc. are left alone (CD-ROM refs). - */ - private static void fixAbsoluteConfigPaths(Path extractDir) throws IOException { - java.util.regex.Pattern drvPattern = java.util.regex.Pattern.compile( - "[A-Za-z]:(\\\\|/)[^\\s\"\\r\\n]+"); - try (var walk = Files.walk(extractDir)) { - walk.filter(Files::isRegularFile) - .filter(f -> { - try { return isSmallTextFile(f); } - catch (IOException e) { return false; } - }) - .forEach(f -> { - try { - byte[] data = Files.readAllBytes(f); - byte[] original = data.clone(); - String text = new String(data, java.nio.charset.StandardCharsets.ISO_8859_1); - String patched = patchAbsolutePaths(text, extractDir, drvPattern); - if (!patched.equals(text)) { - Files.write(f, patched.getBytes( - java.nio.charset.StandardCharsets.ISO_8859_1)); - LOG.info("Patched absolute paths in " + f.getFileName()); - } - } catch (IOException e) { - LOG.warning("Failed to patch " + f.getFileName() + ": " + e.getMessage()); - } - }); - } - } - - /** Returns true if the file is small (<100KB) and contains no null bytes (i.e. is text). */ - private static boolean isSmallTextFile(Path f) throws IOException { - long size = Files.size(f); - if (size > 100 * 1024 || size == 0) return false; - try (var is = Files.newInputStream(f)) { - int len = (int) Math.min(size, 4096); - byte[] head = new byte[len]; - int read = is.readNBytes(head, 0, len); - for (int i = 0; i < read; i++) { - if (head[i] == 0) return false; - } - } - return true; - } - - /** - * Find absolute DOS paths (C:\dir\file) in the text and rewrite stale - * directory prefixes to {@code .\} where the file/dir is found at root - * level after flattening. - */ - private static String patchAbsolutePaths(String text, Path extractDir, - java.util.regex.Pattern drvPattern) { - var m = drvPattern.matcher(text); - var sb = new StringBuilder(); - int lastEnd = 0; - java.util.Set seenPrefixes = new java.util.HashSet<>(); - - while (m.find()) { - int start = m.start(); - // Copy text up to this match - sb.append(text, lastEnd, start); - - String fullPath = m.group(); - String drive = fullPath.substring(0, 2); // "C:" or "D:" etc. - - // Only rewrite C: drive references - if (!"C:".equalsIgnoreCase(drive)) { - sb.append(fullPath); - lastEnd = m.end(); - continue; - } - - // Try to find what exists after flattening - String replacement = findStalePrefixReplace(fullPath, extractDir, seenPrefixes); - sb.append(replacement); - lastEnd = m.end(); - } - sb.append(text.substring(lastEnd)); - return sb.toString(); - } - - /** - * Given a full path like {@code C:\FALLOUT1\CRITTER.DAT}, try progressively - * shorter suffixes against the extracted directory tree. When a suffix is - * found, rewrite the stale prefix to {@code .\}. - * - * Example: {@code C:\FALLOUT1\CRITTER.DAT} → CRITTER.DAT exists at root → - * stale prefix is {@code C:\FALLOUT1\} → returns {@code .\CRITTER.DAT} - */ - private static String findStalePrefixReplace(String fullPath, Path extractDir, - java.util.Set seenPrefixes) { - String drivePrefix = fullPath.substring(0, 2); // "C:" - String sep = fullPath.substring(2, 3); // "\" or "/" - String relPath = fullPath.substring(3); // everything after "C:\" - - // Split into components - String separator = "\\".equals(sep) ? "\\\\" : "/"; - String[] parts = relPath.split(separator, -1); - // parts = ["FALLOUT1", "CRITTER.DAT"] or ["FALLOUT1", "DATA", "SOUND", "MUSIC", ""] - // The last element is empty if the path ends with separator (directory ref) - - // Try suffixes from longest to shortest - for (int skip = 0; skip < parts.length; skip++) { - // Build suffix: parts[skip] + sep + parts[skip+1] + ... - 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; - - // Check if this suffix exists in the extracted directory (case-insensitive) - if (existsIgnoreCase(extractDir, suffixStr)) { - // The path is valid — keep it as-is - if (skip == 0) return fullPath; - - // This suffix exists at root; build the replacement - // stale prefix = C:\ + parts[0..skip-1] + separator - // replacement = .\ + suffixStr - // But avoid double-replacing the same prefix - StringBuilder stale = new StringBuilder(drivePrefix + sep); - for (int k = 0; k < skip; k++) { - if (k > 0) stale.append(sep); - stale.append(parts[k]); - } - stale.append(sep); - - String staleStr = stale.toString(); - if (seenPrefixes.contains(staleStr)) { - // Already replaced elsewhere; just return the relative form - return "." + sep + suffixStr; - } - seenPrefixes.add(staleStr); - return "." + sep + suffixStr; - } - } - - // Nothing matched — return the path unchanged - return fullPath; - } - - /** - * Case-insensitive path existence check. Walks the path component by - * component, matching each directory/file name case-insensitively against - * the actual filesystem entries. - */ - private static boolean existsIgnoreCase(Path base, String relativePath) { - String sep = relativePath.contains("\\") ? "\\\\" : "/"; - String[] parts = relativePath.split(sep, -1); - Path current = base; - try { - for (int i = 0; i < parts.length; i++) { - String part = parts[i]; - if (part.isEmpty()) { - if (i == parts.length - 1) return Files.isDirectory(current); - continue; - } - Path found = null; - try (var dirStream = Files.list(current)) { - for (Path entry : dirStream.toList()) { - if (entry.getFileName().toString().equalsIgnoreCase(part)) { - found = entry; - break; - } - } - } - if (found == null) return false; - current = found; - } - } catch (IOException e) { - return false; - } - return true; - } - - /** Find mountable CD image files in the extracted game directory. - * Returns relative paths (with forward slashes) sorted alphabetically. */ - private List findCdImages(Path dir) throws IOException { - List cds = new ArrayList<>(); - try (var walk = Files.walk(dir)) { - walk.filter(Files::isRegularFile).forEach(f -> { - String name = f.getFileName().toString().toLowerCase(); - int dot = name.lastIndexOf('.'); - if (dot >= 0 && CD_EXT.contains(name.substring(dot))) { - cds.add(dir.relativize(f).toString().replace('\\', '/')); - } - }); - } - cds.sort(String::compareTo); - return cds; - } - - /** - * Executable filenames (lowercase, stem only, no extension) that should NEVER - * be selected as the main game executable. These are DOS extenders, DPMI hosts, - * debuggers, and other auxiliary tools that ship alongside many DOS games. - */ - private static final java.util.Set SKIP_EXE_NAMES = java.util.Set.of( - "dos4gw", "dos32a", "dos4gw2", // DOS/4GW family (DOS extenders) - "pmode", "pmodew", // Watcom / PMODE extenders - "cwsdpmi", // DPMI host (used by many DJGPP games) - "emm386", // Expanded memory manager - "himem", "himemx", // Extended memory managers - "debug", // Debugger - "uninst", "uninstall", "uninstaller", // Uninstallers - // Compression / archive tools (self-extractors) - "pksfx", "pkzip", "pkunzip", "sfx", "makesfx", // PKWARE - "unzip", "zip", "zip2exe", // Info-ZIP - "arj", "arj32", // ARJ - "lha", "lha32", "lzh", // LHA/LHarc - "rar", "unrar", // RAR - "ace", "unace", // ACE - "zoo", "arc", "ha", "cab" // others - ); - - private void createBundle(Path extractDir, String exePath, List cdImages, Path bundlePath) throws IOException { - // Write .jsdos config directly into the extract directory - Path jsdos = extractDir.resolve(".jsdos"); - Files.createDirectories(jsdos); - 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, 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(cdImages)); - } - - // ZIP it up directly from extractDir (no temp copy) - try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) { - - // ── Phase 1: Collect all directory paths from the file tree ── - // js-dos C code (jsdos-libzip.c) iterates ZIP entries linearly and - // calls mkdir() on each directory entry. All parents must come - // before their children or mkdir() fails with exit(1). - var allDirs = new java.util.TreeSet(); - try (var walk = Files.walk(extractDir)) { - walk.filter(Files::isRegularFile).forEach(f -> { - String entryName = extractDir.relativize(f).toString().replace('\\', '/'); - int idx = entryName.lastIndexOf('/'); - while (idx >= 0) { - allDirs.add(entryName.substring(0, idx + 1)); - idx = entryName.lastIndexOf('/', idx - 1); - } - }); - } - - // ── Phase 2: Write ALL directory entries first (sorted → shallowest first) ── - for (String dir : allDirs) { - zos.putNextEntry(new java.util.zip.ZipEntry(dir)); - zos.closeEntry(); - } - - // ── Phase 3: .jsdos config files (must come before game files) ── - try (var walk = Files.walk(jsdos)) { - walk.filter(Files::isRegularFile) - .sorted() - .forEach(f -> { - try { - String entryName = extractDir.relativize(f).toString().replace('\\', '/'); - zos.putNextEntry(new java.util.zip.ZipEntry(entryName)); - Files.copy(f, zos); - zos.closeEntry(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); - } - - // ── Phase 4: All other game files ── - try (var walk = Files.walk(extractDir)) { - walk.filter(Files::isRegularFile) - .filter(f -> !f.startsWith(jsdos)) - .filter(f -> { - String n = f.getFileName().toString().toLowerCase(); - int dot = n.lastIndexOf('.'); - return dot < 0 || !SKIP_EXT.contains(n.substring(dot)); - }) - .sorted() - .forEach(f -> { - try { - String entryName = extractDir.relativize(f).toString().replace('\\', '/'); - zos.putNextEntry(new java.util.zip.ZipEntry(entryName)); - Files.copy(f, zos); - zos.closeEntry(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); - } - } - - // Clean up .jsdos config files from the extract dir - Files.walk(jsdos).sorted(Comparator.reverseOrder()).forEach(p -> { - try { Files.deleteIfExists(p); } catch (IOException ignored) {} - }); - } - - // ─── Configuration Helpers ────────────────────────────────── - - /** - * Split a relative executable path into its directory and file parts. - * If relExe contains a '/' (or '\\'), returns {dir, file}. - * If relExe is just a filename, returns {null, relExe}. - */ - private static String[] splitDirExe(String relExe) { - int idx = relExe.replace('\\', '/').lastIndexOf('/'); - if (idx >= 0) { - return new String[]{relExe.substring(0, idx), relExe.substring(idx + 1)}; - } - return new String[]{null, relExe}; - } - - private byte[] buildJsdosJson(String relExe, List cdImages) { - // DOS uses \\ as path separator; / is a switch character. - // 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) { - script.append("path=c:\\\n"); - script.append("cd ").append(parts[0]).append("\n"); - } - script.append(parts[1]); - String json = "{" - + "\"autoexec\":{" - + "\"options\":{\"script\":{\"value\":\"" + escapeJson(script.toString()) + "\"}}" - + "}," - + "\"output\":{" - + "\"options\":{\"autolock\":{\"value\":true}}}" - + "}"; - return json.getBytes(java.nio.charset.StandardCharsets.UTF_8); - } - - /** Resolve one CD image per parent directory with best-format priority. - * Priority: .cue > .iso > .ccd > .img > .bin - * js-dos DOSBox (Emscripten) supports .cue natively, but .ccd often fails. */ - private static List resolveCdImages(List cdImages) { - // Group by parent directory - java.util.Map> byDir = new java.util.LinkedHashMap<>(); - for (String img : cdImages) { - int slashIdx = img.lastIndexOf('/'); - String parentDir = slashIdx >= 0 ? img.substring(0, slashIdx) : ""; - byDir.computeIfAbsent(parentDir, k -> new java.util.ArrayList<>()).add(img); - } - List result = new java.util.ArrayList<>(); - for (var entry : byDir.entrySet()) { - String best = pickBestCdImage(entry.getValue()); - if (best != null) result.add(best); - } - result.sort(String::compareTo); - return result; - } - - private static String pickBestCdImage(java.util.List images) { - // Priority: .cue [5] > .iso [4] > .ccd [3] > .img [2] > .bin [1] - String best = null; - int bestScore = -1; - for (String s : images) { - String lo = s.toLowerCase(); - int score = lo.endsWith(".cue") ? 5 - : lo.endsWith(".iso") ? 4 - : lo.endsWith(".ccd") ? 3 - : lo.endsWith(".img") ? 2 - : lo.endsWith(".bin") ? 1 - : 0; - if (score > bestScore) { - bestScore = score; - best = s; - } - } - return best; - } - - /** Build config for a CD-only game (no executables on the filesystem). */ - private String buildCdOnlyDosboxConf(List cdImages) { - StringBuilder sb = new StringBuilder(); - sb.append("[sdl]\n"); - sb.append("autolock=true\n"); - sb.append("usescancodes=true\n"); - sb.append("output=surface\n"); - sb.append("\n"); - sb.append("[cpu]\n"); - sb.append("core=auto\n"); - sb.append("cycles=auto\n"); - sb.append("\n"); - sb.append("[mixer]\n"); - sb.append("nosound=false\n"); - sb.append("rate=44100\n"); - sb.append("\n"); - sb.append("[sblaster]\n"); - sb.append("sbtype=sb16\n"); - sb.append("irq=7\n"); - sb.append("dma=1\n"); - sb.append("hdma=5\n"); - sb.append("\n"); - sb.append("[dos]\n"); - sb.append("xms=true\n"); - sb.append("ems=true\n"); - sb.append("umb=true\n"); - sb.append("\n"); - sb.append("[dosbox]\n"); - sb.append("memsize=64\n"); - sb.append("\n"); - sb.append("[autoexec]\n"); - sb.append("@echo off\n"); - sb.append("mount c .\n"); - // Mount CD images as D:, E:, … - // Resolve one image per directory with format priority (.cue > .iso > .ccd > .img > .bin) - 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"; - sb.append("imgmount ").append(driveLetter).append(" \"").append(img).append("\"").append(flags).append("\n"); - driveLetter++; - } - // Present a DOS prompt — the game runs from the CD - 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(); - } - - private byte[] buildCdOnlyJsdosJson(List 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\":\"" - + escapeJson(script.toString()) + "\"}}" - + "}," - + "\"output\":{" - + "\"options\":{\"autolock\":{\"value\":true}}}" - + "}"; - return json.getBytes(java.nio.charset.StandardCharsets.UTF_8); - } - - private String buildDosboxConf(String relExe, List cdImages) { - // DOS uses \\ as path separator; / is a switch character. - // If the executable is in a subdirectory, cd into it first. - var parts = splitDirExe(relExe); - String dir = parts[0]; - String exe = parts[1]; - StringBuilder sb = new StringBuilder(); - sb.append("[sdl]\n"); - sb.append("autolock=true\n"); - sb.append("usescancodes=true\n"); - sb.append("output=surface\n"); - sb.append("\n"); - sb.append("[cpu]\n"); - sb.append("core=auto\n"); - sb.append("cycles=auto\n"); - sb.append("\n"); - sb.append("[mixer]\n"); - sb.append("nosound=false\n"); - sb.append("rate=44100\n"); - sb.append("\n"); - sb.append("[sblaster]\n"); - sb.append("sbtype=sb16\n"); - sb.append("irq=7\n"); - sb.append("dma=1\n"); - sb.append("hdma=5\n"); - sb.append("\n"); - sb.append("[dos]\n"); - sb.append("xms=true\n"); - sb.append("ems=true\n"); - sb.append("umb=true\n"); - sb.append("\n"); - sb.append("[dosbox]\n"); - sb.append("memsize=64\n"); - sb.append("\n"); - sb.append("[autoexec]\n"); - sb.append("@echo off\n"); - sb.append("mount c .\n"); - // Mount CD images as D:, E:, … so games can find their CD. - // Resolve one image per directory with format priority (.cue > .iso > .ccd > .img > .bin) - 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"; - sb.append("imgmount ").append(driveLetter).append(" \"").append(img).append("\"").append(flags).append("\n"); - driveLetter++; - } - sb.append("c:\n"); - if (dir != null) { - sb.append("path=c:\\\n"); - sb.append("cd ").append(dir).append("\n"); - } - sb.append(exe).append("\n"); - return sb.toString(); - } - - private String escapeJson(String s) { - return s.replace("\\", "\\\\").replace("\"", "\\\""); - } - - /** - * Detect whether an executable targets DOS or Windows by reading its PE/NE header. - *
    - *
  • Pure MZ, LE (DOS/4GW extender), LX (OS/2) → "dos"
  • - *
  • NE (Windows 3.x), PE (Win32) → "windows"
  • - *
  • Cannot read → null
  • - *
- * Note: LE (Linear Executable) is used by DOS/4GW, DOS/32A, PMODE/W - * — all 32-bit DOS extenders for games like DOOM, Blood, Duke Nukem 3D. - * Only PE (Portable Executable) and NE (New Executable) are Windows. - */ - static String detectPlatform(Path exePath) { - String name = exePath.getFileName().toString().toLowerCase(); - // .com and .bat files are always DOS - if (name.endsWith(".com") || name.endsWith(".bat")) return "dos"; - - try (var fis = Files.newInputStream(exePath)) { - byte[] buf = new byte[0x80]; - int read = fis.readNBytes(buf, 0, buf.length); - if (read < 2) return null; - - // Must start with MZ - if (buf[0] != 0x4D || buf[1] != 0x5A) return null; - - // Read e_lfanew at offset 0x3C (only meaningful for PE/NE — garbage for pure DOS) - if (read < 0x40) return "dos"; // too short for PE/NE, assume DOS - int peOffset = (buf[0x3C] & 0xFF) - | ((buf[0x3D] & 0xFF) << 8) - | ((buf[0x3E] & 0xFF) << 16) - | ((buf[0x3F] & 0xFF) << 24); - - // e_lfanew is garbage for pure DOS executables — may be negative or absurd - if (peOffset < 0) return "dos"; - - // If signature is beyond our buffer, read it from the file - byte sig1, sig2; - if (peOffset + 2 <= read) { - sig1 = buf[peOffset]; - sig2 = buf[peOffset + 1]; - } else { - try (var fis2 = Files.newInputStream(exePath)) { - fis2.skip(peOffset); - sig1 = (byte) fis2.read(); - sig2 = (byte) fis2.read(); - } - } - - // PE = Portable Executable (Win32) - // NE = New Executable (Windows 3.x) - // LE = Linear Executable (DOS/4GW, DOS/32A — 32-bit DOS extenders) - // LX = Extended Linear Executable (OS/2 2.x) - if ((sig1 == 0x50 && sig2 == 0x45) // PE - || (sig1 == 0x4E && sig2 == 0x45)) // NE - return "windows"; - - return "dos"; // Plain MZ without known Windows signature - } catch (IOException e) { - LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage()); - return null; - } - } - // ─── File Utilities ────────────────────────────────────────── private void deleteDir(Path dir) throws IOException { diff --git a/src/main/java/com/dostalgia/ZipService.java b/src/main/java/com/dostalgia/ZipService.java new file mode 100644 index 0000000..6a83b08 --- /dev/null +++ b/src/main/java/com/dostalgia/ZipService.java @@ -0,0 +1,162 @@ +package com.dostalgia; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.*; +import java.util.*; +import java.util.logging.Logger; + +/** + * ZIP extraction, directory flattening, and .jsdos bundle creation. + */ +@ApplicationScoped +public class ZipService { + + private static final Logger LOG = Logger.getLogger(ZipService.class.getName()); + + @Inject + ConfigBuilder config; + + /** File extensions to exclude from .jsdos bundles (disk images DOSBox can't mount). */ + private static final Set SKIP_EXT = Set.of( + ".nrg", ".mdf", ".mds", ".sub", ".dmg" + ); + + /** Unzip an archive to a destination directory (path traversal safe). */ + public void unzip(Path zipPath, Path dest) throws IOException { + try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(zipPath))) { + java.util.zip.ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + Path target = dest.resolve(entry.getName()).normalize(); + if (!target.startsWith(dest)) continue; + if (entry.isDirectory()) { + Files.createDirectories(target); + } else { + Files.createDirectories(target.getParent()); + Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING); + } + zis.closeEntry(); + } + } + } + + /** + * If the extraction directory contains a single subdirectory, move its contents up. + * Handles ZIPs where all game files are inside a folder. + */ + public void flattenSingleDir(Path dir) throws IOException { + Path singleDir = null; + try (var files = Files.list(dir)) { + for (Path entry : files.toList()) { + if (Files.isDirectory(entry)) { + if (singleDir != null) return; // multiple directories — abort + singleDir = entry; + } + } + } + if (singleDir == null) return; + + LOG.info("Flattening root directory: " + singleDir.getFileName()); + try (var walk = Files.walk(singleDir)) { + var list = walk.sorted(Comparator.reverseOrder()).toList(); + for (Path f : list) { + if (f.equals(singleDir)) continue; + Path target = dir.resolve(singleDir.relativize(f)); + if (Files.isDirectory(f)) { + Files.createDirectories(target); + Files.delete(f); + } else { + Files.createDirectories(target.getParent()); + Files.move(f, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + Files.delete(singleDir); + LOG.info("Flattened " + singleDir.getFileName()); + } + + /** + * Create a .jsdos bundle ZIP from the extracted game directory. + * Generates DOSBox configs and packs everything into a valid js-dos bundle. + */ + public void createBundle(Path extractDir, String exePath, List cdImages, Path bundlePath) throws IOException { + // Write .jsdos config into the extract directory + Path jsdos = extractDir.resolve(".jsdos"); + Files.createDirectories(jsdos); + if (exePath != null) { + String relExe = extractDir.relativize(Path.of(exePath)).toString(); + Files.writeString(jsdos.resolve("dosbox.conf"), config.buildDosboxConf(relExe, cdImages)); + Files.write(jsdos.resolve("jsdos.json"), config.buildJsdosJson(relExe, cdImages)); + } else { + Files.writeString(jsdos.resolve("dosbox.conf"), config.buildCdOnlyDosboxConf(cdImages)); + Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages)); + } + + // ZIP it up from extractDir + try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) { + + // Phase 1: Collect all directory paths + var allDirs = new TreeSet(); + try (var walk = Files.walk(extractDir)) { + walk.filter(Files::isRegularFile).forEach(f -> { + String entryName = extractDir.relativize(f).toString().replace('\\', '/'); + int idx = entryName.lastIndexOf('/'); + while (idx >= 0) { + allDirs.add(entryName.substring(0, idx + 1)); + idx = entryName.lastIndexOf('/', idx - 1); + } + }); + } + + // Phase 2: Write directory entries first (sorted → shallowest first) + for (String dir : allDirs) { + zos.putNextEntry(new java.util.zip.ZipEntry(dir)); + zos.closeEntry(); + } + + // Phase 3: .jsdos config files (must come before game files) + try (var walk = Files.walk(jsdos)) { + walk.filter(Files::isRegularFile).sorted().forEach(f -> { + try { + String entryName = extractDir.relativize(f).toString().replace('\\', '/'); + zos.putNextEntry(new java.util.zip.ZipEntry(entryName)); + Files.copy(f, zos); + zos.closeEntry(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + // Phase 4: Game files (excluding skipped extensions) + try (var walk = Files.walk(extractDir)) { + walk.filter(Files::isRegularFile) + .filter(f -> !f.startsWith(jsdos)) + .filter(f -> { + String n = f.getFileName().toString().toLowerCase(); + int dot = n.lastIndexOf('.'); + return dot < 0 || !SKIP_EXT.contains(n.substring(dot)); + }) + .sorted() + .forEach(f -> { + try { + String entryName = extractDir.relativize(f).toString().replace('\\', '/'); + zos.putNextEntry(new java.util.zip.ZipEntry(entryName)); + Files.copy(f, zos); + zos.closeEntry(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } + + // Clean up .jsdos config files from the extract dir + Files.walk(jsdos).sorted(Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) {} + }); + } +}