- Added 'executables' (full list) and 'executable' (selected) fields to Game - Upload now stores ALL discoverable .exe/.com/.bat files in game metadata - Added setExecutable() to GameService that patches the .jsdos bundle (updates dosbox.conf + jsdos.json inside the ZIP) when changing executable - GameResource PATCH handler delegates executable changes to bundle patching - GameDetail edit mode shows a radio-button picker of all executables - Heuristics remain as first-guess default, user can always override - No re-upload needed to fix wrong executable selection
This commit is contained in:
@@ -23,6 +23,12 @@ public class Game {
|
||||
|
||||
private String setupExe; // relative path to SETUP.EXE (null if none)
|
||||
|
||||
/** Currently selected main executable (relative path inside the bundle, e.g. "FALLOUT.EXE" or "FALLOUT/FALLOUT.EXE"). */
|
||||
private String executable;
|
||||
|
||||
/** All discoverable executables in the bundle (for the UI picker). */
|
||||
private List<String> executables;
|
||||
|
||||
/** Platform type: "dos" for pure DOS, "windows" for Windows 3.x/9x+ (requires emulated Windows), null if unknown. */
|
||||
private String platform;
|
||||
|
||||
@@ -85,6 +91,12 @@ public class Game {
|
||||
public String getSetupExe() { return setupExe; }
|
||||
public void setSetupExe(String setupExe) { this.setupExe = setupExe; }
|
||||
|
||||
public String getExecutable() { return executable; }
|
||||
public void setExecutable(String executable) { this.executable = executable; }
|
||||
|
||||
public List<String> getExecutables() { return executables; }
|
||||
public void setExecutables(List<String> executables) { this.executables = executables; }
|
||||
|
||||
/** Returns true if a SETUP.EXE (or variant) was detected in the game archive. */
|
||||
public boolean isHasSetup() { return setupExe != null && !setupExe.isBlank(); }
|
||||
|
||||
|
||||
@@ -84,7 +84,17 @@ public class GameResource {
|
||||
if (updates.containsKey("rating")) game.setRating(asDouble(updates.get("rating")));
|
||||
if (updates.containsKey("platform")) game.setPlatform((String) updates.get("platform"));
|
||||
|
||||
svc.save(game);
|
||||
// Changing the executable requires patching the .jsdos bundle
|
||||
if (updates.containsKey("executable")) {
|
||||
String newExe = (String) updates.get("executable");
|
||||
if (newExe != null && !newExe.isBlank()) {
|
||||
svc.setExecutable(id, newExe);
|
||||
}
|
||||
// Reload after bundle patch to get updated metadata
|
||||
game = svc.load(id);
|
||||
} else {
|
||||
svc.save(game);
|
||||
}
|
||||
return Response.ok(game).build();
|
||||
} catch (NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
|
||||
@@ -146,5 +146,134 @@ public class GameService {
|
||||
return Files.exists(p);
|
||||
}
|
||||
|
||||
/** Patch the .jsdos bundle to use a different main executable. */
|
||||
public void setExecutable(String id, String executable) throws IOException {
|
||||
Game game = load(id);
|
||||
String bundleFile = game.getBundleFile();
|
||||
if (bundleFile == null) {
|
||||
throw new IOException("Game has no bundle file");
|
||||
}
|
||||
|
||||
Path bundlePath = gamesDir.resolve(bundleFile);
|
||||
if (!Files.exists(bundlePath)) {
|
||||
throw new NoSuchFileException("Bundle not found: " + bundleFile);
|
||||
}
|
||||
|
||||
// Build new config entries
|
||||
byte[] newDosboxConf = buildDosboxConfBytes(executable);
|
||||
byte[] newJsdosJson = buildJsdosJsonBytes(executable);
|
||||
|
||||
// Patch the ZIP: read old, write new with modified config entries
|
||||
Path tmpPath = bundlePath.resolveSibling(bundleFile + ".tmp");
|
||||
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();
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
Files.move(tmpPath, bundlePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
// Update metadata
|
||||
game.setExecutable(executable);
|
||||
game.setPlatform(detectPlatformSimple(executable, bundlePath));
|
||||
save(game);
|
||||
}
|
||||
|
||||
/** Build a dosbox.conf for a given executable path. */
|
||||
private byte[] buildDosboxConfBytes(String relExe) {
|
||||
var parts = splitExePath(relExe);
|
||||
String dir = parts[0];
|
||||
String exe = parts[1];
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("[sdl]\nautolock=true\nusescancodes=true\noutput=surface\n\n");
|
||||
sb.append("[cpu]\ncore=auto\ncycles=auto\n\n");
|
||||
sb.append("[mixer]\nnosound=false\nrate=44100\n\n");
|
||||
sb.append("[sblaster]\nsbtype=sb16\nirq=7\ndma=1\nhdma=5\n\n");
|
||||
sb.append("[dos]\nxms=true\nems=true\numb=true\n\n");
|
||||
sb.append("[autoexec]\n@echo off\nmount c .\nc:\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. */
|
||||
private byte[] buildJsdosJsonBytes(String relExe) {
|
||||
var parts = splitExePath(relExe);
|
||||
StringBuilder script = new StringBuilder();
|
||||
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
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (entry.getName().equals(relExe.replace('\\', '/'))) {
|
||||
byte[] buf = new byte[0x80];
|
||||
int read = zis.readNBytes(buf, 0, buf.length);
|
||||
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";
|
||||
byte sig1 = buf[peOffset], sig2 = buf[peOffset + 1];
|
||||
if ((sig1 == 0x50 && sig2 == 0x45) || (sig1 == 0x4E && sig2 == 0x45))
|
||||
return "windows";
|
||||
return "dos";
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Path getGamesDir() { return gamesDir; }
|
||||
}
|
||||
|
||||
@@ -120,6 +120,10 @@ public class UploadResource {
|
||||
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 (hasSetupBundle) {
|
||||
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
|
||||
}
|
||||
@@ -242,6 +246,23 @@ public class UploadResource {
|
||||
|
||||
// ─── 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<>();
|
||||
|
||||
Reference in New Issue
Block a user