diff --git a/frontend/src/lib/GameCard.svelte b/frontend/src/lib/GameCard.svelte
index 94f4728..9a72fee 100644
--- a/frontend/src/lib/GameCard.svelte
+++ b/frontend/src/lib/GameCard.svelte
@@ -29,7 +29,13 @@
{/if}
{#if game.ready}
-
+ {#if game.platform === 'windows'}
+ 🪟 Win
+ {:else}
+ Ready
+ {/if}
+
{/if}
@@ -111,4 +117,8 @@
font-weight: 600;
letter-spacing: 0.05em;
}
+ .badge-windows {
+ background: #1a3a5c;
+ color: #6ab0ff;
+ }
\ No newline at end of file
diff --git a/frontend/src/pages/GameDetail.svelte b/frontend/src/pages/GameDetail.svelte
index d4c9c99..6cd0910 100644
--- a/frontend/src/pages/GameDetail.svelte
+++ b/frontend/src/pages/GameDetail.svelte
@@ -16,6 +16,7 @@
let editDeveloper = $state("");
let editPublisher = $state("");
let editDescription = $state("");
+ let editPlatform = $state("");
// IGDB scrape state
let scraping = $state(false);
@@ -63,6 +64,7 @@
editDeveloper = game.developer || "";
editPublisher = game.publisher || "";
editDescription = game.description || "";
+ editPlatform = game.platform || "";
editing = true;
}
@@ -76,6 +78,7 @@
developer: editDeveloper || undefined,
publisher: editPublisher || undefined,
description: editDescription || undefined,
+ platform: editPlatform || undefined,
});
editing = false;
} catch (e) {
@@ -193,6 +196,13 @@
+ {#if game.platform === 'windows'}
+ 🪟 Requires Windows 3.1 — may not work in browser
+ {:else}
+ 💾 DOS
+ {/if}
+
+ {/if}
{#if game.description}
- {#if game.ready}
+ {#if game.ready && (game.platform !== 'windows')}
+ {:else if game.ready && game.platform === 'windows'}
+
{/if}
{#if game.has_setup}
@@ -347,6 +370,20 @@
.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); }
.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-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; }
diff --git a/frontend/src/pages/Play.svelte b/frontend/src/pages/Play.svelte
index 4cb9ef4..ae2117a 100644
--- a/frontend/src/pages/Play.svelte
+++ b/frontend/src/pages/Play.svelte
@@ -12,6 +12,7 @@
let error = $state("");
let booting = $state(false);
let running = $state(false);
+ let unsupported = $state(false);
let started = false;
let dosContainer;
@@ -23,6 +24,10 @@
isSetup = window.location.hash.includes("setup=1");
try {
game = await getGame(id);
+ // Windows games can't run in pure DOSBox — flag it
+ if (game.platform === 'windows') {
+ unsupported = true;
+ }
} catch (e) {
error = "Game not found";
}
@@ -76,6 +81,11 @@
window.location.reload();
}
+ function handleTryAnyway() {
+ unsupported = false;
+ startEmulator();
+ }
+
function goBack() {
push(`/game/${id}`);
}
@@ -84,7 +94,7 @@
// Auto-start when both game data and the container exist
$effect(() => {
- if (game?.ready && dosContainer && !started) {
+ if (game?.ready && dosContainer && !started && !unsupported) {
startEmulator();
}
});
@@ -121,6 +131,19 @@
{error}
{/if}
+
+ {#if unsupported}
+
+
🪟
+
Windows game
+
{game.title} is a Windows application and won't run in the browser DOS emulator without Windows 3.1 installed.
+
+
+
+
+
+ {/if}
+
{#if !running}
@@ -256,4 +279,9 @@
border-radius: var(--radius);
z-index: 10;
}
+ .unsupported-actions {
+ display: flex;
+ gap: 12px;
+ margin-top: 20px;
+ }
diff --git a/src/main/java/com/dostalgia/Game.java b/src/main/java/com/dostalgia/Game.java
index 7338832..4b9149e 100644
--- a/src/main/java/com/dostalgia/Game.java
+++ b/src/main/java/com/dostalgia/Game.java
@@ -23,6 +23,9 @@ public class Game {
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 String igdbCoverId;
@@ -85,6 +88,14 @@ public class Game {
/** Returns true if a SETUP.EXE (or variant) was detected in the game archive. */
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 void setIgdbId(Integer igdbId) { this.igdbId = igdbId; }
diff --git a/src/main/java/com/dostalgia/GameResource.java b/src/main/java/com/dostalgia/GameResource.java
index 60215ab..6658ab2 100644
--- a/src/main/java/com/dostalgia/GameResource.java
+++ b/src/main/java/com/dostalgia/GameResource.java
@@ -82,6 +82,7 @@ public class GameResource {
if (updates.containsKey("publisher")) game.setPublisher((String) updates.get("publisher"));
if (updates.containsKey("description")) game.setDescription((String) updates.get("description"));
if (updates.containsKey("rating")) game.setRating(asDouble(updates.get("rating")));
+ if (updates.containsKey("platform")) game.setPlatform((String) updates.get("platform"));
svc.save(game);
return Response.ok(game).build();
diff --git a/src/main/java/com/dostalgia/UploadResource.java b/src/main/java/com/dostalgia/UploadResource.java
index 025654e..a66647a 100644
--- a/src/main/java/com/dostalgia/UploadResource.java
+++ b/src/main/java/com/dostalgia/UploadResource.java
@@ -91,9 +91,13 @@ public class UploadResource {
createBundle(extractDir, setupExe, setupBundlePath);
}
+ // Detect platform (DOS vs Windows) by reading the main EXE header
+ String platform = detectPlatform(Path.of(mainExe));
+
// Save metadata
Game game = new Game(gameId, title);
game.setBundleFile(bundleFile);
+ game.setPlatform(platform);
game.setSetupExe(setupExe != null ? extractDir.relativize(Path.of(setupExe)).toString() : null);
game.setReady(true);
svc.save(game);
@@ -382,6 +386,64 @@ public class UploadResource {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
+ /**
+ * Detect whether an executable targets DOS or Windows by reading its PE/NE header.
+ *
+ * - Pure MZ (no NE/PE/LE) → "dos"
+ * - NE (Windows 3.x), PE (Win32), LE (VxD) → "windows"
+ * - Cannot read → null
+ *
+ */
+ 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 ──────────────────────────────────────────
private void deleteDir(Path dir) throws IOException {