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} -
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 @@ +
+ +
{:else}

{game.title}

+ {#if game.platform} +
+ {#if game.platform === 'windows'} + 🪟 Requires Windows 3.1 — may not work in browser + {:else} + 💾 DOS + {/if} +
+ {/if} {#if game.description}

{game.description}

{/if}
- {#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. + * + */ + 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 {