romdevtools 0.71.1 → 0.84.1

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 (105) hide show
  1. package/AGENTS.md +19 -16
  2. package/CHANGELOG.md +184 -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 +24 -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/platforms/gametank/lib/gt/audio/acp_image.bin +0 -0
  28. package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.c +55 -0
  29. package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.h +34 -0
  30. package/src/platforms/gametank/lib/gt/audio/audio_fw.asm +223 -0
  31. package/src/platforms/gametank/lib/gt/audio/audio_fw_blob.s +8 -0
  32. package/src/platforms/gametank/lib/gt/audio/instruments.c +79 -0
  33. package/src/platforms/gametank/lib/gt/audio/instruments.h +25 -0
  34. package/src/platforms/gametank/lib/gt/audio/music.c +370 -0
  35. package/src/platforms/gametank/lib/gt/audio/music.h +35 -0
  36. package/src/platforms/gametank/lib/gt/audio/note_numbers.h +132 -0
  37. package/src/platforms/gametank/lib/gt/audio/scaled_sines.raw +0 -0
  38. package/src/platforms/gametank/lib/gt/audio/sine_256_-63_63.bin +0 -0
  39. package/src/platforms/gametank/lib/gt/banking.c +29 -0
  40. package/src/platforms/gametank/lib/gt/banking.h +8 -0
  41. package/src/platforms/gametank/lib/gt/banking2.s +41 -0
  42. package/src/platforms/gametank/lib/gt/crt0.s +102 -0
  43. package/src/platforms/gametank/lib/gt/draw_logo.s +55 -0
  44. package/src/platforms/gametank/lib/gt/feature/persist/persist.c +103 -0
  45. package/src/platforms/gametank/lib/gt/feature/persist/persist.h +13 -0
  46. package/src/platforms/gametank/lib/gt/feature/random/random.c +40 -0
  47. package/src/platforms/gametank/lib/gt/feature/random/random.h +18 -0
  48. package/src/platforms/gametank/lib/gt/feature/text/text.c +111 -0
  49. package/src/platforms/gametank/lib/gt/feature/text/text.h +25 -0
  50. package/src/platforms/gametank/lib/gt/gametank.c +12 -0
  51. package/src/platforms/gametank/lib/gt/gametank.h +80 -0
  52. package/src/platforms/gametank/lib/gt/gametank_logo.inc +393 -0
  53. package/src/platforms/gametank/lib/gt/gen/assets/assets_index.h +9 -0
  54. package/src/platforms/gametank/lib/gt/gen/assets/sdk_default.h +5 -0
  55. package/src/platforms/gametank/lib/gt/gen/bank_nums.h +11 -0
  56. package/src/platforms/gametank/lib/gt/gen/modules_enabled.h +4 -0
  57. package/src/platforms/gametank/lib/gt/gen/modules_enabled.inc +4 -0
  58. package/src/platforms/gametank/lib/gt/gfx/draw_direct.c +132 -0
  59. package/src/platforms/gametank/lib/gt/gfx/draw_direct.h +75 -0
  60. package/src/platforms/gametank/lib/gt/gfx/draw_queue.c +177 -0
  61. package/src/platforms/gametank/lib/gt/gfx/draw_queue.h +35 -0
  62. package/src/platforms/gametank/lib/gt/gfx/draw_util.s +157 -0
  63. package/src/platforms/gametank/lib/gt/gfx/gfx_sys.c +43 -0
  64. package/src/platforms/gametank/lib/gt/gfx/gfx_sys.h +46 -0
  65. package/src/platforms/gametank/lib/gt/gfx/sprites.c +216 -0
  66. package/src/platforms/gametank/lib/gt/gfx/sprites.h +41 -0
  67. package/src/platforms/gametank/lib/gt/init.c +9 -0
  68. package/src/platforms/gametank/lib/gt/input.c +26 -0
  69. package/src/platforms/gametank/lib/gt/input.h +20 -0
  70. package/src/platforms/gametank/lib/gt/interrupt.s +67 -0
  71. package/src/platforms/gametank/lib/gt/vectors.s +14 -0
  72. package/src/platforms/gametank/lib/gt/wait.s +53 -0
  73. package/src/playtest/playtest.js +174 -26
  74. package/src/toolchains/arm-none-eabi-gcc/gcc.js +39 -188
  75. package/src/toolchains/asar/asar.js +10 -15
  76. package/src/toolchains/cc65/cc65.js +82 -92
  77. package/src/toolchains/cc65/da65.js +12 -17
  78. package/src/toolchains/cc65/preset-resolver.js +13 -2
  79. package/src/toolchains/cc65/presets/gametank/gametank.h +80 -0
  80. package/src/toolchains/cc65/presets/gametank/sdk.cfg +32 -0
  81. package/src/toolchains/cc65/presets/gametank/sdk.crt0.s +61 -0
  82. package/src/toolchains/cc65/presets/gametank/sdk.vectors.s +11 -0
  83. package/src/toolchains/cc65/presets/gametank/single-bank.cfg +28 -0
  84. package/src/toolchains/cc65/presets/gametank/single-bank.crt0.s +71 -0
  85. package/src/toolchains/cc65/presets/gametank/single-bank.vectors.s +12 -0
  86. package/src/toolchains/common/c-build.js +109 -0
  87. package/src/toolchains/common/gcc-toolchain.js +164 -0
  88. package/src/toolchains/common/wasm-tool.js +101 -0
  89. package/src/toolchains/dasm/dasm.js +12 -18
  90. package/src/toolchains/gba-c/gba-c.js +253 -305
  91. package/src/toolchains/genesis-c/genesis-c.js +196 -225
  92. package/src/toolchains/index.js +58 -4
  93. package/src/toolchains/m68k-elf-gcc/gcc.js +39 -202
  94. package/src/toolchains/mips-c/mips-c.js +68 -78
  95. package/src/toolchains/mips-elf-gcc/gcc.js +55 -118
  96. package/src/toolchains/rgbds/rgbds.js +7 -19
  97. package/src/toolchains/sdcc/sdcc.js +35 -26
  98. package/src/toolchains/sh-c/sh-c.js +44 -52
  99. package/src/toolchains/sh-elf-gcc/gcc.js +55 -110
  100. package/src/toolchains/sjasm/sjasm.js +10 -14
  101. package/src/toolchains/snes-c/snes-c.js +125 -150
  102. package/src/toolchains/tcc816/tcc816.js +10 -15
  103. package/src/toolchains/vasm68k/vasm68k.js +11 -16
  104. package/src/toolchains/wladx/wladx.js +5 -17
  105. package/src/toolchains/z80/binutils.js +5 -11
@@ -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 "romdev-audio-resampler";
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;
@@ -1,216 +1,67 @@
1
1
  // arm-none-eabi-gcc — WASM toolchain wrappers for GBA C builds.
2
2
  //
3
- // Mirrors src/toolchains/m68k-elf-gcc/gcc.js. The GBA target uses
4
- // ARM7TDMI in Thumb-interwork mode by default (matches libgba's
5
- // expectations).
3
+ // The full pipeline:
4
+ // runCc1arm({source, headers, options}) ARM assembly text (.s)
5
+ // runArmAs({source, includes}).o ELF object
6
+ // runArmLd({objects, linkScript}) → linked .elf (+ map)
7
+ // runArmObjcopy({elf}) → raw .gba ROM
6
8
  //
7
- // Pipeline:
8
- // runCc1arm({source, headers, options}) → ARM assembly text (.s)
9
- // runArmAs({asmSource, includes}) → .o ELF object
10
- // runArmLd({objects, linkScript}) → linked .elf
11
- // runArmObjcopy({elf}) → raw .gba ROM
9
+ // 0.81.0: the 4 stages come from the shared makeGccToolchain() factory
10
+ // (common/gcc-toolchain.js); this file is just the ARM config + thin re-exports
11
+ // so existing call sites keep their runArm*/runCc1arm names.
12
12
  //
13
- // Each step runs as a WASM module through the worker pool (R12
14
- // crash isolation). The WASM tools live under wasm/ (cc1-arm.{mjs,wasm}
15
- // etc.). emcc emits ESM (EXPORT_ES6=1) so we use .mjs extensions.
16
-
13
+ // The WASM glue ships in romdev-platform-gba (155MB, incl. the 135MB cc1-arm);
14
+ // resolution is lazy + memoized, so booting never loads it unless a GBA C ROM
15
+ // is actually built. ARMv4T (arm7tdmi), thumb-interwork.
17
16
  import { fileURLToPath } from "node:url";
18
- import { existsSync } from "node:fs";
19
17
  import path from "node:path";
20
18
 
21
- import { runIsolated, textFile, binaryFile, getOutputBytes, getOutputText } from "../_worker/run.js";
19
+ import { makeGccToolchain } from "../common/gcc-toolchain.js";
22
20
 
23
- const __filename = fileURLToPath(import.meta.url);
24
- const __dirname = path.dirname(__filename);
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
22
 
26
- // arm-none-eabi-gcc's WASM ships in romdev-platform-gba (the GBA platform is
27
- // the only consumer). Resolve each tool's glue from that package; fall back to
28
- // a local copy under src/ if present (transition / dev). emcc emits ESM
29
- // (EXPORT_ES6=1) so the glue uses .mjs extensions. The package is a hard dep
30
- // of romdev.
31
- function resolveArmGlue(file) {
32
- try {
33
- const u = import.meta.resolve("romdev-platform-gba");
34
- const p = path.join(path.dirname(fileURLToPath(u)), "wasm", file);
35
- if (existsSync(p)) return p;
36
- } catch { /* package not resolvable — fall through to local */ }
37
- const local = path.join(__dirname, "wasm", file);
38
- if (existsSync(local)) return local;
39
- throw new Error(`arm-none-eabi-gcc WASM (${file}) not found — install romdev-platform-gba`);
40
- }
23
+ const ARM_FLAGS = ["-mcpu=arm7tdmi", "-mthumb-interwork"];
41
24
 
42
- // Lazy + memoized per tool: resolve only on the first GBA-C build that uses
43
- // each tool, not at module load — so booting the server never touches this
44
- // (155MB, incl. the 135MB cc1-arm) package unless a GBA C ROM is actually built.
45
- const _glue = {};
46
- const armGlue = (file) => (_glue[file] ??= resolveArmGlue(file));
25
+ const { runCc1, runAs, runLd, runObjcopy } = makeGccToolchain({
26
+ pkg: "romdev-platform-gba",
27
+ localDir: __dirname,
28
+ label: "arm-none-eabi-gcc",
29
+ glue: {
30
+ cc1: "cc1-arm.mjs",
31
+ as: "arm-none-eabi-as.mjs",
32
+ ld: "arm-none-eabi-ld.mjs",
33
+ objcopy: "arm-none-eabi-objcopy.mjs",
34
+ },
35
+ cc1Flags: ARM_FLAGS,
36
+ asFlags: ARM_FLAGS,
37
+ ldScriptName: "gba.ld",
38
+ outputName: "main.gba",
39
+ });
47
40
 
48
41
  /**
49
42
  * Compile a C source to ARM assembly via cc1.
50
- *
51
- * @param {Object} args
52
- * @param {string} args.source main C source text
53
- * @param {Record<string, string>} [args.headers] virtual headers visible via -I/work
54
- * @param {string[]} [args.options] extra cc1 flags
43
+ * @param {{source:string, headers?:Record<string,string>, options?:string[]}} args
55
44
  * @returns {Promise<{log:string, exitCode:number, asmSource:string|null, crash?:any}>}
56
45
  */
57
- export async function runCc1arm(args) {
58
- const { source, options = [] } = args;
59
- const headers = args.headers ?? {};
60
- /** @type {import("../_worker/run.js").InputFile[]} */
61
- const inputFiles = [textFile("/work/main.c", source)];
62
- for (const [name, content] of Object.entries(headers)) {
63
- inputFiles.push(textFile("/work/" + name, content));
64
- }
65
- const argv = [
66
- // GBA = ARM7TDMI. Thumb-interwork allows mixed ARM/Thumb code.
67
- "-mcpu=arm7tdmi",
68
- "-mthumb-interwork",
69
- "-iquote", "/work",
70
- "-I", "/work",
71
- ...options,
72
- "/work/main.c",
73
- "-o", "/work/main.s",
74
- ];
75
- const r = await runIsolated({
76
- gluePath: armGlue("cc1-arm.mjs"),
77
- argv,
78
- inputFiles,
79
- outputFiles: [{ vfsPath: "/work/main.s", encoding: "utf8" }],
80
- });
81
- return {
82
- log: r.log,
83
- exitCode: r.exitCode,
84
- asmSource: getOutputText(r, "/work/main.s") || null,
85
- ...(r.crash ? { crash: r.crash, stage: "crash" } : {}),
86
- };
87
- }
46
+ export const runCc1arm = runCc1;
88
47
 
89
48
  /**
90
49
  * Assemble ARM assembly with arm-none-eabi-as.
91
- *
92
- * @param {Object} args
93
- * @param {string} args.source assembly source text
94
- * @param {Record<string, string>} [args.includes]
95
- * @param {Record<string, Uint8Array>} [args.binaryIncludes]
96
- * @param {string[]} [args.options]
50
+ * @param {{source:string, includes?:Record<string,string>, binaryIncludes?:Record<string,Uint8Array>, options?:string[]}} args
97
51
  * @returns {Promise<{log:string, exitCode:number, object:Uint8Array|null, crash?:any}>}
98
52
  */
99
- export async function runArmAs(args) {
100
- const { source, options = [] } = args;
101
- const includes = args.includes ?? {};
102
- const binaryIncludes = args.binaryIncludes ?? {};
103
- /** @type {import("../_worker/run.js").InputFile[]} */
104
- const inputFiles = [textFile("/work/main.s", source)];
105
- for (const [name, content] of Object.entries(includes)) {
106
- inputFiles.push(textFile("/work/" + name, content));
107
- }
108
- for (const [name, bytes] of Object.entries(binaryIncludes)) {
109
- inputFiles.push(binaryFile("/work/" + name, bytes));
110
- }
111
- const argv = [
112
- "-mcpu=arm7tdmi",
113
- "-mthumb-interwork",
114
- "-I", "/work",
115
- ...options,
116
- "/work/main.s",
117
- "-o", "/work/main.o",
118
- ];
119
- const r = await runIsolated({
120
- gluePath: armGlue("arm-none-eabi-as.mjs"),
121
- argv,
122
- inputFiles,
123
- outputFiles: [{ vfsPath: "/work/main.o", encoding: "base64" }],
124
- });
125
- return {
126
- log: r.log,
127
- exitCode: r.exitCode,
128
- object: getOutputBytes(r, "/work/main.o"),
129
- ...(r.crash ? { crash: r.crash, stage: "crash" } : {}),
130
- };
131
- }
53
+ export const runArmAs = runAs;
132
54
 
133
55
  /**
134
- * Link ARM object files into an ELF executable.
135
- *
136
- * @param {Object} args
137
- * @param {Record<string, Uint8Array>} args.objects
138
- * @param {string} args.linkScript linker script contents (lnkscript / gba.ld)
139
- * @param {string[]} [args.libraryPaths]
140
- * @param {string[]} [args.libraries]
141
- * @param {Record<string, Uint8Array>} [args.archives]
142
- * @param {string[]} [args.options]
143
- * @returns {Promise<{log:string, exitCode:number, elf:Uint8Array|null, crash?:any}>}
56
+ * Link ARM object files into an ELF executable (+ linker map).
57
+ * @param {{objects:Record<string,Uint8Array>, linkScript:string, libraries?:string[], libraryPaths?:string[], archives?:Record<string,Uint8Array>, options?:string[]}} args
58
+ * @returns {Promise<{log:string, exitCode:number, elf:Uint8Array|null, map:string|null, crash?:any}>}
144
59
  */
145
- export async function runArmLd(args) {
146
- const { objects, linkScript, libraries = [], libraryPaths = [], options = [] } = args;
147
- const archives = args.archives ?? {};
148
- /** @type {import("../_worker/run.js").InputFile[]} */
149
- const inputFiles = [textFile("/work/gba.ld", linkScript)];
150
- for (const [name, bytes] of Object.entries(objects)) {
151
- inputFiles.push(binaryFile("/work/" + name, bytes));
152
- }
153
- for (const [name, bytes] of Object.entries(archives)) {
154
- inputFiles.push(binaryFile("/work/" + name, bytes));
155
- }
156
- const argv = [
157
- "-T", "/work/gba.ld",
158
- "-o", "/work/main.elf",
159
- // Emit a GNU ld map so symbols({op:'resolve'/'lookup'/'list'/'map'}) can turn
160
- // a C global's name into an address (parsed by gnu-ld-map.js) — same as the
161
- // m68k/Genesis path. Without this the GBA build returns no symbol table.
162
- "-Map=/work/main.map",
163
- ...libraryPaths.flatMap((p) => ["-L", p]),
164
- ...Object.keys(objects).map((n) => "/work/" + n),
165
- ...libraries.map((l) => `-l${l}`),
166
- ...options,
167
- ];
168
- const r = await runIsolated({
169
- gluePath: armGlue("arm-none-eabi-ld.mjs"),
170
- argv,
171
- inputFiles,
172
- outputFiles: [
173
- { vfsPath: "/work/main.elf", encoding: "base64" },
174
- { vfsPath: "/work/main.map", encoding: "utf8" },
175
- ],
176
- });
177
- return {
178
- log: r.log,
179
- exitCode: r.exitCode,
180
- elf: getOutputBytes(r, "/work/main.elf"),
181
- map: getOutputText(r, "/work/main.map") || null,
182
- ...(r.crash ? { crash: r.crash, stage: "crash" } : {}),
183
- };
184
- }
60
+ export const runArmLd = runLd;
185
61
 
186
62
  /**
187
63
  * Strip an ELF down to a raw .gba ROM.
188
- *
189
- * @param {Object} args
190
- * @param {Uint8Array} args.elf
191
- * @param {string[]} [args.options]
64
+ * @param {{elf:Uint8Array, options?:string[]}} args
192
65
  * @returns {Promise<{log:string, exitCode:number, binary:Uint8Array|null, crash?:any}>}
193
66
  */
194
- export async function runArmObjcopy(args) {
195
- const { elf, options = [] } = args;
196
- /** @type {import("../_worker/run.js").InputFile[]} */
197
- const inputFiles = [binaryFile("/work/main.elf", elf)];
198
- const argv = [
199
- "-O", "binary",
200
- ...options,
201
- "/work/main.elf",
202
- "/work/main.gba",
203
- ];
204
- const r = await runIsolated({
205
- gluePath: armGlue("arm-none-eabi-objcopy.mjs"),
206
- argv,
207
- inputFiles,
208
- outputFiles: [{ vfsPath: "/work/main.gba", encoding: "base64" }],
209
- });
210
- return {
211
- log: r.log,
212
- exitCode: r.exitCode,
213
- binary: getOutputBytes(r, "/work/main.gba"),
214
- ...(r.crash ? { crash: r.crash, stage: "crash" } : {}),
215
- };
216
- }
67
+ export const runArmObjcopy = runObjcopy;
@@ -5,30 +5,25 @@
5
5
  // directives.
6
6
 
7
7
  import { fileURLToPath } from "node:url";
8
- import { existsSync } from "node:fs";
9
8
  import path from "node:path";
10
9
 
11
10
  import { runIsolated, textFile, binaryFile, getOutputBytes, getOutputText } from "../_worker/run.js";
11
+ import { resolveGlueFile } from "../common/wasm-tool.js";
12
12
 
13
13
  const __filename = fileURLToPath(import.meta.url);
14
14
  const __dirname = path.dirname(__filename);
15
15
 
16
16
  // asar's WASM ships in romdev-platform-snes (the SNES platform is the only
17
- // consumer). Resolve from that package; fall back to a local copy under src/
18
- // if present (transition / dev). The package is a hard dep of romdev.
19
- function resolveAsarGlue() {
20
- try {
21
- const u = import.meta.resolve("romdev-platform-snes");
22
- const p = path.join(path.dirname(fileURLToPath(u)), "wasm", "asar.js");
23
- if (existsSync(p)) return p;
24
- } catch { /* package not resolvable — fall through to local */ }
25
- const local = path.join(__dirname, "wasm", "asar.js");
26
- if (existsSync(local)) return local;
27
- throw new Error("asar WASM not found — install romdev-platform-snes");
28
- }
29
- // Lazy + memoized: resolve only on the first asar (SNES asm) build, not at boot.
17
+ // consumer); a local src/ copy is the dev fallback. Lazy + memoized: resolve
18
+ // only on the first asar (SNES asm) build, not at boot.
30
19
  let _gluePath;
31
- const gluePath = () => (_gluePath ??= resolveAsarGlue());
20
+ const gluePath = () =>
21
+ (_gluePath ??= resolveGlueFile({
22
+ pkg: "romdev-platform-snes",
23
+ file: "asar.js",
24
+ localDir: __dirname,
25
+ label: "asar",
26
+ }));
32
27
 
33
28
  /**
34
29
  * Assemble an asar source.