- 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:
@@ -17,6 +17,7 @@
|
|||||||
let editPublisher = $state("");
|
let editPublisher = $state("");
|
||||||
let editDescription = $state("");
|
let editDescription = $state("");
|
||||||
let editPlatform = $state("");
|
let editPlatform = $state("");
|
||||||
|
let editExecutable = $state("");
|
||||||
|
|
||||||
// IGDB scrape state
|
// IGDB scrape state
|
||||||
let scraping = $state(false);
|
let scraping = $state(false);
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
editPublisher = game.publisher || "";
|
editPublisher = game.publisher || "";
|
||||||
editDescription = game.description || "";
|
editDescription = game.description || "";
|
||||||
editPlatform = game.platform || "";
|
editPlatform = game.platform || "";
|
||||||
|
editExecutable = game.executable || "";
|
||||||
editing = true;
|
editing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@
|
|||||||
publisher: editPublisher || undefined,
|
publisher: editPublisher || undefined,
|
||||||
description: editDescription || undefined,
|
description: editDescription || undefined,
|
||||||
platform: editPlatform || undefined,
|
platform: editPlatform || undefined,
|
||||||
|
executable: editExecutable || undefined,
|
||||||
});
|
});
|
||||||
editing = false;
|
editing = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -207,6 +210,25 @@
|
|||||||
<option value="windows">Windows</option>
|
<option value="windows">Windows</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{#if game.executables && game.executables.length > 0}
|
||||||
|
<div class="edit-exec-section">
|
||||||
|
<span class="edit-exec-label">Main executable:</span>
|
||||||
|
<div class="edit-exec-list">
|
||||||
|
{#each game.executables as exe}
|
||||||
|
<label class="edit-exec-option" class:selected={editExecutable === exe}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="executable"
|
||||||
|
value={exe}
|
||||||
|
checked={editExecutable === exe}
|
||||||
|
onchange={() => editExecutable = exe}
|
||||||
|
/>
|
||||||
|
<span class="edit-exec-path">{exe}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="edit-actions">
|
<div class="edit-actions">
|
||||||
<button class="btn btn-primary" onclick={saveEdit} disabled={saving}>
|
<button class="btn btn-primary" onclick={saveEdit} disabled={saving}>
|
||||||
{saving ? "Saving..." : "Save"}
|
{saving ? "Saving..." : "Save"}
|
||||||
@@ -601,6 +623,62 @@
|
|||||||
color: #66ff66;
|
color: #66ff66;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Executable Picker ────────────────────── */
|
||||||
|
.edit-exec-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.edit-exec-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.edit-exec-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.edit-exec-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.edit-exec-option:hover {
|
||||||
|
border-color: var(--phosphor-dim);
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.edit-exec-option.selected {
|
||||||
|
border-color: var(--phosphor);
|
||||||
|
background: var(--phosphor-burn);
|
||||||
|
}
|
||||||
|
.edit-exec-option input[type="radio"] {
|
||||||
|
accent-color: var(--phosphor);
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.edit-exec-path {
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.edit-exec-option.selected .edit-exec-path {
|
||||||
|
color: var(--phosphor-glow);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Responsive ────────────────────────────────── */
|
/* ─── Responsive ────────────────────────────────── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.detail { padding: 16px 0; }
|
.detail { padding: 16px 0; }
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ public class Game {
|
|||||||
|
|
||||||
private String setupExe; // relative path to SETUP.EXE (null if none)
|
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. */
|
/** Platform type: "dos" for pure DOS, "windows" for Windows 3.x/9x+ (requires emulated Windows), null if unknown. */
|
||||||
private String platform;
|
private String platform;
|
||||||
|
|
||||||
@@ -85,6 +91,12 @@ public class Game {
|
|||||||
public String getSetupExe() { return setupExe; }
|
public String getSetupExe() { return setupExe; }
|
||||||
public void setSetupExe(String setupExe) { this.setupExe = 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. */
|
/** Returns true if a SETUP.EXE (or variant) was detected in the game archive. */
|
||||||
public boolean isHasSetup() { return setupExe != null && !setupExe.isBlank(); }
|
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("rating")) game.setRating(asDouble(updates.get("rating")));
|
||||||
if (updates.containsKey("platform")) game.setPlatform((String) updates.get("platform"));
|
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();
|
return Response.ok(game).build();
|
||||||
} catch (NoSuchFileException e) {
|
} catch (NoSuchFileException e) {
|
||||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||||
|
|||||||
@@ -146,5 +146,134 @@ public class GameService {
|
|||||||
return Files.exists(p);
|
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; }
|
public Path getGamesDir() { return gamesDir; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ public class UploadResource {
|
|||||||
Game game = new Game(gameId, title);
|
Game game = new Game(gameId, title);
|
||||||
game.setBundleFile(bundleFile);
|
game.setBundleFile(bundleFile);
|
||||||
game.setPlatform(platform);
|
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) {
|
if (hasSetupBundle) {
|
||||||
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
|
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
|
||||||
}
|
}
|
||||||
@@ -242,6 +246,23 @@ public class UploadResource {
|
|||||||
|
|
||||||
// ─── Executable Detection ────────────────────────────────────
|
// ─── 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 {
|
private String findMainExe(Path dir) throws IOException {
|
||||||
record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {}
|
record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {}
|
||||||
List<Candidate> candidates = new ArrayList<>();
|
List<Candidate> candidates = new ArrayList<>();
|
||||||
|
|||||||
Reference in New Issue
Block a user