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.
- package/AGENTS.md +19 -16
- package/CHANGELOG.md +184 -0
- package/README.md +5 -5
- package/examples/README.md +1 -0
- package/examples/gametank/templates/gt_draw.h +91 -0
- package/examples/gametank/templates/gt_hud.h +89 -0
- package/examples/gametank/templates/gt_palette.h +59 -0
- package/examples/gametank/templates/gt_sound.h +90 -0
- package/examples/gametank/templates/gt_sprites.h +53 -0
- package/examples/gametank/templates/platformer.c +318 -0
- package/examples/gametank/templates/puzzle.c +295 -0
- package/examples/gametank/templates/racing.c +139 -0
- package/examples/gametank/templates/shmup.c +277 -0
- package/examples/gametank/templates/sports.c +119 -0
- package/package.json +24 -22
- package/src/cores/capabilities.js +24 -0
- package/src/cores/registry.js +7 -1
- package/src/host/LibretroHost.js +90 -17
- package/src/host/callbacks.js +20 -2
- package/src/host/cpu-state.js +17 -0
- package/src/host/gametank-acp-state.js +53 -0
- package/src/http/skill-doc.js +10 -6
- package/src/mcp/tools/audio.js +1 -1
- package/src/mcp/tools/cart-parts.js +76 -0
- package/src/mcp/tools/lifecycle.js +1 -1
- package/src/mcp/tools/platform-tools.js +9 -1
- package/src/platforms/gametank/lib/gt/audio/acp_image.bin +0 -0
- package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.c +55 -0
- package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.h +34 -0
- package/src/platforms/gametank/lib/gt/audio/audio_fw.asm +223 -0
- package/src/platforms/gametank/lib/gt/audio/audio_fw_blob.s +8 -0
- package/src/platforms/gametank/lib/gt/audio/instruments.c +79 -0
- package/src/platforms/gametank/lib/gt/audio/instruments.h +25 -0
- package/src/platforms/gametank/lib/gt/audio/music.c +370 -0
- package/src/platforms/gametank/lib/gt/audio/music.h +35 -0
- package/src/platforms/gametank/lib/gt/audio/note_numbers.h +132 -0
- package/src/platforms/gametank/lib/gt/audio/scaled_sines.raw +0 -0
- package/src/platforms/gametank/lib/gt/audio/sine_256_-63_63.bin +0 -0
- package/src/platforms/gametank/lib/gt/banking.c +29 -0
- package/src/platforms/gametank/lib/gt/banking.h +8 -0
- package/src/platforms/gametank/lib/gt/banking2.s +41 -0
- package/src/platforms/gametank/lib/gt/crt0.s +102 -0
- package/src/platforms/gametank/lib/gt/draw_logo.s +55 -0
- package/src/platforms/gametank/lib/gt/feature/persist/persist.c +103 -0
- package/src/platforms/gametank/lib/gt/feature/persist/persist.h +13 -0
- package/src/platforms/gametank/lib/gt/feature/random/random.c +40 -0
- package/src/platforms/gametank/lib/gt/feature/random/random.h +18 -0
- package/src/platforms/gametank/lib/gt/feature/text/text.c +111 -0
- package/src/platforms/gametank/lib/gt/feature/text/text.h +25 -0
- package/src/platforms/gametank/lib/gt/gametank.c +12 -0
- package/src/platforms/gametank/lib/gt/gametank.h +80 -0
- package/src/platforms/gametank/lib/gt/gametank_logo.inc +393 -0
- package/src/platforms/gametank/lib/gt/gen/assets/assets_index.h +9 -0
- package/src/platforms/gametank/lib/gt/gen/assets/sdk_default.h +5 -0
- package/src/platforms/gametank/lib/gt/gen/bank_nums.h +11 -0
- package/src/platforms/gametank/lib/gt/gen/modules_enabled.h +4 -0
- package/src/platforms/gametank/lib/gt/gen/modules_enabled.inc +4 -0
- package/src/platforms/gametank/lib/gt/gfx/draw_direct.c +132 -0
- package/src/platforms/gametank/lib/gt/gfx/draw_direct.h +75 -0
- package/src/platforms/gametank/lib/gt/gfx/draw_queue.c +177 -0
- package/src/platforms/gametank/lib/gt/gfx/draw_queue.h +35 -0
- package/src/platforms/gametank/lib/gt/gfx/draw_util.s +157 -0
- package/src/platforms/gametank/lib/gt/gfx/gfx_sys.c +43 -0
- package/src/platforms/gametank/lib/gt/gfx/gfx_sys.h +46 -0
- package/src/platforms/gametank/lib/gt/gfx/sprites.c +216 -0
- package/src/platforms/gametank/lib/gt/gfx/sprites.h +41 -0
- package/src/platforms/gametank/lib/gt/init.c +9 -0
- package/src/platforms/gametank/lib/gt/input.c +26 -0
- package/src/platforms/gametank/lib/gt/input.h +20 -0
- package/src/platforms/gametank/lib/gt/interrupt.s +67 -0
- package/src/platforms/gametank/lib/gt/vectors.s +14 -0
- package/src/platforms/gametank/lib/gt/wait.s +53 -0
- package/src/playtest/playtest.js +174 -26
- package/src/toolchains/arm-none-eabi-gcc/gcc.js +39 -188
- package/src/toolchains/asar/asar.js +10 -15
- package/src/toolchains/cc65/cc65.js +82 -92
- package/src/toolchains/cc65/da65.js +12 -17
- package/src/toolchains/cc65/preset-resolver.js +13 -2
- package/src/toolchains/cc65/presets/gametank/gametank.h +80 -0
- package/src/toolchains/cc65/presets/gametank/sdk.cfg +32 -0
- package/src/toolchains/cc65/presets/gametank/sdk.crt0.s +61 -0
- package/src/toolchains/cc65/presets/gametank/sdk.vectors.s +11 -0
- package/src/toolchains/cc65/presets/gametank/single-bank.cfg +28 -0
- package/src/toolchains/cc65/presets/gametank/single-bank.crt0.s +71 -0
- package/src/toolchains/cc65/presets/gametank/single-bank.vectors.s +12 -0
- package/src/toolchains/common/c-build.js +109 -0
- package/src/toolchains/common/gcc-toolchain.js +164 -0
- package/src/toolchains/common/wasm-tool.js +101 -0
- package/src/toolchains/dasm/dasm.js +12 -18
- package/src/toolchains/gba-c/gba-c.js +253 -305
- package/src/toolchains/genesis-c/genesis-c.js +196 -225
- package/src/toolchains/index.js +58 -4
- package/src/toolchains/m68k-elf-gcc/gcc.js +39 -202
- package/src/toolchains/mips-c/mips-c.js +68 -78
- package/src/toolchains/mips-elf-gcc/gcc.js +55 -118
- package/src/toolchains/rgbds/rgbds.js +7 -19
- package/src/toolchains/sdcc/sdcc.js +35 -26
- package/src/toolchains/sh-c/sh-c.js +44 -52
- package/src/toolchains/sh-elf-gcc/gcc.js +55 -110
- package/src/toolchains/sjasm/sjasm.js +10 -14
- package/src/toolchains/snes-c/snes-c.js +125 -150
- package/src/toolchains/tcc816/tcc816.js +10 -15
- package/src/toolchains/vasm68k/vasm68k.js +11 -16
- package/src/toolchains/wladx/wladx.js +5 -17
- package/src/toolchains/z80/binutils.js +5 -11
package/src/playtest/playtest.js
CHANGED
|
@@ -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:
|
|
564
|
+
frequency: deviceSampleRate,
|
|
503
565
|
format: "s16",
|
|
504
566
|
});
|
|
505
567
|
audio.play();
|
|
506
|
-
log.debug(`[playtest] audio: ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
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 =
|
|
890
|
-
const
|
|
891
|
-
|
|
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(
|
|
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 ===
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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 {
|
|
19
|
+
import { makeGccToolchain } from "../common/gcc-toolchain.js";
|
|
22
20
|
|
|
23
|
-
const
|
|
24
|
-
const __dirname = path.dirname(__filename);
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
22
|
|
|
26
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
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
|
-
* @
|
|
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
|
|
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
|
|
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)
|
|
18
|
-
//
|
|
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 = () =>
|
|
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.
|