@muhammedaksam/opentui-doom 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,345 @@
1
+ //
2
+ // Copyright(C) 1993-1996 Id Software, Inc.
3
+ // Copyright(C) 2005-2014 Simon Howard
4
+ //
5
+ // This program is free software; you can redistribute it and/or
6
+ // modify it under the terms of the GNU General Public License
7
+ // as published by the Free Software Foundation; either version 2
8
+ // of the License, or (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU General Public License for more details.
14
+ //
15
+ // DESCRIPTION:
16
+ // OpenTUI-modified i_system.c - System-specific interface functions
17
+ // Modified to support proper exit handling in WebAssembly builds.
18
+ //
19
+
20
+ #include <stdarg.h>
21
+ #include <stdio.h>
22
+ #include <stdlib.h>
23
+ #include <string.h>
24
+ #include <unistd.h>
25
+
26
+ // Include DOOM headers BEFORE emscripten to avoid true/false macro conflicts
27
+ #include "config.h"
28
+ #include "deh_str.h"
29
+ #include "doomtype.h"
30
+ #include "i_joystick.h"
31
+ #include "i_sound.h"
32
+ #include "i_system.h"
33
+ #include "i_timer.h"
34
+ #include "i_video.h"
35
+ #include "m_argv.h"
36
+ #include "m_config.h"
37
+ #include "m_misc.h"
38
+ #include "w_wad.h"
39
+ #include "z_zone.h"
40
+
41
+ // Include emscripten AFTER doom headers to avoid true/false conflicts
42
+ #include <emscripten.h>
43
+
44
+ #define DEFAULT_RAM 6 /* MiB */
45
+ #define MIN_RAM 6 /* MiB */
46
+
47
+ typedef struct atexit_listentry_s atexit_listentry_t;
48
+
49
+ struct atexit_listentry_s {
50
+ atexit_func_t func;
51
+ boolean run_on_error;
52
+ atexit_listentry_t *next;
53
+ };
54
+
55
+ static atexit_listentry_t *exit_funcs = NULL;
56
+
57
+ void I_AtExit(atexit_func_t func, boolean run_on_error) {
58
+ atexit_listentry_t *entry;
59
+
60
+ entry = malloc(sizeof(*entry));
61
+
62
+ entry->func = func;
63
+ entry->run_on_error = run_on_error;
64
+ entry->next = exit_funcs;
65
+ exit_funcs = entry;
66
+ }
67
+
68
+ // Tactile feedback function, probably used for the Logitech Cyberman
69
+
70
+ void I_Tactile(int on, int off, int total) {}
71
+
72
+ // Zone memory auto-allocation function that allocates the zone size
73
+ // by trying progressively smaller zone sizes until one is found that
74
+ // works.
75
+
76
+ static byte *AutoAllocMemory(int *size, int default_ram, int min_ram) {
77
+ byte *zonemem;
78
+
79
+ // Allocate the zone memory. This loop tries progressively smaller
80
+ // zone sizes until a size is found that can be allocated.
81
+ // If we used the -mb command line parameter, only the parameter
82
+ // provided is accepted.
83
+
84
+ zonemem = NULL;
85
+
86
+ while (zonemem == NULL) {
87
+ // We need a reasonable minimum amount of RAM to start.
88
+
89
+ if (default_ram < min_ram) {
90
+ I_Error("Unable to allocate %i MiB of RAM for zone", default_ram);
91
+ }
92
+
93
+ // Try to allocate the zone memory.
94
+
95
+ *size = default_ram * 1024 * 1024;
96
+
97
+ zonemem = malloc(*size);
98
+
99
+ // Failed to allocate? Reduce zone size until we reach a size
100
+ // that is acceptable.
101
+
102
+ if (zonemem == NULL) {
103
+ default_ram -= 1;
104
+ }
105
+ }
106
+
107
+ return zonemem;
108
+ }
109
+
110
+ byte *I_ZoneBase(int *size) {
111
+ byte *zonemem;
112
+ int min_ram, default_ram;
113
+ int p;
114
+
115
+ //!
116
+ // @arg <mb>
117
+ //
118
+ // Specify the heap size, in MiB (default 16).
119
+ //
120
+
121
+ p = M_CheckParmWithArgs("-mb", 1);
122
+
123
+ if (p > 0) {
124
+ default_ram = atoi(myargv[p + 1]);
125
+ min_ram = default_ram;
126
+ } else {
127
+ default_ram = DEFAULT_RAM;
128
+ min_ram = MIN_RAM;
129
+ }
130
+
131
+ zonemem = AutoAllocMemory(size, default_ram, min_ram);
132
+
133
+ printf("zone memory: %p, %x allocated for zone\n", zonemem, *size);
134
+
135
+ return zonemem;
136
+ }
137
+
138
+ void I_PrintBanner(char *msg) {
139
+ int i;
140
+ int spaces = 35 - (strlen(msg) / 2);
141
+
142
+ for (i = 0; i < spaces; ++i)
143
+ putchar(' ');
144
+
145
+ puts(msg);
146
+ }
147
+
148
+ void I_PrintDivider(void) {
149
+ int i;
150
+
151
+ for (i = 0; i < 75; ++i) {
152
+ putchar('=');
153
+ }
154
+
155
+ putchar('\n');
156
+ }
157
+
158
+ void I_PrintStartupBanner(char *gamedescription) {
159
+ I_PrintDivider();
160
+ I_PrintBanner(gamedescription);
161
+ I_PrintDivider();
162
+
163
+ printf(" " PACKAGE_NAME
164
+ " is free software, covered by the GNU General Public\n"
165
+ " License. There is NO warranty; not even for MERCHANTABILITY or "
166
+ "FITNESS\n"
167
+ " FOR A PARTICULAR PURPOSE. You are welcome to change and distribute\n"
168
+ " copies under certain conditions. See the source for more "
169
+ "information.\n");
170
+
171
+ I_PrintDivider();
172
+ }
173
+
174
+ //
175
+ // I_ConsoleStdout
176
+ //
177
+ // Returns true if stdout is a real console, false if it is a file
178
+ //
179
+
180
+ boolean I_ConsoleStdout(void) {
181
+ // In WASM environment, always return 0
182
+ return 0;
183
+ }
184
+
185
+ //
186
+ // I_Quit
187
+ //
188
+ // Modified for OpenTUI: Call JavaScript to properly exit the application
189
+ //
190
+
191
+ void I_Quit(void) {
192
+ atexit_listentry_t *entry;
193
+
194
+ // Signal JavaScript to exit the application FIRST
195
+ // This must happen before atexit handlers because they may prevent
196
+ // this code from being reached (e.g., by calling exit() or longjmp)
197
+ EM_ASM({
198
+ if (typeof Module.quitGame === 'function') {
199
+ Module.quitGame();
200
+ }
201
+ });
202
+
203
+ // Run through all exit functions
204
+ entry = exit_funcs;
205
+
206
+ while (entry != NULL) {
207
+ entry->func();
208
+ entry = entry->next;
209
+ }
210
+ }
211
+
212
+ //
213
+ // I_Error
214
+ //
215
+
216
+ static boolean already_quitting = false;
217
+
218
+ void I_Error(char *error, ...) {
219
+ char msgbuf[512];
220
+ va_list argptr;
221
+ atexit_listentry_t *entry;
222
+
223
+ if (already_quitting) {
224
+ fprintf(stderr, "Warning: recursive call to I_Error detected.\n");
225
+ return;
226
+ } else {
227
+ already_quitting = true;
228
+ }
229
+
230
+ // Message first.
231
+ va_start(argptr, error);
232
+ vfprintf(stderr, error, argptr);
233
+ fprintf(stderr, "\n\n");
234
+ va_end(argptr);
235
+ fflush(stderr);
236
+
237
+ // Write a copy of the message into buffer.
238
+ va_start(argptr, error);
239
+ memset(msgbuf, 0, sizeof(msgbuf));
240
+ M_vsnprintf(msgbuf, sizeof(msgbuf), error, argptr);
241
+ va_end(argptr);
242
+
243
+ // Shutdown. Here might be other errors.
244
+ entry = exit_funcs;
245
+
246
+ while (entry != NULL) {
247
+ if (entry->run_on_error) {
248
+ entry->func();
249
+ }
250
+
251
+ entry = entry->next;
252
+ }
253
+
254
+ // In WASM, we can't really exit, but we signal an error
255
+ EM_ASM(
256
+ {
257
+ var msg = UTF8ToString($0);
258
+ console.error("DOOM Error: " + msg);
259
+ if (typeof Module.quitGame === 'function') {
260
+ Module.quitGame();
261
+ }
262
+ },
263
+ msgbuf);
264
+ }
265
+
266
+ //
267
+ // Read Access Violation emulation.
268
+ //
269
+ // From PrBoom+, by entryway.
270
+ //
271
+
272
+ #define DOS_MEM_DUMP_SIZE 10
273
+
274
+ static const unsigned char mem_dump_dos622[DOS_MEM_DUMP_SIZE] = {
275
+ 0x57, 0x92, 0x19, 0x00, 0xF4, 0x06, 0x70, 0x00, 0x16, 0x00};
276
+ static const unsigned char mem_dump_win98[DOS_MEM_DUMP_SIZE] = {
277
+ 0x9E, 0x0F, 0xC9, 0x00, 0x65, 0x04, 0x70, 0x00, 0x16, 0x00};
278
+ static const unsigned char mem_dump_dosbox[DOS_MEM_DUMP_SIZE] = {
279
+ 0x00, 0x00, 0x00, 0xF1, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00};
280
+ static unsigned char mem_dump_custom[DOS_MEM_DUMP_SIZE];
281
+
282
+ static const unsigned char *dos_mem_dump = mem_dump_dos622;
283
+
284
+ boolean I_GetMemoryValue(unsigned int offset, void *value, int size) {
285
+ static boolean firsttime = true;
286
+
287
+ if (firsttime) {
288
+ int p, i, val;
289
+
290
+ firsttime = false;
291
+ i = 0;
292
+
293
+ //!
294
+ // @category compat
295
+ // @arg <version>
296
+ //
297
+ // Specify DOS version to emulate for NULL pointer dereference
298
+ // emulation. Supported versions are: dos622, dos71, dosbox.
299
+ // The default is to emulate DOS 7.1 (Windows 98).
300
+ //
301
+
302
+ p = M_CheckParmWithArgs("-setmem", 1);
303
+
304
+ if (p > 0) {
305
+ if (!strcasecmp(myargv[p + 1], "dos622")) {
306
+ dos_mem_dump = mem_dump_dos622;
307
+ }
308
+ if (!strcasecmp(myargv[p + 1], "dos71")) {
309
+ dos_mem_dump = mem_dump_win98;
310
+ } else if (!strcasecmp(myargv[p + 1], "dosbox")) {
311
+ dos_mem_dump = mem_dump_dosbox;
312
+ } else {
313
+ for (i = 0; i < DOS_MEM_DUMP_SIZE; ++i) {
314
+ ++p;
315
+
316
+ if (p >= myargc || myargv[p][0] == '-') {
317
+ break;
318
+ }
319
+
320
+ M_StrToInt(myargv[p], &val);
321
+ mem_dump_custom[i++] = (unsigned char)val;
322
+ }
323
+
324
+ dos_mem_dump = mem_dump_custom;
325
+ }
326
+ }
327
+ }
328
+
329
+ switch (size) {
330
+ case 1:
331
+ *((unsigned char *)value) = dos_mem_dump[offset];
332
+ return true;
333
+ case 2:
334
+ *((unsigned short *)value) =
335
+ dos_mem_dump[offset] | (dos_mem_dump[offset + 1] << 8);
336
+ return true;
337
+ case 4:
338
+ *((unsigned int *)value) =
339
+ dos_mem_dump[offset] | (dos_mem_dump[offset + 1] << 8) |
340
+ (dos_mem_dump[offset + 2] << 16) | (dos_mem_dump[offset + 3] << 24);
341
+ return true;
342
+ }
343
+
344
+ return false;
345
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/opentui-doom",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Play DOOM in your terminal using OpenTUI's framebuffer rendering and doomgeneric WASM",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -13,6 +13,7 @@
13
13
  "doom/doomgeneric_opentui.c",
14
14
  "doom/doom_js_sound_bridge.c",
15
15
  "doom/i_sound.c",
16
+ "doom/i_system.c",
16
17
  "doom/s_sound.c",
17
18
  "sound",
18
19
  "scripts"
@@ -43,7 +44,10 @@
43
44
  ],
44
45
  "scripts": {
45
46
  "dev": "bun run --watch src/index.ts",
47
+ "dev:debug": "DOOM_DEBUG=1 bun run --watch src/index.ts",
46
48
  "build:doom": "bash ./scripts/build-doom.sh",
49
+ "build": "bun build src/index.ts --outdir dist --target node",
50
+ "typecheck": "bun x tsc --noEmit",
47
51
  "start": "bun run src/index.ts",
48
52
  "prepublishOnly": "bun run build:doom"
49
53
  },
@@ -34,11 +34,12 @@ fi
34
34
  # Create build directory
35
35
  mkdir -p "$BUILD_DIR"
36
36
 
37
- # Copy our platform file, sound bridge, and custom sound files
37
+ # Copy our platform file, sound bridge, and custom files
38
38
  cp "$DOOM_DIR/doomgeneric_opentui.c" "$DOOM_DIR/doomgeneric/doomgeneric/"
39
39
  cp "$DOOM_DIR/doom_js_sound_bridge.c" "$DOOM_DIR/doomgeneric/doomgeneric/"
40
40
  cp "$DOOM_DIR/i_sound.c" "$DOOM_DIR/doomgeneric/doomgeneric/"
41
41
  cp "$DOOM_DIR/s_sound.c" "$DOOM_DIR/doomgeneric/doomgeneric/"
42
+ cp "$DOOM_DIR/i_system.c" "$DOOM_DIR/doomgeneric/doomgeneric/"
42
43
 
43
44
  echo "Compiling DOOM to WebAssembly..."
44
45
  cd "$DOOM_DIR/doomgeneric/doomgeneric"
@@ -48,7 +49,7 @@ emcc -O2 \
48
49
  -s WASM=1 \
49
50
  -s USE_SDL=2 \
50
51
  -s EXPORTED_FUNCTIONS="['_doomgeneric_Create','_doomgeneric_Tick','_DG_GetFrameBuffer','_DG_PushKeyEvent','_malloc','_free']" \
51
- -s EXPORTED_RUNTIME_METHODS="['ccall','cwrap','getValue','setValue']" \
52
+ -s EXPORTED_RUNTIME_METHODS="['ccall','cwrap','getValue','setValue','FS']" \
52
53
  -s ALLOW_MEMORY_GROWTH=1 \
53
54
  -s INITIAL_MEMORY=33554432 \
54
55
  -s MODULARIZE=1 \
package/src/debug.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Debug logging utility for OpenTUI-DOOM
3
+ *
4
+ * Only logs when DOOM_DEBUG environment variable is set.
5
+ * Usage: DOOM_DEBUG=1 bun run dev
6
+ */
7
+
8
+ import { appendFileSync } from "fs";
9
+ import { join } from "path";
10
+
11
+ const DEBUG_ENABLED = process.env.DOOM_DEBUG === "1" || process.env.DOOM_DEBUG === "true";
12
+ const logFile = join(import.meta.dir, "..", "debug.log");
13
+
14
+ /**
15
+ * Log a debug message to debug.log file if DOOM_DEBUG is enabled
16
+ */
17
+ export function debugLog(category: string, message: string): void {
18
+ if (!DEBUG_ENABLED) return;
19
+
20
+ const timestamp = new Date().toISOString();
21
+ const line = `[${timestamp}] [${category}] ${message}\n`;
22
+ try {
23
+ appendFileSync(logFile, line);
24
+ } catch (e) {
25
+ // Ignore logging errors
26
+ }
27
+ }
package/src/doom-audio.ts CHANGED
@@ -7,20 +7,13 @@
7
7
 
8
8
  import { spawn, ChildProcess } from "child_process";
9
9
  import { join } from "path";
10
- import { existsSync, appendFileSync, unlinkSync } from "fs";
10
+ import { existsSync, unlinkSync } from "fs";
11
11
  import { createConnection, Socket } from "net";
12
+ import { debugLog } from "./debug";
12
13
 
13
- // Log file for debugging
14
- const logFile = join(import.meta.dir, "..", "debug.log");
15
-
14
+ // Local helper to log with Audio category
16
15
  function log(message: string): void {
17
- const timestamp = new Date().toISOString();
18
- const line = `[${timestamp}] [Audio] ${message}\n`;
19
- try {
20
- appendFileSync(logFile, line);
21
- } catch (e) {
22
- // Ignore logging errors
23
- }
16
+ debugLog('Audio', message);
24
17
  }
25
18
 
26
19
  // Track all spawned mpv processes for cleanup
@@ -7,6 +7,8 @@
7
7
 
8
8
  import { readFile } from "fs/promises";
9
9
  import { join, resolve } from "path";
10
+ import { debugLog } from "./debug";
11
+ import { loadExistingSaves, writeSave } from "./doom-saves";
10
12
 
11
13
  // DOOM screen dimensions
12
14
  export const DOOM_WIDTH = 1280;
@@ -35,6 +37,12 @@ export interface DoomModule {
35
37
  canRead: boolean,
36
38
  canWrite: boolean
37
39
  ) => string;
40
+ FS?: {
41
+ readFile: (path: string, opts?: { encoding?: string }) => Uint8Array;
42
+ readdir: (path: string) => string[];
43
+ stat: (path: string) => { mode: number };
44
+ isDir: (mode: number) => boolean;
45
+ };
38
46
  ccall: (name: string, returnType: string | null, argTypes: string[], args: any[]) => any;
39
47
  cwrap: (name: string, returnType: string | null, argTypes: string[]) => (...args: any[]) => any;
40
48
  setValue: (ptr: number, value: number, type: string) => void;
@@ -45,6 +53,7 @@ export interface DoomEngineOptions {
45
53
  wadPath: string;
46
54
  print?: (text: string) => void;
47
55
  printErr?: (text: string) => void;
56
+ onQuit?: () => void;
48
57
  }
49
58
 
50
59
  export class DoomEngine {
@@ -54,6 +63,8 @@ export class DoomEngine {
54
63
  private wadPath: string;
55
64
  private print: (text: string) => void;
56
65
  private printErr: (text: string) => void;
66
+ private onQuit: (() => void) | null = null;
67
+ private emscriptenFS: any = null; // FS reference captured from Emscripten
57
68
 
58
69
  constructor(optionsOrPath: string | DoomEngineOptions) {
59
70
  if (typeof optionsOrPath === "string") {
@@ -64,6 +75,7 @@ export class DoomEngine {
64
75
  this.wadPath = resolve(optionsOrPath.wadPath);
65
76
  this.print = optionsOrPath.print || ((text: string) => console.log('[DOOM]', text));
66
77
  this.printErr = optionsOrPath.printErr || ((text: string) => console.error('[DOOM]', text));
78
+ this.onQuit = optionsOrPath.onQuit || null;
67
79
  }
68
80
  }
69
81
 
@@ -101,13 +113,39 @@ export class DoomEngine {
101
113
  stopMusic: () => audio.stopMusic(),
102
114
  setMusicVolume: (volume: number) => audio.setMusicVolume(volume),
103
115
 
116
+ // Game lifecycle callbacks - called from C via EM_ASM
117
+ quitGame: () => {
118
+ debugLog('Engine', 'quitGame callback called from WASM');
119
+ debugLog('Engine', `this.onQuit is: ${this.onQuit ? 'defined' : 'undefined'}`);
120
+ if (this.onQuit) {
121
+ debugLog('Engine', 'calling this.onQuit()');
122
+ this.onQuit();
123
+ debugLog('Engine', 'this.onQuit() returned');
124
+ }
125
+ },
126
+
104
127
  // preRun receives Module as first argument
105
128
  preRun: [
106
- function (module: DoomModule) {
107
- // Create /doom directory
129
+ (module: any) => {
130
+ // Create /doom directory for WAD
108
131
  module.FS_createPath("/", "doom", true, true);
109
132
  // Write WAD file to virtual filesystem
110
133
  module.FS_createDataFile("/doom", "doom1.wad", wadArray, true, false);
134
+
135
+ // Create .savegame directory for saves (DOOM looks here by default)
136
+ module.FS_createPath("/", ".savegame", true, true);
137
+
138
+ // Load existing saves from ~/.opentui-doom/ into virtual filesystem
139
+ const existingSaves = loadExistingSaves();
140
+ for (const [slot, data] of existingSaves) {
141
+ const filename = `doomsav${slot}.dsg`;
142
+ try {
143
+ module.FS_createDataFile("/.savegame", filename, Array.from(data), true, true);
144
+ debugLog("Engine", `Pre-loaded save slot ${slot} to virtual FS`);
145
+ } catch (e) {
146
+ debugLog("Engine", `Failed to pre-load save slot ${slot}: ${e}`);
147
+ }
148
+ }
111
149
  }
112
150
  ],
113
151
  };
@@ -117,6 +155,18 @@ export class DoomEngine {
117
155
  if (!this.module) {
118
156
  throw new Error("Failed to initialize DOOM module");
119
157
  }
158
+
159
+ // Capture FS reference after module is fully loaded
160
+ // Try different methods to access Emscripten's FS
161
+ if ((this.module as any).FS) {
162
+ this.emscriptenFS = (this.module as any).FS;
163
+ debugLog("Engine", "Captured FS from module.FS");
164
+ } else if (typeof (globalThis as any).FS !== 'undefined') {
165
+ this.emscriptenFS = (globalThis as any).FS;
166
+ debugLog("Engine", "Captured FS from globalThis.FS");
167
+ } else {
168
+ debugLog("Engine", "Warning: Could not find Emscripten FS object");
169
+ }
120
170
 
121
171
  // Initialize DOOM
122
172
  this.initDoom();
@@ -215,4 +265,72 @@ export class DoomEngine {
215
265
  isInitialized(): boolean {
216
266
  return this.initialized;
217
267
  }
268
+
269
+ /**
270
+ * Sync save games from the virtual filesystem to disk (~/.opentui-doom/)
271
+ * Call this periodically or after save operations to persist saves
272
+ */
273
+ syncSaves(): void {
274
+ if (!this.module || !this.emscriptenFS) {
275
+ debugLog("Engine", "syncSaves: module or FS not available");
276
+ return;
277
+ }
278
+
279
+ // DOOM can save to different paths depending on configuration
280
+ // Try multiple possible locations
281
+ const savePaths = [
282
+ "/", // Root
283
+ "/.savegame", // Default when configdir is "."
284
+ ".savegame", // Relative path (CWD)
285
+ "/doom", // Our custom path
286
+ "/tmp", // Temp directory
287
+ ];
288
+
289
+ const FS = this.emscriptenFS;
290
+
291
+ // List root directory to see what exists
292
+ try {
293
+ const rootEntries = FS.readdir("/");
294
+ debugLog("Engine", `VFS root contents: ${rootEntries.join(", ")}`);
295
+
296
+ // Check each directory at root
297
+ for (const entry of rootEntries) {
298
+ if (entry === "." || entry === "..") continue;
299
+ try {
300
+ const stat = FS.stat(`/${entry}`);
301
+ if (FS.isDir(stat.mode)) {
302
+ const subEntries = FS.readdir(`/${entry}`);
303
+ const dsgFiles = subEntries.filter((e: string) => e.endsWith(".dsg"));
304
+ if (dsgFiles.length > 0) {
305
+ debugLog("Engine", `Found .dsg files in /${entry}: ${dsgFiles.join(", ")}`);
306
+ }
307
+ }
308
+ } catch (e) {
309
+ // Not a directory or can't read
310
+ }
311
+ }
312
+ } catch (e) {
313
+ debugLog("Engine", `Failed to list VFS root: ${e}`);
314
+ }
315
+
316
+ for (let slot = 0; slot <= 5; slot++) {
317
+ const filename = `doomsav${slot}.dsg`;
318
+
319
+ for (const basePath of savePaths) {
320
+ const vfsPath = basePath === "/" ? `/${filename}` : `${basePath}/${filename}`;
321
+
322
+ try {
323
+ // Try to read the file from virtual FS
324
+ const data = FS.readFile(vfsPath);
325
+ if (data && data.length > 0) {
326
+ debugLog("Engine", `Found save at ${vfsPath}, syncing slot ${slot} (${data.length} bytes)`);
327
+ writeSave(slot, data);
328
+ break; // Found this slot, move to next
329
+ }
330
+ } catch (e) {
331
+ // File doesn't exist at this path, try next
332
+ }
333
+ }
334
+ }
335
+ }
218
336
  }
package/src/doom-input.ts CHANGED
@@ -151,6 +151,11 @@ export function createDoomInputHandler(options: DoomInputOptions) {
151
151
 
152
152
  const keyId = key.name || key.sequence || "";
153
153
  const wasPressed = keyStates.get(keyId) ?? false;
154
+ const keyName = key.name?.toLowerCase() ?? "";
155
+
156
+ // Menu confirmation keys (y/n) should always send keydown on every press
157
+ // These are used for quit dialogs and other prompts
158
+ const isMenuConfirmKey = keyName === "y" || keyName === "n";
154
159
 
155
160
  // Clear any existing release timer for this key
156
161
  const existingTimer = keyTimers.get(keyId);
@@ -159,13 +164,23 @@ export function createDoomInputHandler(options: DoomInputOptions) {
159
164
  keyTimers.delete(keyId);
160
165
  }
161
166
 
162
- // Key press - only send if not already pressed
163
- if (!wasPressed) {
167
+ // Key press - send if not already pressed, OR if it's a menu confirmation key
168
+ if (!wasPressed || isMenuConfirmKey) {
164
169
  keyStates.set(keyId, true);
165
170
  engine.pushKey(true, doomKey);
171
+
172
+ // For menu confirmation keys, immediately send release too
173
+ // since DOOM only cares about the keydown event
174
+ if (isMenuConfirmKey) {
175
+ setTimeout(() => {
176
+ engine.pushKey(false, doomKey);
177
+ keyStates.set(keyId, false);
178
+ }, 50);
179
+ return;
180
+ }
166
181
  }
167
182
 
168
- // Schedule key release after 300ms of no input
183
+ // Schedule key release after 300ms of no input (for non-menu keys)
169
184
  const timer = setTimeout(() => {
170
185
  if (keyStates.get(keyId)) {
171
186
  keyStates.set(keyId, false);