383c66b166
Build & Deploy / build-and-deploy (push) Successful in 37s
Since GameService now preserves CD images when regenerating configs, the dedup logic is safe to re-add. Deduplicates by parent directory so a .bin and .cue from the same folder only produce one imgmount. Alphabetical sort ensures .bin (b) is mounted before .cue (c).
695 lines
30 KiB
Java
695 lines
30 KiB
Java
package com.dostalgia;
|
|
|
|
import jakarta.inject.Inject;
|
|
import jakarta.ws.rs.Consumes;
|
|
import jakarta.ws.rs.POST;
|
|
import jakarta.ws.rs.PathParam;
|
|
import jakarta.ws.rs.Produces;
|
|
import jakarta.ws.rs.core.MediaType;
|
|
import jakarta.ws.rs.core.Response;
|
|
import org.jboss.resteasy.reactive.RestForm;
|
|
import org.jboss.resteasy.reactive.multipart.FileUpload;
|
|
|
|
import java.io.*;
|
|
import java.nio.file.*;
|
|
import java.nio.file.attribute.BasicFileAttributes;
|
|
import java.util.*;
|
|
import java.util.logging.Logger;
|
|
|
|
@jakarta.ws.rs.Path("/api/upload")
|
|
@Produces(MediaType.APPLICATION_JSON)
|
|
public class UploadResource {
|
|
|
|
@Inject
|
|
GameService svc;
|
|
|
|
@Inject
|
|
IgdbService igdb;
|
|
|
|
private static final Logger LOG = Logger.getLogger(UploadResource.class.getName());
|
|
|
|
// ─── Upload ──────────────────────────────────────────────────
|
|
|
|
@POST
|
|
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
|
public Response upload(
|
|
@RestForm("file") FileUpload upload,
|
|
@RestForm("title") String title,
|
|
@RestForm("igdb_id") Integer igdbId
|
|
) throws Exception {
|
|
if (upload == null) {
|
|
return Response.status(400).entity(Map.of("error", "No file provided")).build();
|
|
}
|
|
|
|
String filename = upload.fileName();
|
|
if (title == null || title.isBlank()) {
|
|
title = filename.endsWith(".zip") ? filename.substring(0, filename.length() - 4) : filename;
|
|
}
|
|
|
|
String gameId = GameService.sanitizeId(title);
|
|
|
|
// Check for duplicate by title (case-insensitive)
|
|
for (var g : svc.list()) {
|
|
if (g.getTitle() != null && g.getTitle().equalsIgnoreCase(title)) {
|
|
return Response.status(409)
|
|
.entity(Map.of("error", "\"" + title + "\" is already in your library"))
|
|
.build();
|
|
}
|
|
}
|
|
|
|
// Ensure unique ID
|
|
String baseId = gameId;
|
|
int counter = 2;
|
|
while (Files.exists(svc.gameJsonPath(gameId))) {
|
|
gameId = baseId + "-" + counter++;
|
|
}
|
|
|
|
// Extract ZIP
|
|
Path tmpDir = Files.createTempDirectory("dostalgia-");
|
|
try {
|
|
Path zipPath = tmpDir.resolve(filename);
|
|
Files.copy(upload.uploadedFile(), zipPath, StandardCopyOption.REPLACE_EXISTING);
|
|
|
|
Path extractDir = tmpDir.resolve("extracted");
|
|
unzip(zipPath, extractDir);
|
|
// If the ZIP has a single root directory, flatten it
|
|
try {
|
|
flattenSingleDir(extractDir);
|
|
} catch (Exception e) {
|
|
LOG.warning("Flatten failed for '" + filename + "': " + e.getMessage()
|
|
+ " — continuing (cd in autoexec handles subdirs)");
|
|
}
|
|
|
|
// Find main executable
|
|
String mainExe = findMainExe(extractDir);
|
|
if (mainExe == null) {
|
|
return Response.status(400)
|
|
.entity(Map.of("error", "No executable (.exe/.com/.bat) found in the archive"))
|
|
.build();
|
|
}
|
|
|
|
// Find setup executable (SETUP.EXE, INSTALL.EXE, CONFIG.EXE, etc.)
|
|
String setupExe = findSetupExe(extractDir);
|
|
|
|
// Create game directory
|
|
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, bundlePath);
|
|
|
|
// Detect platform (DOS vs Windows) by reading the main EXE header
|
|
String platform = detectPlatform(Path.of(mainExe));
|
|
|
|
// 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.
|
|
|
|
// Save metadata
|
|
Game game = new Game(gameId, title);
|
|
game.setBundleFile(bundleFile);
|
|
game.setPlatform(platform);
|
|
// Store the selected executable and the full list for the UI picker
|
|
String relMain = extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/');
|
|
game.setExecutable(relMain);
|
|
game.setExecutables(findAllExecutables(extractDir));
|
|
if (setupExe != null) {
|
|
String setupPlatform = detectPlatform(Path.of(setupExe));
|
|
if (!"windows".equals(setupPlatform)) {
|
|
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
|
|
}
|
|
}
|
|
game.setReady(true);
|
|
svc.save(game);
|
|
|
|
// Auto-populate from IGDB (use provided igdb_id or auto-search)
|
|
if (igdb.isConfigured()) {
|
|
if (igdbId != null) {
|
|
igdb.applyIgdbId(game, igdbId);
|
|
} else {
|
|
igdb.autoScrape(game);
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
return Response.status(201).entity(game).build();
|
|
|
|
} catch (Exception e) {
|
|
LOG.severe("Upload failed for '" + filename + "': " + e.getMessage());
|
|
return Response.serverError()
|
|
.entity(Map.of("error", "Upload failed: " + e.getMessage()))
|
|
.build();
|
|
} finally {
|
|
deleteDir(tmpDir);
|
|
}
|
|
}
|
|
|
|
@POST
|
|
@jakarta.ws.rs.Path("/{id}/cover")
|
|
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
|
public Response uploadCover(@PathParam("id") String id, @RestForm("file") FileUpload upload) throws Exception {
|
|
if (upload == null) {
|
|
return Response.status(400).entity(Map.of("error", "No file provided")).build();
|
|
}
|
|
if (!Files.exists(svc.gameJsonPath(id))) {
|
|
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
|
}
|
|
|
|
String fname = upload.fileName().toLowerCase();
|
|
String ext = "";
|
|
if (fname.endsWith(".jpg") || fname.endsWith(".jpeg")) ext = ".jpg";
|
|
else if (fname.endsWith(".png")) ext = ".png";
|
|
else if (fname.endsWith(".webp")) ext = ".webp";
|
|
else {
|
|
return Response.status(400).entity(Map.of("error", "Only JPG, PNG, or WebP images accepted")).build();
|
|
}
|
|
|
|
Path gameDir = svc.gameDir(id);
|
|
// Remove old covers
|
|
for (String old : new String[]{".jpg", ".jpeg", ".png", ".webp"}) {
|
|
Files.deleteIfExists(gameDir.resolve("cover" + old));
|
|
}
|
|
|
|
Files.copy(upload.uploadedFile(), gameDir.resolve("cover" + ext), StandardCopyOption.REPLACE_EXISTING);
|
|
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<String> findAllExecutables(Path dir) throws IOException {
|
|
List<String> 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<Candidate> 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<Candidate> candidates = new ArrayList<>();
|
|
|
|
List<String> 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<String> 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<String> CD_EXT = java.util.Set.of(
|
|
".iso", ".cue", ".img", ".ccd", ".bin"
|
|
);
|
|
|
|
/** Find mountable CD image files in the extracted game directory.
|
|
* Returns relative paths (with forward slashes) sorted alphabetically. */
|
|
private List<String> findCdImages(Path dir) throws IOException {
|
|
List<String> 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<String> 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, Path bundlePath) throws IOException {
|
|
// Write .jsdos config directly into the extract directory
|
|
Path jsdos = extractDir.resolve(".jsdos");
|
|
Files.createDirectories(jsdos);
|
|
String relExe = extractDir.relativize(Path.of(exePath)).toString();
|
|
// Find CD images for DOSBox imgmount (mountable via D:, E:, …)
|
|
List<String> cdImages = findCdImages(extractDir);
|
|
Files.writeString(jsdos.resolve("dosbox.conf"), buildDosboxConf(relExe, cdImages));
|
|
Files.write(jsdos.resolve("jsdos.json"), buildJsdosJson(relExe));
|
|
|
|
// 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<String>();
|
|
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) {
|
|
// DOS uses \\ as path separator; / is a switch character.
|
|
// If the executable is in a subdirectory, cd into it first.
|
|
var parts = splitDirExe(relExe);
|
|
StringBuilder script = new StringBuilder();
|
|
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");
|
|
}
|
|
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);
|
|
}
|
|
|
|
private String buildDosboxConf(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.
|
|
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("[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 .bin over .cue since
|
|
// .bin files sort first alphabetically and avoid .cue FILE path
|
|
// case-mismatch issues.
|
|
Set<String> 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.add(parentDir)) continue; // already mounted from this dir
|
|
String imgLower = img.toLowerCase();
|
|
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();
|
|
}
|
|
|
|
private String escapeJson(String s) {
|
|
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
|
}
|
|
|
|
/**
|
|
* Detect whether an executable targets DOS or Windows by reading its PE/NE header.
|
|
* <ul>
|
|
* <li>Pure MZ, LE (DOS/4GW extender), LX (OS/2) → "dos"</li>
|
|
* <li>NE (Windows 3.x), PE (Win32) → "windows"</li>
|
|
* <li>Cannot read → null</li>
|
|
* </ul>
|
|
* 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 {
|
|
if (!Files.exists(dir)) return;
|
|
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
|
@Override
|
|
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
|
|
Files.delete(f);
|
|
return FileVisitResult.CONTINUE;
|
|
}
|
|
@Override
|
|
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
|
|
Files.delete(d);
|
|
return FileVisitResult.CONTINUE;
|
|
}
|
|
});
|
|
}
|
|
}
|