Add platform auto-detection and UI flagging for Windows games
Build & Deploy / build-and-deploy (push) Successful in 49s
Build & Deploy / build-and-deploy (push) Successful in 49s
- Game.java: add 'platform' field ('dos'/'windows') + 'isPlayable' helper
- UploadResource.java: detect EXE type by reading PE/NE header during upload
- GameResource.java: allow PATCH to update platform
- GameCard.svelte: show 🪟 Win badge for Windows games (blue style)
- GameDetail.svelte: show platform badge, disable Play for Windows with
'Unplayable' button; add platform dropdown to edit form
- Play.svelte: show blocking overlay for Windows games with 'Try anyway'
option for users who want to attempt it
This commit is contained in:
@@ -29,7 +29,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if game.ready}
|
{#if game.ready}
|
||||||
<div class="badge">Ready</div>
|
<div class="badge" class:badge-windows={game.platform === 'windows'}>
|
||||||
|
{#if game.platform === 'windows'}
|
||||||
|
🪟 Win
|
||||||
|
{:else}
|
||||||
|
Ready
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -111,4 +117,8 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
.badge-windows {
|
||||||
|
background: #1a3a5c;
|
||||||
|
color: #6ab0ff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
let editDeveloper = $state("");
|
let editDeveloper = $state("");
|
||||||
let editPublisher = $state("");
|
let editPublisher = $state("");
|
||||||
let editDescription = $state("");
|
let editDescription = $state("");
|
||||||
|
let editPlatform = $state("");
|
||||||
|
|
||||||
// IGDB scrape state
|
// IGDB scrape state
|
||||||
let scraping = $state(false);
|
let scraping = $state(false);
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
editDeveloper = game.developer || "";
|
editDeveloper = game.developer || "";
|
||||||
editPublisher = game.publisher || "";
|
editPublisher = game.publisher || "";
|
||||||
editDescription = game.description || "";
|
editDescription = game.description || "";
|
||||||
|
editPlatform = game.platform || "";
|
||||||
editing = true;
|
editing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@
|
|||||||
developer: editDeveloper || undefined,
|
developer: editDeveloper || undefined,
|
||||||
publisher: editPublisher || undefined,
|
publisher: editPublisher || undefined,
|
||||||
description: editDescription || undefined,
|
description: editDescription || undefined,
|
||||||
|
platform: editPlatform || undefined,
|
||||||
});
|
});
|
||||||
editing = false;
|
editing = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -193,6 +196,13 @@
|
|||||||
<input type="text" bind:value={editPublisher} placeholder="Publisher" style="flex:1" />
|
<input type="text" bind:value={editPublisher} placeholder="Publisher" style="flex:1" />
|
||||||
</div>
|
</div>
|
||||||
<textarea bind:value={editDescription} placeholder="Description" rows="4"></textarea>
|
<textarea bind:value={editDescription} placeholder="Description" rows="4"></textarea>
|
||||||
|
<div class="edit-row">
|
||||||
|
<select bind:value={editPlatform} style="flex:1">
|
||||||
|
<option value="">Auto (unknown)</option>
|
||||||
|
<option value="dos">DOS</option>
|
||||||
|
<option value="windows">Windows</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<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"}
|
||||||
@@ -201,13 +211,26 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<h1>{game.title}</h1>
|
<h1>{game.title}</h1>
|
||||||
|
{#if game.platform}
|
||||||
|
<div class="platform-badge" class:platform-win={game.platform === 'windows'}>
|
||||||
|
{#if game.platform === 'windows'}
|
||||||
|
🪟 Requires Windows 3.1 — may not work in browser
|
||||||
|
{:else}
|
||||||
|
💾 DOS
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if game.description}
|
{#if game.description}
|
||||||
<p class="description">{game.description}</p>
|
<p class="description">{game.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if game.ready}
|
{#if game.ready && (game.platform !== 'windows')}
|
||||||
<button class="btn btn-primary" onclick={handlePlay}>▶ Play</button>
|
<button class="btn btn-primary" onclick={handlePlay}>▶ Play</button>
|
||||||
|
{:else if game.ready && game.platform === 'windows'}
|
||||||
|
<button class="btn btn-primary btn-disabled" disabled title="Windows games need Windows 3.1 emulation (not yet supported)">
|
||||||
|
▶ Unplayable
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if game.has_setup}
|
{#if game.has_setup}
|
||||||
<button class="btn btn-setup" onclick={handleSetup}>🛠 Setup</button>
|
<button class="btn btn-setup" onclick={handleSetup}>🛠 Setup</button>
|
||||||
@@ -347,6 +370,20 @@
|
|||||||
.cover-img { width: 100%; border-radius: var(--radius); }
|
.cover-img { width: 100%; border-radius: var(--radius); }
|
||||||
.cover-placeholder { aspect-ratio: 3/4; background: var(--surface); display: flex; align-items: center; justify-content: center; font-size: 4rem; border-radius: var(--radius); }
|
.cover-placeholder { aspect-ratio: 3/4; background: var(--surface); display: flex; align-items: center; justify-content: center; font-size: 4rem; border-radius: var(--radius); }
|
||||||
.info-section h1 { color: var(--phosphor); font-family: var(--font-mono); margin-bottom: 8px; word-break: break-word; }
|
.info-section h1 { color: var(--phosphor); font-family: var(--font-mono); margin-bottom: 8px; word-break: break-word; }
|
||||||
|
.platform-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--phosphor-dark);
|
||||||
|
color: var(--phosphor);
|
||||||
|
}
|
||||||
|
.platform-badge.platform-win {
|
||||||
|
background: #1a3a5c;
|
||||||
|
color: #6ab0ff;
|
||||||
|
}
|
||||||
.meta { display: flex; gap: 8px; margin-bottom: 8px; }
|
.meta { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||||
.meta-item { background: var(--surface-hover); color: var(--phosphor-dim); padding: 4px 12px; border-radius: 12px; font-size: 0.85rem; }
|
.meta-item { background: var(--surface-hover); color: var(--phosphor-dim); padding: 4px 12px; border-radius: 12px; font-size: 0.85rem; }
|
||||||
.dev { color: var(--text-dim); margin-bottom: 16px; }
|
.dev { color: var(--text-dim); margin-bottom: 16px; }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
let error = $state("");
|
let error = $state("");
|
||||||
let booting = $state(false);
|
let booting = $state(false);
|
||||||
let running = $state(false);
|
let running = $state(false);
|
||||||
|
let unsupported = $state(false);
|
||||||
let started = false;
|
let started = false;
|
||||||
|
|
||||||
let dosContainer;
|
let dosContainer;
|
||||||
@@ -23,6 +24,10 @@
|
|||||||
isSetup = window.location.hash.includes("setup=1");
|
isSetup = window.location.hash.includes("setup=1");
|
||||||
try {
|
try {
|
||||||
game = await getGame(id);
|
game = await getGame(id);
|
||||||
|
// Windows games can't run in pure DOSBox — flag it
|
||||||
|
if (game.platform === 'windows') {
|
||||||
|
unsupported = true;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = "Game not found";
|
error = "Game not found";
|
||||||
}
|
}
|
||||||
@@ -76,6 +81,11 @@
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTryAnyway() {
|
||||||
|
unsupported = false;
|
||||||
|
startEmulator();
|
||||||
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
push(`/game/${id}`);
|
push(`/game/${id}`);
|
||||||
}
|
}
|
||||||
@@ -84,7 +94,7 @@
|
|||||||
|
|
||||||
// Auto-start when both game data and the container exist
|
// Auto-start when both game data and the container exist
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (game?.ready && dosContainer && !started) {
|
if (game?.ready && dosContainer && !started && !unsupported) {
|
||||||
startEmulator();
|
startEmulator();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -121,6 +131,19 @@
|
|||||||
<div class="error-box">{error}</div>
|
<div class="error-box">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Unsupported platform warning -->
|
||||||
|
{#if unsupported}
|
||||||
|
<div class="overlay">
|
||||||
|
<div class="overlay-icon">🪟</div>
|
||||||
|
<h2>Windows game</h2>
|
||||||
|
<p><strong>{game.title}</strong> is a Windows application and won't run in the browser DOS emulator without Windows 3.1 installed.</p>
|
||||||
|
<div class="unsupported-actions">
|
||||||
|
<button class="btn btn-secondary" onclick={goBack}>← Back</button>
|
||||||
|
<button class="btn" onclick={handleTryAnyway}>Try anyway</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Loading / booting overlay (covers the canvas while loading) -->
|
<!-- Loading / booting overlay (covers the canvas while loading) -->
|
||||||
{#if !running}
|
{#if !running}
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
@@ -256,4 +279,9 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
.unsupported-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ 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)
|
||||||
|
|
||||||
|
/** Platform type: "dos" for pure DOS, "windows" for Windows 3.x/9x+ (requires emulated Windows), null if unknown. */
|
||||||
|
private String platform;
|
||||||
|
|
||||||
private Integer igdbId;
|
private Integer igdbId;
|
||||||
private String igdbCoverId;
|
private String igdbCoverId;
|
||||||
|
|
||||||
@@ -85,6 +88,14 @@ public class Game {
|
|||||||
/** 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(); }
|
||||||
|
|
||||||
|
public String getPlatform() { return platform; }
|
||||||
|
public void setPlatform(String platform) { this.platform = platform; }
|
||||||
|
|
||||||
|
/** Returns true if the game is a pure DOS executable that can run natively in DOSBox. */
|
||||||
|
public boolean isPlayable() {
|
||||||
|
return ready && (platform == null || "dos".equals(platform));
|
||||||
|
}
|
||||||
|
|
||||||
public Integer getIgdbId() { return igdbId; }
|
public Integer getIgdbId() { return igdbId; }
|
||||||
public void setIgdbId(Integer igdbId) { this.igdbId = igdbId; }
|
public void setIgdbId(Integer igdbId) { this.igdbId = igdbId; }
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ public class GameResource {
|
|||||||
if (updates.containsKey("publisher")) game.setPublisher((String) updates.get("publisher"));
|
if (updates.containsKey("publisher")) game.setPublisher((String) updates.get("publisher"));
|
||||||
if (updates.containsKey("description")) game.setDescription((String) updates.get("description"));
|
if (updates.containsKey("description")) game.setDescription((String) updates.get("description"));
|
||||||
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"));
|
||||||
|
|
||||||
svc.save(game);
|
svc.save(game);
|
||||||
return Response.ok(game).build();
|
return Response.ok(game).build();
|
||||||
|
|||||||
@@ -91,9 +91,13 @@ public class UploadResource {
|
|||||||
createBundle(extractDir, setupExe, setupBundlePath);
|
createBundle(extractDir, setupExe, setupBundlePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect platform (DOS vs Windows) by reading the main EXE header
|
||||||
|
String platform = detectPlatform(Path.of(mainExe));
|
||||||
|
|
||||||
// Save metadata
|
// Save metadata
|
||||||
Game game = new Game(gameId, title);
|
Game game = new Game(gameId, title);
|
||||||
game.setBundleFile(bundleFile);
|
game.setBundleFile(bundleFile);
|
||||||
|
game.setPlatform(platform);
|
||||||
game.setSetupExe(setupExe != null ? extractDir.relativize(Path.of(setupExe)).toString() : null);
|
game.setSetupExe(setupExe != null ? extractDir.relativize(Path.of(setupExe)).toString() : null);
|
||||||
game.setReady(true);
|
game.setReady(true);
|
||||||
svc.save(game);
|
svc.save(game);
|
||||||
@@ -382,6 +386,64 @@ public class UploadResource {
|
|||||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether an executable targets DOS or Windows by reading its PE/NE header.
|
||||||
|
* <ul>
|
||||||
|
* <li>Pure MZ (no NE/PE/LE) → "dos"</li>
|
||||||
|
* <li>NE (Windows 3.x), PE (Win32), LE (VxD) → "windows"</li>
|
||||||
|
* <li>Cannot read → null</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
|
||||||
|
// If signature is beyond our buffer, read it
|
||||||
|
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 (VxD, OS/2)
|
||||||
|
// LX = Extended Linear Executable (OS/2 2.x)
|
||||||
|
if ((sig1 == 0x50 && sig2 == 0x45) // PE
|
||||||
|
|| (sig1 == 0x4E && sig2 == 0x45) // NE
|
||||||
|
|| (sig1 == 0x4C && sig2 == 0x45) // LE
|
||||||
|
|| (sig1 == 0x4C && sig2 == 0x58)) // LX
|
||||||
|
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 ──────────────────────────────────────────
|
// ─── File Utilities ──────────────────────────────────────────
|
||||||
|
|
||||||
private void deleteDir(Path dir) throws IOException {
|
private void deleteDir(Path dir) throws IOException {
|
||||||
|
|||||||
Reference in New Issue
Block a user