romdevtools 0.71.0 → 0.84.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.
Files changed (113) hide show
  1. package/AGENTS.md +19 -16
  2. package/CHANGELOG.md +196 -0
  3. package/README.md +5 -5
  4. package/examples/README.md +1 -0
  5. package/examples/gametank/templates/gt_draw.h +91 -0
  6. package/examples/gametank/templates/gt_hud.h +89 -0
  7. package/examples/gametank/templates/gt_palette.h +59 -0
  8. package/examples/gametank/templates/gt_sound.h +90 -0
  9. package/examples/gametank/templates/gt_sprites.h +53 -0
  10. package/examples/gametank/templates/platformer.c +318 -0
  11. package/examples/gametank/templates/puzzle.c +295 -0
  12. package/examples/gametank/templates/racing.c +139 -0
  13. package/examples/gametank/templates/shmup.c +277 -0
  14. package/examples/gametank/templates/sports.c +119 -0
  15. package/package.json +23 -22
  16. package/src/cores/capabilities.js +24 -0
  17. package/src/cores/registry.js +7 -1
  18. package/src/host/LibretroHost.js +90 -17
  19. package/src/host/callbacks.js +20 -2
  20. package/src/host/cpu-state.js +17 -0
  21. package/src/host/gametank-acp-state.js +53 -0
  22. package/src/http/skill-doc.js +10 -6
  23. package/src/mcp/tools/audio.js +1 -1
  24. package/src/mcp/tools/cart-parts.js +76 -0
  25. package/src/mcp/tools/lifecycle.js +1 -1
  26. package/src/mcp/tools/platform-tools.js +9 -1
  27. package/src/mcp/tools/platforms.js +46 -10
  28. package/src/mcp/tools/toolchain.js +35 -10
  29. package/src/platforms/gametank/lib/gt/audio/acp_image.bin +0 -0
  30. package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.c +55 -0
  31. package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.h +34 -0
  32. package/src/platforms/gametank/lib/gt/audio/audio_fw.asm +223 -0
  33. package/src/platforms/gametank/lib/gt/audio/audio_fw_blob.s +8 -0
  34. package/src/platforms/gametank/lib/gt/audio/instruments.c +79 -0
  35. package/src/platforms/gametank/lib/gt/audio/instruments.h +25 -0
  36. package/src/platforms/gametank/lib/gt/audio/music.c +370 -0
  37. package/src/platforms/gametank/lib/gt/audio/music.h +35 -0
  38. package/src/platforms/gametank/lib/gt/audio/note_numbers.h +132 -0
  39. package/src/platforms/gametank/lib/gt/audio/scaled_sines.raw +0 -0
  40. package/src/platforms/gametank/lib/gt/audio/sine_256_-63_63.bin +0 -0
  41. package/src/platforms/gametank/lib/gt/banking.c +29 -0
  42. package/src/platforms/gametank/lib/gt/banking.h +8 -0
  43. package/src/platforms/gametank/lib/gt/banking2.s +41 -0
  44. package/src/platforms/gametank/lib/gt/crt0.s +102 -0
  45. package/src/platforms/gametank/lib/gt/draw_logo.s +55 -0
  46. package/src/platforms/gametank/lib/gt/feature/persist/persist.c +103 -0
  47. package/src/platforms/gametank/lib/gt/feature/persist/persist.h +13 -0
  48. package/src/platforms/gametank/lib/gt/feature/random/random.c +40 -0
  49. package/src/platforms/gametank/lib/gt/feature/random/random.h +18 -0
  50. package/src/platforms/gametank/lib/gt/feature/text/text.c +111 -0
  51. package/src/platforms/gametank/lib/gt/feature/text/text.h +25 -0
  52. package/src/platforms/gametank/lib/gt/gametank.c +12 -0
  53. package/src/platforms/gametank/lib/gt/gametank.h +80 -0
  54. package/src/platforms/gametank/lib/gt/gametank_logo.inc +393 -0
  55. package/src/platforms/gametank/lib/gt/gen/assets/assets_index.h +9 -0
  56. package/src/platforms/gametank/lib/gt/gen/assets/sdk_default.h +5 -0
  57. package/src/platforms/gametank/lib/gt/gen/bank_nums.h +11 -0
  58. package/src/platforms/gametank/lib/gt/gen/modules_enabled.h +4 -0
  59. package/src/platforms/gametank/lib/gt/gen/modules_enabled.inc +4 -0
  60. package/src/platforms/gametank/lib/gt/gfx/draw_direct.c +132 -0
  61. package/src/platforms/gametank/lib/gt/gfx/draw_direct.h +75 -0
  62. package/src/platforms/gametank/lib/gt/gfx/draw_queue.c +177 -0
  63. package/src/platforms/gametank/lib/gt/gfx/draw_queue.h +35 -0
  64. package/src/platforms/gametank/lib/gt/gfx/draw_util.s +157 -0
  65. package/src/platforms/gametank/lib/gt/gfx/gfx_sys.c +43 -0
  66. package/src/platforms/gametank/lib/gt/gfx/gfx_sys.h +46 -0
  67. package/src/platforms/gametank/lib/gt/gfx/sprites.c +216 -0
  68. package/src/platforms/gametank/lib/gt/gfx/sprites.h +41 -0
  69. package/src/platforms/gametank/lib/gt/init.c +9 -0
  70. package/src/platforms/gametank/lib/gt/input.c +26 -0
  71. package/src/platforms/gametank/lib/gt/input.h +20 -0
  72. package/src/platforms/gametank/lib/gt/interrupt.s +67 -0
  73. package/src/platforms/gametank/lib/gt/vectors.s +14 -0
  74. package/src/platforms/gametank/lib/gt/wait.s +53 -0
  75. package/src/platforms/gba/MENTAL_MODEL.md +22 -1
  76. package/src/playtest/playtest.js +174 -26
  77. package/src/playtest/resampler/build.sh +19 -0
  78. package/src/playtest/resampler/index.mjs +75 -0
  79. package/src/playtest/resampler/resampler.c +129 -0
  80. package/src/playtest/resampler/resampler.mjs +2 -0
  81. package/src/playtest/resampler/resampler.wasm +0 -0
  82. package/src/toolchains/arm-none-eabi-gcc/gcc.js +39 -188
  83. package/src/toolchains/asar/asar.js +10 -15
  84. package/src/toolchains/cc65/cc65.js +82 -92
  85. package/src/toolchains/cc65/da65.js +12 -17
  86. package/src/toolchains/cc65/preset-resolver.js +13 -2
  87. package/src/toolchains/cc65/presets/gametank/gametank.h +80 -0
  88. package/src/toolchains/cc65/presets/gametank/sdk.cfg +32 -0
  89. package/src/toolchains/cc65/presets/gametank/sdk.crt0.s +61 -0
  90. package/src/toolchains/cc65/presets/gametank/sdk.vectors.s +11 -0
  91. package/src/toolchains/cc65/presets/gametank/single-bank.cfg +28 -0
  92. package/src/toolchains/cc65/presets/gametank/single-bank.crt0.s +71 -0
  93. package/src/toolchains/cc65/presets/gametank/single-bank.vectors.s +12 -0
  94. package/src/toolchains/common/c-build.js +109 -0
  95. package/src/toolchains/common/gcc-toolchain.js +164 -0
  96. package/src/toolchains/common/wasm-tool.js +101 -0
  97. package/src/toolchains/dasm/dasm.js +12 -18
  98. package/src/toolchains/gba-c/gba-c.js +253 -305
  99. package/src/toolchains/genesis-c/genesis-c.js +196 -225
  100. package/src/toolchains/index.js +58 -4
  101. package/src/toolchains/m68k-elf-gcc/gcc.js +39 -202
  102. package/src/toolchains/mips-c/mips-c.js +68 -78
  103. package/src/toolchains/mips-elf-gcc/gcc.js +55 -118
  104. package/src/toolchains/rgbds/rgbds.js +7 -19
  105. package/src/toolchains/sdcc/sdcc.js +35 -26
  106. package/src/toolchains/sh-c/sh-c.js +44 -52
  107. package/src/toolchains/sh-elf-gcc/gcc.js +55 -110
  108. package/src/toolchains/sjasm/sjasm.js +10 -14
  109. package/src/toolchains/snes-c/snes-c.js +125 -150
  110. package/src/toolchains/tcc816/tcc816.js +10 -15
  111. package/src/toolchains/vasm68k/vasm68k.js +11 -16
  112. package/src/toolchains/wladx/wladx.js +5 -17
  113. package/src/toolchains/z80/binutils.js +5 -11
@@ -0,0 +1,9 @@
1
+ #include "gametank.h"
2
+ #include "gfx_sys.h"
3
+ #include "audio_coprocessor.h"
4
+ #include "music.h"
5
+ void sdk_init(void) {
6
+ init_graphics();
7
+ init_audio_coprocessor();
8
+ init_music();
9
+ }
@@ -0,0 +1,26 @@
1
+ #include "input.h"
2
+ #include "gametank.h"
3
+
4
+ int player1_buttons = 0, player1_old_buttons = 0, player1_new_buttons = 0;
5
+ int player2_buttons = 0, player2_old_buttons = 0, player2_new_buttons = 0;
6
+
7
+ #pragma optimize (push, off)
8
+ void update_inputs(){
9
+ char inputsA, inputsB;
10
+ inputsA = *gamepad_2;
11
+ inputsA = *gamepad_1;
12
+ inputsB = *gamepad_1;
13
+
14
+ player1_old_buttons = player1_buttons;
15
+ player1_buttons = ~((((int) inputsB) << 8) | inputsA);
16
+ player1_buttons &= INPUT_MASK_ALL_KEYS;
17
+ player1_new_buttons = player1_buttons & ~player1_old_buttons;
18
+
19
+ inputsA = *gamepad_2;
20
+ inputsB = *gamepad_2;
21
+ player2_old_buttons = player2_buttons;
22
+ player2_buttons = ~((((int) inputsB) << 8) | inputsA);
23
+ player2_buttons &= INPUT_MASK_ALL_KEYS;
24
+ player2_new_buttons = player2_buttons & ~player2_old_buttons;
25
+ }
26
+ #pragma optimize (pop)
@@ -0,0 +1,20 @@
1
+ #ifndef INPUT_H
2
+ #define INPUT_H
3
+
4
+ extern int player1_buttons, player1_old_buttons, player1_new_buttons;
5
+ extern int player2_buttons, player2_old_buttons, player2_new_buttons;
6
+
7
+ #define INPUT_MASK_UP 2056
8
+ #define INPUT_MASK_DOWN 1028
9
+ #define INPUT_MASK_LEFT 512
10
+ #define INPUT_MASK_RIGHT 256
11
+ #define INPUT_MASK_A 16
12
+ #define INPUT_MASK_B 4096
13
+ #define INPUT_MASK_C 8192
14
+ #define INPUT_MASK_START 32
15
+ #define INPUT_MASK_ALL_KEYS (INPUT_MASK_UP|INPUT_MASK_DOWN|INPUT_MASK_LEFT|INPUT_MASK_RIGHT|INPUT_MASK_A|INPUT_MASK_B|INPUT_MASK_C|INPUT_MASK_START)
16
+ #define INPUT_MASK_ANY_DIRECTION (INPUT_MASK_UP|INPUT_MASK_DOWN|INPUT_MASK_LEFT|INPUT_MASK_RIGHT)
17
+
18
+ void update_inputs();
19
+
20
+ #endif
@@ -0,0 +1,67 @@
1
+ ; ---------------------------------------------------------------------------
2
+ ; interrupt.s
3
+ ; ---------------------------------------------------------------------------
4
+ ;
5
+ ; Interrupt handler.
6
+ ;
7
+ ; Checks for a BRK instruction and returns from all valid interrupts.
8
+
9
+ .include "modules_enabled.inc"
10
+
11
+ .import _frameflag
12
+ .import _draw_busy
13
+ .export _irq_int, _nmi_int
14
+
15
+ .ifdef ENABLE_MODULE_DRAWQUEUE
16
+ .import _next_draw_queue, _queue_end, _queue_pending, _queue_start
17
+ .endif
18
+
19
+ .pc02
20
+
21
+ DMA_Start = $4006
22
+
23
+ .segment "CODE"
24
+
25
+ .PC02 ; Force 65C02 assembly mode
26
+
27
+ ; ---------------------------------------------------------------------------
28
+ ; Non-maskable interrupt (NMI) service routine
29
+
30
+ _nmi_int:
31
+ PHA
32
+ LDA $1FFF
33
+ BNE nmi_done
34
+ STZ _frameflag
35
+ nmi_done:
36
+ PLA
37
+ RTI
38
+
39
+ ; ---------------------------------------------------------------------------
40
+ ; Maskable interrupt (IRQ) service routine
41
+
42
+ .ifdef ENABLE_MODULE_DRAWQUEUE
43
+ _irq_int:
44
+ PHX ; Save X register contents to stack
45
+ PHA
46
+ PHY
47
+
48
+ ;make sure DMA isn't running then compare head and tail of queue
49
+ ;to determine whether there is more to process
50
+ STZ DMA_Start
51
+ STZ _queue_pending
52
+ STZ _draw_busy
53
+ LDA _queue_start
54
+ CMP _queue_end
55
+ BEQ finish_irq
56
+ JSR _next_draw_queue
57
+ finish_irq:
58
+ PLY
59
+ PLA ; Restore accumulator contents
60
+ PLX ; Restore X register contents
61
+ RTI ; Return from all IRQ interrupts
62
+ .else
63
+ _irq_int:
64
+ STZ DMA_Start
65
+ STZ _draw_busy
66
+ RTI
67
+ .endif
@@ -0,0 +1,14 @@
1
+ ; ---------------------------------------------------------------------------
2
+ ; vectors.s
3
+ ; ---------------------------------------------------------------------------
4
+ ;
5
+ ; Defines the interrupt vector table.
6
+
7
+ .import _init
8
+ .import _nmi_int, _irq_int
9
+
10
+ .segment "VECTORS"
11
+
12
+ .addr _nmi_int ; NMI vector
13
+ .addr _init ; Reset vector
14
+ .addr _irq_int ; IRQ/BRK vector
@@ -0,0 +1,53 @@
1
+ ; ---------------------------------------------------------------------------
2
+ ; wait.s
3
+ ; ---------------------------------------------------------------------------
4
+ ;
5
+ ; Wait for interrupt and return
6
+
7
+ .export _wait, _stop, _nop5, _nop10
8
+
9
+ ; ---------------------------------------------------------------------------
10
+ ; Wait for interrupt: Forces the assembler to emit a WAI opcode ($CB)
11
+ ; ---------------------------------------------------------------------------
12
+
13
+ .segment "CODE"
14
+
15
+ .proc _wait: near
16
+
17
+ .byte $CB ; Inserts a WAI opcode
18
+ RTS ; Return to caller
19
+
20
+ .endproc
21
+
22
+ ; ---------------------------------------------------------------------------
23
+ ; Stop: Forces the assembler to emit a STP opcode ($DB)
24
+ ; ---------------------------------------------------------------------------
25
+
26
+ .proc _stop: near
27
+
28
+ .byte $DB ; Inserts a STP opcode
29
+
30
+ .endproc
31
+
32
+ .proc _nop5
33
+ NOP
34
+ NOP
35
+ NOP
36
+ NOP
37
+ NOP
38
+ RTS
39
+ .endproc
40
+
41
+ .proc _nop10
42
+ NOP
43
+ NOP
44
+ NOP
45
+ NOP
46
+ NOP
47
+ NOP
48
+ NOP
49
+ NOP
50
+ NOP
51
+ NOP
52
+ RTS
53
+ .endproc
@@ -248,6 +248,11 @@ Loadable via mGBA (`loadMedia`).
248
248
 
249
249
  ## What's NOT bundled
250
250
 
251
+ - **`agbcc` (the legacy GBA compiler).** romdev's GBA C path is **modern
252
+ `arm-none-eabi-gcc` 14.2.0** only. The byte-exact decompilations + romhacks
253
+ (pokeruby / pokeemerald / pokefirered, etc.) build with agbcc + a custom
254
+ `ld_script.txt`, so romdev **cannot reproduce a matching retail ROM** for those.
255
+ That's a hard limit, not a missing feature — see "romdev's build model" below.
251
256
  - **libgba's `console.c`** (iprintf-style stdio output). Pulls in
252
257
  devkitPro's libsysbase header chain — not yet ported. See
253
258
  TROUBLESHOOTING.md for the trade-off rationale and workarounds.
@@ -256,7 +261,23 @@ Loadable via mGBA (`loadMedia`).
256
261
  - **devkitARM's `bin2s`** (binary → assembly converter for asset
257
262
  pipelines). Not bundled; ship binary assets as C arrays for now.
258
263
 
259
- Everything else from a stock devkitARM install works.
264
+ Everything else from a stock devkitARM install (homebrew-style) works.
265
+
266
+ ## romdev's build model (read this before driving an existing project)
267
+
268
+ `build` is a **single-shot "compile these sources → one ROM" tool**, NOT an arbitrary
269
+ build-system backend. The toolchain binaries are **WASM, run only inside romdev's build
270
+ worker (virtual FS)** — they are **not host-callable** and **cannot back an external
271
+ project's `Makefile`** as `$(TOOLCHAIN)/bin`. (The `.mjs` wrappers under `node_modules`
272
+ export a worker factory, not a CLI `main` — invoking one directly exits 0 and writes
273
+ nothing.)
274
+
275
+ So for an **existing decomp/romhack** (agbcc-era or any project with its own Makefile):
276
+ build it **on the host** with its own toolchain, then point romdev at the resulting
277
+ `.gba` for the run / inspect / debug / decompile loop. Confirmed host recipe for the
278
+ Pokémon Gen-III decomps: `brew install arm-none-eabi-gcc arm-none-eabi-binutils`, clone
279
+ `pret/agbcc` → `./build.sh` → `./install.sh <repo>`, then `make <target>` (yields a
280
+ byte-matching ROM). romdev's value here is everything AFTER the build, not the build.
260
281
 
261
282
  ## Horizontal scrolling (for side-scrollers)
262
283
 
@@ -6,8 +6,10 @@ import {
6
6
  RETRO_PIXEL_FORMAT_0RGB1555,
7
7
  RETRO_PIXEL_FORMAT_RGB565,
8
8
  RETRO_PIXEL_FORMAT_XRGB8888,
9
+ ROMDEV_PIXEL_FORMAT_RGBA8888,
9
10
  } from "../host/retroConstants.js";
10
11
  import { log } from "../mcp/log.js";
12
+ import { initResampler, resampleS16Stereo } from "./resampler/index.mjs";
11
13
  import path from "node:path";
12
14
  import { existsSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
13
15
  import { execFile } from "node:child_process";
@@ -203,10 +205,39 @@ const SDL_BUTTON_TO_LIBRETRO_BIT = {
203
205
  back: 2, // SELECT
204
206
  guide: 2,
205
207
  start: 3,
206
- leftShoulder: 10,
207
- rightShoulder: 11,
208
- leftStick: 14,
209
- rightStick: 15,
208
+ leftShoulder: 10, // RETRO L
209
+ rightShoulder: 11, // RETRO R
210
+ leftStick: 14, // RETRO L3
211
+ rightStick: 15, // RETRO R3
212
+ // NOTE: L2/R2 (bits 12/13) are ANALOG triggers in libretro — node-sdl exposes
213
+ // them as axes (leftTrigger/rightTrigger), not buttons, so they're read from
214
+ // inst.axes below, not here.
215
+ };
216
+
217
+ // N64-specific pad map. parallel_n64's RetroPad layout (its digital_cbuttons_map)
218
+ // is NOT the generic NES/SNES one — RETRO B="N64 A", RETRO Y="N64 B", RETRO X/A/L/R
219
+ // are the four C-buttons, RETRO Select="N64 L", RETRO R2="N64 R", RETRO L2="N64 Z".
220
+ // The N64's Z/L/R are DIGITAL buttons (its analog triggers don't exist), so on a
221
+ // modern pad they go on the SHOULDER buttons, NOT the analog triggers (which idle
222
+ // half-pressed and would stick). Layout for a standard X360-style pad:
223
+ // Xbox A (bottom) = N64 A (accelerate)
224
+ // Xbox B (right) = N64 B (brake)
225
+ // Xbox X (left) = N64 Z (FIRE ITEM) ← a free face button, easy to reach
226
+ // Xbox Y (top) = N64 B (alias, so either right/top brakes)
227
+ // L shoulder = N64 L
228
+ // R shoulder = N64 R (hop / drift)
229
+ // right stick = the four C-buttons (in readControllerInto)
230
+ // left stick/dpad = N64 analog stick (via the dpad→ANALOG synth in callbacks.js)
231
+ const SDL_BUTTON_TO_LIBRETRO_BIT_N64 = {
232
+ dpadUp: 4, dpadDown: 5, dpadLeft: 6, dpadRight: 7, // N64 d-pad (literal)
233
+ a: 0, // Xbox A (bottom) → RETRO B = N64 A (accelerate)
234
+ b: 1, // Xbox B (right) → RETRO Y = N64 B (brake)
235
+ y: 1, // Xbox Y (top) → RETRO Y = N64 B (alias)
236
+ x: 12, // Xbox X (left) → RETRO L2 = N64 Z (FIRE ITEM)
237
+ start: 3, // Start
238
+ leftShoulder: 2, // L shoulder → RETRO Select = N64 L
239
+ rightShoulder: 13, // R shoulder → RETRO R2 = N64 R (hop/drift)
240
+ // C-buttons (RETRO X/A/L/R) come from the RIGHT STICK in readControllerInto.
210
241
  };
211
242
 
212
243
  // Analog stick → dpad direction (for games that only read dpad)
@@ -479,12 +510,22 @@ export async function playtest(args) {
479
510
  }
480
511
 
481
512
  // Open the window.
513
+ //
514
+ // HW-render cores (n64/ps1/dreamcast) already own a GL context via native-gles
515
+ // (the EGL pbuffer the core renders its RDP/GPU into; we glReadPixels it to CPU
516
+ // pixels). If the SDL window ALSO requests an accelerated (GL) renderer, node-sdl
517
+ // calls glXMakeCurrent on the same X display and the two GL contexts collide →
518
+ // `X Error BadAccess (GLX X_GLXMakeCurrent)`, crashing the process. The window only
519
+ // ever presents CPU pixels (window.render(..., "rgba32", rgba)), so it does NOT need
520
+ // its own GL context — open a SOFTWARE-blit window for HW-render cores to avoid the
521
+ // context fight. Software cores keep the accelerated path (faster upscale blit).
522
+ const hwRenderCore = !!host.hwRender;
482
523
  const window = sdl.video.createWindow({
483
524
  title,
484
525
  width: winInitW,
485
526
  height: winInitH,
486
527
  resizable: true,
487
- accelerated: true,
528
+ accelerated: hwRenderCore ? false : true,
488
529
  vsync: false,
489
530
  });
490
531
  log.debug(`[playtest] window opened: ${winInitW}x${winInitH}, fb=${fbWidth}x${fbHeight}, aspect=${aspectMode}`);
@@ -494,16 +535,38 @@ export async function playtest(args) {
494
535
  // Mismatched rates produce choppy/sped-up/cracking playback because the
495
536
  // SDL device consumes samples at the wrong rate, alternately starving
496
537
  // (clicks) and overflowing.
538
+ //
539
+ // EXCEPTION — very-low-rate cores (the GameTank ACP emits ~13983 Hz, 3x lower
540
+ // than anything else): SDL's device buffer granularity (thousands of samples)
541
+ // dwarfs a 60 fps core's ~233-sample-per-frame chunks at that rate, so the
542
+ // device starves between ticks = clicks and pops. (RetroArch avoids this with
543
+ // a sinc resampler + dynamic rate control to the device rate.) So for low
544
+ // rates we open the device at 48 kHz and LINEAR-RESAMPLE each chunk up to it,
545
+ // which makes the per-frame chunks big enough for clean playback.
497
546
  let audio = null;
498
547
  const coreSampleRate = Math.round(host.status.audioSampleRate ?? 48000);
548
+ const AUDIO_RESAMPLE_TO = 48000;
549
+ let needsResample = coreSampleRate > 0 && coreSampleRate < 24000;
550
+ // Load the WASM+SIMD resampler if this core needs upsampling. If it fails to
551
+ // load, fall back to opening the device at the native rate (better than no
552
+ // audio) — needsResample is forced off so we never call a missing resampler.
553
+ if (needsResample) {
554
+ const ready = await initResampler();
555
+ if (!ready) {
556
+ log.error("[playtest] resampler WASM failed to load — using native rate (audio may click)");
557
+ needsResample = false;
558
+ }
559
+ }
560
+ const deviceSampleRate = needsResample ? AUDIO_RESAMPLE_TO : coreSampleRate;
499
561
  try {
500
562
  audio = sdl.audio.openDevice({ type: "playback" }, {
501
563
  channels: 2,
502
- frequency: coreSampleRate,
564
+ frequency: deviceSampleRate,
503
565
  format: "s16",
504
566
  });
505
567
  audio.play();
506
- log.debug(`[playtest] audio: ${coreSampleRate} Hz, stereo, s16`);
568
+ log.debug(`[playtest] audio: ${deviceSampleRate} Hz, stereo, s16` +
569
+ (needsResample ? ` (resampled from core ${coreSampleRate} Hz)` : ""));
507
570
  } catch (e) {
508
571
  log.error("[playtest] audio init failed (continuing silent):", e.message);
509
572
  }
@@ -688,7 +751,15 @@ export async function playtest(args) {
688
751
 
689
752
  // Tick = one emulated frame + render + audio drain. Driven by setInterval
690
753
  // so the Node event loop stays free for MCP requests on the same host.
691
- const frameMs = 1000 / 60;
754
+ // Pace to the CORE's native refresh rate (status.coreFps), not a hardcoded 60: a
755
+ // 30fps title (Sonic Adventure on flycast) at a 60Hz tick gets double-ticked —
756
+ // wasting half the budget and, on the heavy interpreter-only DC core (23ms/frame,
757
+ // no JIT), falling behind every tick → the black-flash/glitch. At its real 30fps
758
+ // each frame gets a full 33ms tick, which the core can actually hit. Clamped so a
759
+ // bogus report can't run the window absurdly fast or slow.
760
+ const coreFps = openHost?.status?.coreFps;
761
+ const fps = (coreFps >= 20 && coreFps <= 120) ? coreFps : 60;
762
+ const frameMs = 1000 / fps;
692
763
 
693
764
  function tick() {
694
765
  if (!running || window.destroyed) { stop(); return; }
@@ -704,7 +775,12 @@ export async function playtest(args) {
704
775
  // the N-second cadence; serialize off the live host so it captures the human's
705
776
  // exact progress. Skipped while paused (nothing changed) and on the very first
706
777
  // ticks (let the core settle).
707
- if (checkpointPath && tickCount - lastCheckpointTick >= checkpointEverySec * 60 && !h.status.paused) {
778
+ // Auto-checkpoint serializes the WHOLE machine state cheap for 8/16-bit (KB,
779
+ // instant) but BRUTAL for the hwRender 3D cores (DC/N64 savestate ≈16MB, ~18ms
780
+ // to serialize), which would freeze the window for ~18ms every cadence on an
781
+ // already-slow core. Skip it entirely for hwRender — same call as the rewind
782
+ // buffer skip. (Eviction recovery matters less than a playable window here.)
783
+ if (!h.hwRender && checkpointPath && tickCount - lastCheckpointTick >= checkpointEverySec * 60 && !h.status.paused) {
708
784
  lastCheckpointTick = tickCount;
709
785
  writeCheckpoint(h, "auto");
710
786
  }
@@ -726,6 +802,7 @@ export async function playtest(args) {
726
802
  // window leaves it alone. Select+Start on any controller quits.
727
803
  let quit = false;
728
804
  const isC64 = h.status?.platform === "c64";
805
+ const isN64 = h.status?.platform === "n64";
729
806
  function readControllerInto(port, inst) {
730
807
  if (!inst) return;
731
808
  const btn = inst.buttons || {};
@@ -733,7 +810,8 @@ export async function playtest(args) {
733
810
  quit = true;
734
811
  return;
735
812
  }
736
- for (const [sdlName, bit] of Object.entries(SDL_BUTTON_TO_LIBRETRO_BIT)) {
813
+ const buttonMap = isN64 ? SDL_BUTTON_TO_LIBRETRO_BIT_N64 : SDL_BUTTON_TO_LIBRETRO_BIT;
814
+ for (const [sdlName, bit] of Object.entries(buttonMap)) {
737
815
  if (btn[sdlName]) port[bitToName(bit)] = true;
738
816
  }
739
817
  const axes = inst.axes || {};
@@ -743,6 +821,9 @@ export async function playtest(args) {
743
821
  else if (lx < -STICK_DEADZONE) port.left = true;
744
822
  if (ly > STICK_DEADZONE) port.down = true;
745
823
  else if (ly < -STICK_DEADZONE) port.up = true;
824
+ // NOTE: the analog triggers are NOT used for N64 — its Z/L/R are digital and
825
+ // map to the SHOULDER buttons (see SDL_BUTTON_TO_LIBRETRO_BIT_N64). (node-sdl's
826
+ // X360 trigger axes also idle at ~0.5, so reading them as buttons would stick.)
746
827
  // C64: the RIGHT stick selects the function keys (F1/F3/F5/F7) — the
747
828
  // Batocera/RetroDeck convention so a controller alone reaches the keyboard
748
829
  // keys C64 setup screens need. Emitted as virtual buttons the host's C64
@@ -755,6 +836,19 @@ export async function playtest(args) {
755
836
  if (rx < -STICK_DEADZONE) port.c64_f3 = true; // left → F3
756
837
  else if (rx > STICK_DEADZONE) port.c64_f5 = true; // right → F5
757
838
  }
839
+ // N64: the RIGHT stick drives the four C-buttons — the standard emulation
840
+ // convention so a modern dual-stick pad plays N64 naturally (left stick =
841
+ // analog stick via the d-pad synthesis in callbacks.js; right stick = C). The
842
+ // C-buttons land on libretro bits A/X/L/R, which parallel_n64 reads as
843
+ // C-Down/C-Up/C-Left/C-Right. Z is the left trigger (mapped above).
844
+ if (isN64) {
845
+ const rx = axes.rightStickX ?? 0;
846
+ const ry = axes.rightStickY ?? 0;
847
+ if (ry < -STICK_DEADZONE) port.x = true; // up → C-Up (RETRO X)
848
+ else if (ry > STICK_DEADZONE) port.a = true; // down → C-Down (RETRO A)
849
+ if (rx < -STICK_DEADZONE) port.l = true; // left → C-Left (RETRO L)
850
+ else if (rx > STICK_DEADZONE) port.r = true; // right → C-Right (RETRO R)
851
+ }
758
852
  }
759
853
 
760
854
  const port0 = {};
@@ -792,8 +886,14 @@ export async function playtest(args) {
792
886
  // either port)?
793
887
  const humanPressing = anyButtonHeld(port0) || anyButtonHeld(port1);
794
888
  humanInput.note(humanPressing, tickCount);
795
- // Capture snapshot before stepping so R can rewind to it later.
796
- if (h.status?.loaded) {
889
+ // Capture snapshot before stepping so R can rewind to it later. SKIP for
890
+ // hwRender cores (n64/ps1/dreamcast): their savestates are HUGE (N64 ≈16MB
891
+ // each — 600 frames would be ~9GB of RAM) and serializeState costs ~8ms/frame
892
+ // there, eating half the 16.6ms budget and starving the audio feed (the choppy
893
+ // playback). The R-key rewind is a nicety, not worth that on the 3D engines —
894
+ // pause + savestate still work for those. (Rewind buffer is playtest-only; it's
895
+ // NOT part of the debug ABI, so dropping it on these cores changes nothing else.)
896
+ if (h.status?.loaded && !h.hwRender) {
797
897
  try {
798
898
  const snap = h.serializeState();
799
899
  rewindBuffer.push(snap);
@@ -810,9 +910,39 @@ export async function playtest(args) {
810
910
  h.setInput({ ports: [port0, port1] });
811
911
  humanInputDirty = humanPressing;
812
912
  }
913
+ // AUDIO-PACED stepping, BUDGETED BY WALL-CLOCK. We catch the buffer up by
914
+ // stepping extra frames per tick to keep SDL's queue topped — but a fast core
915
+ // (n64 2.4ms/frame) and a SUB-REALTIME core (flycast DC ~60ms/frame) need very
916
+ // different burst sizes. A fixed MAX_STEPS frame-count cap is the trap: 8 frames
917
+ // is 19ms on n64 (fine) but 480ms on DC — which BLOCKS the Node event loop for
918
+ // half a second per tick, so the window can't repaint and audio drains dry →
919
+ // "breaks down, super choppy" death spiral that never recovers. The fix is to
920
+ // cap by TIME: keep stepping only while we're under a wall-clock budget (~1.5
921
+ // ticks). A sub-realtime core then runs steady-slow (audio underruns gracefully,
922
+ // a constant low pitch) instead of stuttering — and the loop ALWAYS yields the
923
+ // event loop promptly so the window stays responsive.
813
924
  let stepped = 0;
814
925
  try {
815
- stepped = h.stepFrames(1);
926
+ if (audio && deviceSampleRate > 0) {
927
+ const bps = deviceSampleRate * 4; // stereo s16
928
+ const TARGET_MS = 60; // keep ~60ms queued — drain sets the speed
929
+ const BUDGET_MS = frameMs * 1.5; // wall-clock ceiling for the whole burst
930
+ const burstStart = performance.now();
931
+ do {
932
+ stepped += h.stepFrames(1);
933
+ // Stop the instant we've spent our wall-clock budget — this is what keeps
934
+ // a slow core from freezing the loop. A single frame already over budget
935
+ // still steps once (progress), then we yield.
936
+ if (performance.now() - burstStart >= BUDGET_MS) break;
937
+ const qMs = ((audio.queued ?? 0) / bps) * 1000;
938
+ let ringMs = 0;
939
+ for (const b of h.state.audioRing) ringMs += (b.length / 2);
940
+ ringMs = (ringMs / deviceSampleRate) * 1000;
941
+ if (qMs + ringMs >= TARGET_MS) break;
942
+ } while (true);
943
+ } else {
944
+ stepped = h.stepFrames(1); // no audio device → plain 1 frame/tick
945
+ }
816
946
  } catch (e) {
817
947
  // A step error mid-swap (host being torn down/rebuilt) is transient —
818
948
  // skip this frame and let the next tick pick up the new host. Don't kill
@@ -879,17 +1009,16 @@ export async function playtest(args) {
879
1009
  }
880
1010
 
881
1011
  if (running && audio && h.state.audioRing.length > 0) {
882
- // SDL device tells us how many bytes are still queued (not yet played).
883
- // If we get >200ms ahead, the player got a stutter and is now
884
- // building latency drop new samples until the queue drains back
885
- // to ~50ms. This keeps audio synced to wall clock without underruns.
886
- // Wrap the whole block in try/catch stop() may have closed the
887
- // audio device between the running check and the access below.
1012
+ // Enqueue the real audio the core produced this tick. The AUDIO-PACED stepping
1013
+ // above already steps however many frames are needed to keep ~60ms queued, so
1014
+ // the buffer stays full from REAL samples no silence padding, no rate-deficit
1015
+ // starvation. SDL's steady drain at the device rate is what sets emulation speed.
1016
+ // We only stop enqueuing if latency runs away (>250ms) a safety valve, not
1017
+ // the normal path.
888
1018
  try {
889
- const bytesPerSecond = coreSampleRate * 2 /* ch */ * 2 /* s16 */;
890
- const queuedBytes = audio.queued ?? 0;
891
- const queuedMs = (queuedBytes / bytesPerSecond) * 1000;
892
- if (queuedMs < 200) {
1019
+ const bytesPerSecond = deviceSampleRate * 2 /* ch */ * 2 /* s16 */;
1020
+ const queuedMs = ((audio.queued ?? 0) / bytesPerSecond) * 1000;
1021
+ if (queuedMs < 250) {
893
1022
  let total = 0;
894
1023
  for (const buf of h.state.audioRing) total += buf.byteLength;
895
1024
  const merged = Buffer.alloc(total);
@@ -898,14 +1027,15 @@ export async function playtest(args) {
898
1027
  merged.set(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength), off);
899
1028
  off += buf.byteLength;
900
1029
  }
901
- audio.enqueue(merged);
1030
+ audio.enqueue(needsResample
1031
+ ? resampleS16Stereo(merged, coreSampleRate, deviceSampleRate)
1032
+ : merged);
902
1033
  }
903
1034
  } catch (e) {
904
1035
  if (!e.message?.includes("closed")) {
905
1036
  log.error("[playtest] audio enqueue error:", e.message);
906
1037
  }
907
1038
  }
908
- // Always clear the ring — if we skipped enqueue we drop those samples.
909
1039
  h.state.audioRing.length = 0;
910
1040
  }
911
1041
  }
@@ -992,7 +1122,25 @@ function bitToName(bit) {
992
1122
  /** Convert libretro framebuffer (any pixel format) to RGBA32. */
993
1123
  function framebufferToRgba(f) {
994
1124
  const out = Buffer.alloc(f.width * f.height * 4);
995
- if (f.format === RETRO_PIXEL_FORMAT_XRGB8888) {
1125
+ if (f.format === ROMDEV_PIXEL_FORMAT_RGBA8888) {
1126
+ // HW-render readback (n64/ps1/dreamcast via native-gles): pixels are ALREADY
1127
+ // RGBA in row order. Copy straight through but FORCE alpha=255 — the GL render
1128
+ // target leaves alpha=0 (unused channel), which SDL would composite as fully
1129
+ // transparent → a black window. This is the on-screen analogue of the same
1130
+ // alpha fix framebufferToRgba() in framebuffer.js does for screenshots.
1131
+ for (let y = 0; y < f.height; y++) {
1132
+ const src = y * f.pitch;
1133
+ const dst = y * f.width * 4;
1134
+ for (let x = 0; x < f.width; x++) {
1135
+ const s = src + x * 4;
1136
+ const d = dst + x * 4;
1137
+ out[d + 0] = f.pixels[s + 0];
1138
+ out[d + 1] = f.pixels[s + 1];
1139
+ out[d + 2] = f.pixels[s + 2];
1140
+ out[d + 3] = 0xff;
1141
+ }
1142
+ }
1143
+ } else if (f.format === RETRO_PIXEL_FORMAT_XRGB8888) {
996
1144
  for (let y = 0; y < f.height; y++) {
997
1145
  const src = y * f.pitch;
998
1146
  const dst = y * f.width * 4;
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # Build the WASM+SIMD audio resampler for the playtest sink.
3
+ # Mirrors simdpipe's emcc style (-O3 -msimd128, MODULARIZE ES6). Single-thread,
4
+ # small heap (audio chunks are a few KB). Output: resampler.mjs + resampler.wasm.
5
+ set -euo pipefail
6
+ cd "$(dirname "$0")"
7
+
8
+ source "$HOME/code/mine/emsdk/emsdk_env.sh" >/dev/null 2>&1 || true
9
+
10
+ emcc resampler.c \
11
+ -O3 -msimd128 -ffast-math \
12
+ -s WASM=1 -s MODULARIZE=1 -s EXPORT_ES6=1 \
13
+ -s ENVIRONMENT=node,web,worker \
14
+ -s ALLOW_MEMORY_GROWTH=1 -s INITIAL_MEMORY=4194304 \
15
+ -s EXPORTED_RUNTIME_METHODS='["HEAP16","HEAPU8","cwrap","ccall"]' \
16
+ -s EXPORTED_FUNCTIONS='["_rs_alloc","_rs_free","_rs_resample","_malloc","_free"]' \
17
+ -o resampler.mjs
18
+
19
+ echo "built resampler.mjs + resampler.wasm ($(stat -c%s resampler.wasm 2>/dev/null || echo '?') bytes)"
@@ -0,0 +1,75 @@
1
+ /* ── resampler/index.mjs — JS wrapper for the WASM+SIMD audio resampler ────────
2
+ *
3
+ * Loads the WASM module once and exposes resampleS16Stereo(buf, src, dst) that
4
+ * resamples an interleaved S16LE stereo Node Buffer. Used by the playtest audio
5
+ * sink for low-rate cores (the GameTank ACP at ~13983 Hz) — see resampler.c for
6
+ * the why (libretro frontends resample; only GameTank is low enough to need it).
7
+ *
8
+ * The WASM scratch buffers are sized once to the largest chunk seen and reused.
9
+ */
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ let mod = null; // the emscripten Module
16
+ let inPtr = 0, inCap = 0; // input scratch (bytes)
17
+ let outPtr = 0, outCap = 0; // output scratch (bytes)
18
+
19
+ /** Lazily instantiate the WASM module. Returns true once ready, false if it
20
+ * failed to load (caller falls back to passing audio through unresampled). */
21
+ export async function initResampler() {
22
+ if (mod) return true;
23
+ try {
24
+ const factory = (await import(path.join(__dirname, "resampler.mjs"))).default;
25
+ mod = await factory();
26
+ return true;
27
+ } catch (e) {
28
+ mod = null;
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function ensureCap(needIn, needOut) {
34
+ if (needIn > inCap) {
35
+ if (inPtr) mod._rs_free(inPtr);
36
+ inCap = needIn * 2; // grow with headroom
37
+ inPtr = mod._rs_alloc(inCap);
38
+ }
39
+ if (needOut > outCap) {
40
+ if (outPtr) mod._rs_free(outPtr);
41
+ outCap = needOut * 2;
42
+ outPtr = mod._rs_alloc(outCap);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Resample an interleaved S16LE stereo Buffer from srcRate to dstRate using the
48
+ * WASM+SIMD core. Synchronous; initResampler() must have resolved true first.
49
+ * Falls back to returning the input unchanged if the module isn't loaded.
50
+ * @param {Buffer} buf interleaved S16LE stereo at srcRate
51
+ * @param {number} srcRate
52
+ * @param {number} dstRate
53
+ * @returns {Buffer} interleaved S16LE stereo at dstRate
54
+ */
55
+ export function resampleS16Stereo(buf, srcRate, dstRate) {
56
+ if (!mod || srcRate === dstRate || srcRate <= 0 || dstRate <= 0) return buf;
57
+ const inFrames = (buf.length / 4) | 0; // 4 bytes/stereo frame
58
+ if (inFrames < 2) return buf;
59
+ const maxOutFrames = Math.ceil(inFrames * (dstRate / srcRate)) + 2;
60
+
61
+ ensureCap(inFrames * 4, maxOutFrames * 4);
62
+
63
+ // copy input into WASM heap
64
+ mod.HEAPU8.set(new Uint8Array(buf.buffer, buf.byteOffset, buf.length), inPtr);
65
+
66
+ const outFrames = mod._rs_resample(
67
+ inPtr, inFrames, outPtr, maxOutFrames, srcRate, dstRate
68
+ );
69
+ if (outFrames <= 0) return buf;
70
+
71
+ // copy result out into a fresh Buffer (the audio device owns its own memory,
72
+ // and the WASM heap may move on the next call).
73
+ const outBytes = outFrames * 4;
74
+ return Buffer.from(new Uint8Array(mod.HEAPU8.buffer, outPtr, outBytes));
75
+ }