romdevtools 0.84.0 → 0.85.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.
- package/CHANGELOG.md +30 -0
- package/package.json +9 -5
- package/src/host/JsGameHost.js +178 -0
- package/src/host/WasmcartHost.js +263 -0
- package/src/mcp/server.js +17 -0
- package/src/mcp/state.js +28 -4
- package/src/mcp/tools/frame.js +5 -3
- package/src/mcp/tools/index.js +2 -1
- package/src/mcp/tools/lifecycle.js +36 -3
- package/src/mcp/tools/native-pack.js +134 -0
- package/src/playtest/playtest.js +1 -1
- package/src/playtest/resampler/build.sh +0 -19
- package/src/playtest/resampler/index.mjs +0 -75
- package/src/playtest/resampler/resampler.c +0 -129
- package/src/playtest/resampler/resampler.mjs +0 -2
- package/src/playtest/resampler/resampler.wasm +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ All notable changes to `romdevtools`. Dates are release dates.
|
|
|
4
4
|
(Published as `romdev-mcp` through 0.11.0; renamed to `romdevtools` in 0.13.0 —
|
|
5
5
|
the `romdev-mcp` bin is kept as an alias.)
|
|
6
6
|
|
|
7
|
+
## 0.85.0 — 2026-07-01
|
|
8
|
+
|
|
9
|
+
- **Native-runtime game kinds — wasmcart + jsgame are now first-class hosts.** romdev is a
|
|
10
|
+
uniform harness for runnable game artifacts; libretro emulator cores were one kind, and now
|
|
11
|
+
wasmcart carts (`.wasc`) and jsgame web games (`.jsgame`) are two more. They run native
|
|
12
|
+
WASM/JS in-process (not emulation) but share the same run/see/drive surface:
|
|
13
|
+
`loadMedia({platform:'wasmcart'|'jsgame'}) → frame(step/screenshot/verify) → input`, driven
|
|
14
|
+
through the existing tools. A capability descriptor per host keeps emulator-only tools
|
|
15
|
+
(memory regions, cpuState, disasm, cheats) absent for these kinds.
|
|
16
|
+
- `WasmcartHost` wraps wasmcart's `CartHost.runFrame`; framebuffer is XRGB8888 (decoded as-is).
|
|
17
|
+
**WASM introspection** (the V8-runtime bonus): read/write the real cart heap, enumerate the
|
|
18
|
+
module's exports, read the WCInfo. Needs `wasmcart@^0.3.0` (its new `setFixedStep`).
|
|
19
|
+
- `JsGameHost` wraps jsgamelauncher's new headless `createHostSession` (`rungame@^0.13.0`):
|
|
20
|
+
host-stepped rAF + offscreen-canvas readback, synthetic gamepad input, **JS introspection**
|
|
21
|
+
(`globalThis._jsg`). Requires the server to run with `--experimental-vm-modules` — the server
|
|
22
|
+
now SELF-RE-EXECS with that flag if missing (harmless for everything else).
|
|
23
|
+
- **`pack` tool — the "build" (zip) step for native kinds.** `pack({target:'wasc'|'jsgame',
|
|
24
|
+
source, outputPath})` assembles a source directory into its distributable `.wasc`/`.jsgame`
|
|
25
|
+
archive (a zip; NOT a compiler — wasmcart is any-language→WASM, jsgame is plain JS). Rounds
|
|
26
|
+
out the build→run→see loop for these kinds. Zero new deps (fflate).
|
|
27
|
+
|
|
28
|
+
## 0.84.1 — 2026-06-30
|
|
29
|
+
|
|
30
|
+
- **New package `romdev-audio-resampler`** — the WASM+SIMD linear audio resampler (interleaved
|
|
31
|
+
S16LE stereo, any src→dst rate) is carved out of `src/playtest/resampler/` into its own
|
|
32
|
+
standalone, zero-dep npm package so other projects can consume it (retroemu's terminal audio
|
|
33
|
+
path needs it to resample fractional-rate cores like the GameTank ACP's ~13983 Hz up to a
|
|
34
|
+
fixed device rate — the way a libretro frontend does). romdevtools now depends on it; the
|
|
35
|
+
playtest audio sink imports it instead of the in-tree copy. No behavior change here.
|
|
36
|
+
|
|
7
37
|
## 0.84.0 — 2026-06-30
|
|
8
38
|
|
|
9
39
|
**The 3D GL cores are now PLAYABLE in the SDL playtest window — N64 + PS1 at full speed.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "romdevtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.85.0",
|
|
4
4
|
"description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 17 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, PlayStation, N64, Dreamcast, ...) via WASM toolchains + emulator cores. Use over plain HTTP, as an Agent Skill, or as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/mcp/server.js",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"postinstall": "node src/install/postinstall.mjs",
|
|
14
14
|
"smoke": "node --enable-source-maps src/cli/smoke.js",
|
|
15
15
|
"mcp": "node --enable-source-maps src/mcp/server.js",
|
|
16
|
-
"test": "node --test 'src/**/*.test.js' 'test/**/*.test.js'"
|
|
16
|
+
"test": "node --test 'src/**/*.test.js' 'test/**/*.test.js'",
|
|
17
|
+
"test:jsgame": "node --experimental-vm-modules --test-force-exit --test test/jsgame-host.test.js"
|
|
17
18
|
},
|
|
18
19
|
"keywords": [
|
|
19
20
|
"mcp",
|
|
@@ -52,16 +53,17 @@
|
|
|
52
53
|
"pngjs": "^7.0.0",
|
|
53
54
|
"romdev-analysis": "0.1.0",
|
|
54
55
|
"romdev-analysis-decompiler": "0.1.0",
|
|
56
|
+
"romdev-audio-resampler": "0.1.0",
|
|
57
|
+
"romdev-core-beetle-psx-hw": "0.2.0",
|
|
55
58
|
"romdev-core-bluemsx": "0.7.0",
|
|
56
59
|
"romdev-core-fceumm": "0.11.0",
|
|
60
|
+
"romdev-core-flycast": "0.2.0",
|
|
57
61
|
"romdev-core-gambatte": "0.10.0",
|
|
62
|
+
"romdev-core-gametank": "0.1.0",
|
|
58
63
|
"romdev-core-geargrafx": "0.8.0",
|
|
59
64
|
"romdev-core-gpgx": "0.13.0",
|
|
60
65
|
"romdev-core-handy": "0.8.0",
|
|
61
|
-
"romdev-core-gametank": "0.1.0",
|
|
62
|
-
"romdev-core-flycast": "0.2.0",
|
|
63
66
|
"romdev-core-parallel-n64": "0.2.0",
|
|
64
|
-
"romdev-core-beetle-psx-hw": "0.2.0",
|
|
65
67
|
"romdev-core-prosystem": "0.9.0",
|
|
66
68
|
"romdev-core-vice": "0.10.0",
|
|
67
69
|
"romdev-famitone": "0.1.0",
|
|
@@ -78,8 +80,10 @@
|
|
|
78
80
|
"romdev-toolchain-vasm": "0.1.1",
|
|
79
81
|
"romdev-xgm2": "0.1.0",
|
|
80
82
|
"romdev_game_codes": "0.1.0",
|
|
83
|
+
"rungame": "^0.13.0",
|
|
81
84
|
"socket.io": "^4.8.3",
|
|
82
85
|
"swagger-ui-dist": "^5.17.14",
|
|
86
|
+
"wasmcart": "^0.3.0",
|
|
83
87
|
"yauzl": "^3.3.1",
|
|
84
88
|
"zod": "^4.4.3"
|
|
85
89
|
},
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// JsGameHost — adapts jsgamelauncher's (rungame) headless host session to the subset
|
|
2
|
+
// of the LibretroHost surface romdev's shared tools drive.
|
|
3
|
+
//
|
|
4
|
+
// A jsgame is a JS web game run in a sandboxed V8 realm (no emulation). rungame's
|
|
5
|
+
// createHostSession() gives us a window-less, host-stepped session: we pump the game's
|
|
6
|
+
// requestAnimationFrame one frame at a time and read the offscreen canvas back as RGBA.
|
|
7
|
+
// So this host implements RUN + SEE + DRIVE (loadMedia / stepFrames / getFramebuffer /
|
|
8
|
+
// screenshot / setInput / status) but NOT the emulator-only surface (memory regions,
|
|
9
|
+
// cpuState, disasm, cheats). The V8 bonus here is JS-heap/globals introspection.
|
|
10
|
+
//
|
|
11
|
+
// REQUIRES the process to run with --experimental-vm-modules (rungame's realm uses
|
|
12
|
+
// vm.SourceTextModule). loadMedia throws a clear message if it's missing.
|
|
13
|
+
|
|
14
|
+
import { createHostSession } from "rungame";
|
|
15
|
+
import { framebufferToScreenshot, framebufferToRgba } from "./framebuffer.js";
|
|
16
|
+
import { ROMDEV_PIXEL_FORMAT_RGBA8888 } from "./retroConstants.js";
|
|
17
|
+
|
|
18
|
+
// canvas.data() is RGBA already — reuse romdev's RGBA framebuffer format (no decode).
|
|
19
|
+
const JSGAME_FB_FORMAT = ROMDEV_PIXEL_FORMAT_RGBA8888;
|
|
20
|
+
|
|
21
|
+
const DEFAULT_W = 640;
|
|
22
|
+
const DEFAULT_H = 480;
|
|
23
|
+
|
|
24
|
+
export class JsGameHost {
|
|
25
|
+
constructor({ log } = {}) {
|
|
26
|
+
this.kind = "jsgame";
|
|
27
|
+
this.hwRender = null;
|
|
28
|
+
this._log = log || (() => {});
|
|
29
|
+
this.session = null;
|
|
30
|
+
this.state = { lastFrame: null };
|
|
31
|
+
this._inputPorts = [{}];
|
|
32
|
+
this.status = {
|
|
33
|
+
loaded: false,
|
|
34
|
+
platform: null,
|
|
35
|
+
mediaPath: null,
|
|
36
|
+
mediaKind: "jsgame",
|
|
37
|
+
frameCount: 0,
|
|
38
|
+
fbWidth: 0,
|
|
39
|
+
fbHeight: 0,
|
|
40
|
+
coreFps: 60,
|
|
41
|
+
displayAspect: 0,
|
|
42
|
+
audioSampleRate: 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getCapabilities() {
|
|
47
|
+
return {
|
|
48
|
+
kind: "jsgame",
|
|
49
|
+
canStepFrames: true,
|
|
50
|
+
canScreenshot: true,
|
|
51
|
+
canSetInput: true,
|
|
52
|
+
hasAudio: false, // audio plays through the game's own WebAudio graph; not captured here yet
|
|
53
|
+
hasSaveData: false,
|
|
54
|
+
hasMemoryRegions: false,
|
|
55
|
+
// Runs in real V8 — JS heap/globals introspection (globalThis._jsg, the realm)
|
|
56
|
+
// is the bonus axis an emulator can't offer. Exposed via jsGlobals()/jsEval hooks.
|
|
57
|
+
hasJsIntrospection: true,
|
|
58
|
+
hasCpuState: false,
|
|
59
|
+
hasDisasm: false,
|
|
60
|
+
hasCheats: false,
|
|
61
|
+
hasWatchpoints: false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load a jsgame (dir / .jsg / .jsgame). `mediaPath` only — a jsgame is a directory
|
|
67
|
+
* tree / archive on disk, not in-memory bytes. Async: settles initial asset loads +
|
|
68
|
+
* renders a first frame so screenshot works immediately.
|
|
69
|
+
*/
|
|
70
|
+
async loadMedia({ platform, path: mediaPath, width, height } = {}) {
|
|
71
|
+
if (!mediaPath) throw new Error("JsGameHost.loadMedia: provide `path` (a jsgame dir/.jsgame). In-memory bytes aren't supported for jsgame.");
|
|
72
|
+
if (typeof globalThis.vm?.SourceTextModule === "undefined") {
|
|
73
|
+
// createHostSession will throw its own clear message if the flag is missing;
|
|
74
|
+
// surface it early with the fix.
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
this.session = await createHostSession(mediaPath, {
|
|
78
|
+
width: width || DEFAULT_W,
|
|
79
|
+
height: height || DEFAULT_H,
|
|
80
|
+
});
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (String(e.message).includes("SourceTextModule")) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
"jsgame requires the romdev server to run with node --experimental-vm-modules " +
|
|
85
|
+
"(rungame sandboxes games in a vm realm). Restart the server with that flag. " +
|
|
86
|
+
"Original: " + e.message,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.status.loaded = true;
|
|
93
|
+
this.status.platform = platform || "jsgame";
|
|
94
|
+
this.status.mediaPath = mediaPath;
|
|
95
|
+
this.status.mediaKind = "jsgame";
|
|
96
|
+
this.status.frameCount = 0;
|
|
97
|
+
|
|
98
|
+
// A first frame so status + screenshot are valid immediately (createHostSession
|
|
99
|
+
// already settled initial async loads).
|
|
100
|
+
await this.stepFrames(1);
|
|
101
|
+
return this.status;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Map romdev's setInput vocabulary → a jsgame standard-pad object. */
|
|
105
|
+
_padFromInput(input) {
|
|
106
|
+
if (!input || typeof input !== "object") return {};
|
|
107
|
+
// Pass through the standard button names jsgame's synthetic pad understands
|
|
108
|
+
// (a,b,x,y,l1,r1,l2,r2,select,start,l3,r3,up,down,left,right + axes lx/ly/rx/ry).
|
|
109
|
+
return { ...input };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
setInput(input) {
|
|
113
|
+
if (input && Array.isArray(input.ports)) {
|
|
114
|
+
this._inputPorts = input.ports.map((p) => this._padFromInput(p));
|
|
115
|
+
} else {
|
|
116
|
+
this._inputPorts = [this._padFromInput(input)];
|
|
117
|
+
}
|
|
118
|
+
if (this.session) this.session.setInput(this._inputPorts);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Advance n frames. stepFrame is async (it yields so the game's async work settles),
|
|
123
|
+
* so this returns a Promise — the tools await it (LibretroHost.stepFrames is sync, but
|
|
124
|
+
* the frame tool already awaits host.stepFrames for proxied cores).
|
|
125
|
+
*/
|
|
126
|
+
async stepFrames(n) {
|
|
127
|
+
if (!this.session) throw new Error("no jsgame loaded — loadMedia first");
|
|
128
|
+
this.session.setInput(this._inputPorts);
|
|
129
|
+
for (let i = 0; i < n; i++) {
|
|
130
|
+
await this.session.stepFrame();
|
|
131
|
+
this.status.frameCount++;
|
|
132
|
+
}
|
|
133
|
+
const f = this.session.readFrame(); // { data (RGBA), width, height }
|
|
134
|
+
this.state.lastFrame = {
|
|
135
|
+
width: f.width,
|
|
136
|
+
height: f.height,
|
|
137
|
+
pixels: f.data instanceof Uint8Array ? f.data : new Uint8Array(f.data.buffer || f.data),
|
|
138
|
+
pitch: f.width * 4,
|
|
139
|
+
format: JSGAME_FB_FORMAT,
|
|
140
|
+
};
|
|
141
|
+
this.status.fbWidth = f.width;
|
|
142
|
+
this.status.fbHeight = f.height;
|
|
143
|
+
return n;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getFramebuffer() {
|
|
147
|
+
if (!this.state.lastFrame) throw new Error("no frame produced yet — step frames first");
|
|
148
|
+
return this.state.lastFrame;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
screenshot() {
|
|
152
|
+
const f = this.getFramebuffer();
|
|
153
|
+
return framebufferToScreenshot(f.width, f.height, f.pixels, f.pitch, f.format);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
screenshotRgba() {
|
|
157
|
+
const f = this.getFramebuffer();
|
|
158
|
+
return { width: f.width, height: f.height,
|
|
159
|
+
pixels: framebufferToRgba(f.width, f.height, f.pixels, f.pitch, f.format) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── JS introspection (the V8-runtime bonus) ──────────────────────────────────
|
|
163
|
+
/** The game's exposed globals bag (rungame sets globalThis._jsg = {controllers, rom, …}). */
|
|
164
|
+
jsGlobals() {
|
|
165
|
+
return globalThis._jsg ? Object.keys(globalThis._jsg) : [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
cheatsSupported() { return false; }
|
|
169
|
+
|
|
170
|
+
reset() { this.status.frameCount = 0; }
|
|
171
|
+
|
|
172
|
+
destroy() {
|
|
173
|
+
if (this.session) { try { this.session.destroy(); } catch { /* ignore */ } }
|
|
174
|
+
this.session = null;
|
|
175
|
+
this.state.lastFrame = null;
|
|
176
|
+
this.status.loaded = false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// WasmcartHost — adapts wasmcart's CartHost to the subset of the LibretroHost
|
|
2
|
+
// surface that romdev's shared tools (frame/input/playtest/livestream) drive.
|
|
3
|
+
//
|
|
4
|
+
// wasmcart carts aren't emulated CPUs — they're native WASM game modules that
|
|
5
|
+
// export wc_render() and draw into a framebuffer. So this host implements the
|
|
6
|
+
// RUN + SEE + DRIVE surface (loadMedia / stepFrames / getFramebuffer / screenshot
|
|
7
|
+
// / setInput / status) but NOT the emulator-only surface (readMemory regions,
|
|
8
|
+
// cpuState, watchpoints, disasm, cheats). Those tools consult getCapabilities()
|
|
9
|
+
// and refuse with a pointer rather than pretending.
|
|
10
|
+
//
|
|
11
|
+
// CartHost.runFrame(pads) is a pure step function returning
|
|
12
|
+
// { framebuffer: Uint8Array (XRGB8888), width, height, audio }
|
|
13
|
+
// which maps cleanly onto stepFrames + state.lastFrame. We drive its fixed-step
|
|
14
|
+
// clock so frame N is reproducible (setFixedStep), matching how romdev steps a core.
|
|
15
|
+
|
|
16
|
+
import { CartHost } from "wasmcart";
|
|
17
|
+
import { framebufferToScreenshot, framebufferToRgba } from "./framebuffer.js";
|
|
18
|
+
import { RETRO_PIXEL_FORMAT_XRGB8888 } from "./retroConstants.js";
|
|
19
|
+
|
|
20
|
+
// wasmcart framebuffer is uint32 XRGB8888 (0x00RRGGBB) → bytes [B,G,R,X] in LE
|
|
21
|
+
// memory, identical to libretro's XRGB8888, so romdev's decoder handles it as-is.
|
|
22
|
+
const WASMCART_FB_FORMAT = RETRO_PIXEL_FORMAT_XRGB8888;
|
|
23
|
+
|
|
24
|
+
// libretro RETRO_DEVICE_ID_JOYPAD bit → wasmcart pad button field. romdev's
|
|
25
|
+
// setInput speaks the libretro button vocabulary (b,y,select,start,up,down,left,
|
|
26
|
+
// right,a,x,l,r,…); wasmcart pads carry the same conceptual buttons. We translate
|
|
27
|
+
// the input bitmask/object into the pad object shape CartHost._writePads expects.
|
|
28
|
+
const JOYPAD = {
|
|
29
|
+
b: 0, y: 1, select: 2, start: 3, up: 4, down: 5, left: 6, right: 7,
|
|
30
|
+
a: 8, x: 9, l: 10, r: 11, l2: 12, r2: 13, l3: 14, r3: 15,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class WasmcartHost {
|
|
34
|
+
constructor({ log } = {}) {
|
|
35
|
+
this.kind = "wasmcart";
|
|
36
|
+
this.hwRender = null; // wasmcart GL carts render into their own FB; no libretro HW-render path
|
|
37
|
+
this._log = log || (() => {});
|
|
38
|
+
this.cart = null;
|
|
39
|
+
this.state = { lastFrame: null };
|
|
40
|
+
this._inputPorts = [{}]; // per-port pad objects, applied each stepFrames
|
|
41
|
+
this.status = {
|
|
42
|
+
loaded: false,
|
|
43
|
+
platform: null,
|
|
44
|
+
mediaPath: null,
|
|
45
|
+
mediaKind: "cart",
|
|
46
|
+
frameCount: 0,
|
|
47
|
+
fbWidth: 0,
|
|
48
|
+
fbHeight: 0,
|
|
49
|
+
coreFps: 60,
|
|
50
|
+
displayAspect: 0,
|
|
51
|
+
audioSampleRate: 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Capability descriptor. The shared tools branch on this; emulator-only tools
|
|
57
|
+
* see the false flags and refuse with a clear pointer instead of no-oping.
|
|
58
|
+
*/
|
|
59
|
+
getCapabilities() {
|
|
60
|
+
return {
|
|
61
|
+
kind: "wasmcart",
|
|
62
|
+
canStepFrames: true,
|
|
63
|
+
canScreenshot: true,
|
|
64
|
+
canSetInput: true,
|
|
65
|
+
hasAudio: true,
|
|
66
|
+
hasSaveData: true,
|
|
67
|
+
// No EMULATED memory regions (there's no CPU/address-space to name), but the
|
|
68
|
+
// cart runs in real V8 — so we DO expose the WASM linear memory + exports for
|
|
69
|
+
// introspection an emulator can't give: peek the actual cart heap, list the
|
|
70
|
+
// module's exported functions/globals. Different axis than emulator regions.
|
|
71
|
+
hasMemoryRegions: false,
|
|
72
|
+
hasWasmIntrospection: true,
|
|
73
|
+
hasCpuState: false,
|
|
74
|
+
hasDisasm: false,
|
|
75
|
+
hasCheats: false,
|
|
76
|
+
hasWatchpoints: false,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Load a .wasc cart (or dev directory). `mediaPath` is a real path; `bytes` is
|
|
82
|
+
* in-memory cart zip. Mirrors LibretroHost.loadMedia's post-conditions:
|
|
83
|
+
* status.loaded + a first framebuffer so screenshot works immediately.
|
|
84
|
+
*/
|
|
85
|
+
async loadMedia({ platform, path: mediaPath, bytes, glBackend } = {}) {
|
|
86
|
+
const source = bytes ?? mediaPath;
|
|
87
|
+
if (!source) throw new Error("WasmcartHost.loadMedia: provide `path` or `bytes`.");
|
|
88
|
+
|
|
89
|
+
this.cart = new CartHost();
|
|
90
|
+
await this.cart.load(source, glBackend ? { glBackend } : {});
|
|
91
|
+
// Deterministic clock: romdev steps frames, so frame N should be reproducible.
|
|
92
|
+
// Feature-detect — setFixedStep is a newer CartHost addition; older published
|
|
93
|
+
// versions fall back to wall-clock (still works, just non-deterministic timing).
|
|
94
|
+
if (typeof this.cart.setFixedStep === "function") {
|
|
95
|
+
this.cart.setFixedStep(1000 / 60);
|
|
96
|
+
this._deterministicClock = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.status.loaded = true;
|
|
100
|
+
this.status.platform = platform || "wasmcart";
|
|
101
|
+
this.status.mediaPath = typeof mediaPath === "string" ? mediaPath : null;
|
|
102
|
+
this.status.mediaKind = "cart";
|
|
103
|
+
this.status.frameCount = 0;
|
|
104
|
+
this.status.coreFps = 60;
|
|
105
|
+
|
|
106
|
+
// Settle a first frame so width/height and a framebuffer exist (carts often
|
|
107
|
+
// finalize their resolution during the first render).
|
|
108
|
+
this.stepFrames(1);
|
|
109
|
+
this.status.fbWidth = this.state.lastFrame?.width || 0;
|
|
110
|
+
this.status.fbHeight = this.state.lastFrame?.height || 0;
|
|
111
|
+
return this.status;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Translate romdev's setInput vocabulary into a wasmcart pad object. */
|
|
115
|
+
_padFromInput(input) {
|
|
116
|
+
if (!input || typeof input !== "object") return {};
|
|
117
|
+
const pad = { up: 0, down: 0, left: 0, right: 0, a: 0, b: 0, x: 0, y: 0,
|
|
118
|
+
l: 0, r: 0, start: 0, select: 0 };
|
|
119
|
+
for (const key of Object.keys(pad)) {
|
|
120
|
+
if (input[key]) pad[key] = 1;
|
|
121
|
+
}
|
|
122
|
+
return pad;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Store input to apply on subsequent stepFrames. Accepts romdev's setInput shapes:
|
|
127
|
+
* a flat button object ({a:true,right:true}) OR the multi-port form
|
|
128
|
+
* ({ports:[{...p0}, {...p1}]}). wasmcart pads are per-controller, so we keep all
|
|
129
|
+
* ports and hand them to CartHost.runFrame.
|
|
130
|
+
*/
|
|
131
|
+
setInput(input) {
|
|
132
|
+
if (input && Array.isArray(input.ports)) {
|
|
133
|
+
this._inputPorts = input.ports.map((p) => this._padFromInput(p));
|
|
134
|
+
} else {
|
|
135
|
+
this._inputPorts = [this._padFromInput(input)];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Advance n frames, driving CartHost.runFrame with the current input. */
|
|
140
|
+
stepFrames(n) {
|
|
141
|
+
if (!this.cart) throw new Error("no cart loaded — loadMedia first");
|
|
142
|
+
let r = null;
|
|
143
|
+
for (let i = 0; i < n; i++) {
|
|
144
|
+
r = this.cart.runFrame(this._inputPorts);
|
|
145
|
+
this.status.frameCount++;
|
|
146
|
+
}
|
|
147
|
+
if (r) {
|
|
148
|
+
// Copy the framebuffer view out (CartHost returns a subarray into WASM memory,
|
|
149
|
+
// which can move on the next frame). pitch = width*4 (no row padding).
|
|
150
|
+
this.state.lastFrame = {
|
|
151
|
+
width: r.width,
|
|
152
|
+
height: r.height,
|
|
153
|
+
pixels: new Uint8Array(r.framebuffer), // copy
|
|
154
|
+
pitch: r.width * 4,
|
|
155
|
+
format: WASMCART_FB_FORMAT,
|
|
156
|
+
};
|
|
157
|
+
this.state.lastAudio = r.audio || null;
|
|
158
|
+
this.status.fbWidth = r.width;
|
|
159
|
+
this.status.fbHeight = r.height;
|
|
160
|
+
}
|
|
161
|
+
return n;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** @returns {{width,height,pixels,pitch,format}} the last rendered frame. */
|
|
165
|
+
getFramebuffer() {
|
|
166
|
+
if (!this.state.lastFrame) throw new Error("no frame produced yet — step frames first");
|
|
167
|
+
return this.state.lastFrame;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** PNG (base64) of the current frame — same output as LibretroHost.screenshot(). */
|
|
171
|
+
screenshot() {
|
|
172
|
+
const f = this.getFramebuffer();
|
|
173
|
+
return framebufferToScreenshot(f.width, f.height, f.pixels, f.pitch, f.format);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Flat RGBA Uint8Array of the current frame (for the livestream/side-by-side). */
|
|
177
|
+
screenshotRgba() {
|
|
178
|
+
const f = this.getFramebuffer();
|
|
179
|
+
return { width: f.width, height: f.height,
|
|
180
|
+
pixels: framebufferToRgba(f.width, f.height, f.pixels, f.pitch, f.format) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Save data (SRAM equivalent) if the cart declares any. */
|
|
184
|
+
getSaveData() {
|
|
185
|
+
return this.cart ? this.cart.getSaveData() : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── WASM introspection (the V8-runtime bonus an emulator can't offer) ─────────
|
|
189
|
+
//
|
|
190
|
+
// A wasmcart runs as a real WebAssembly instance in V8, so we can read its actual
|
|
191
|
+
// linear memory and enumerate its exports. This is NOT an emulated address space
|
|
192
|
+
// with named regions — it's the cart's own heap. `readMemory` therefore takes a
|
|
193
|
+
// raw byte offset into that heap (no region arg), and `wasmExports` lists what the
|
|
194
|
+
// module exposes.
|
|
195
|
+
|
|
196
|
+
/** Total size (bytes) of the cart's WASM linear memory. */
|
|
197
|
+
wasmMemorySize() {
|
|
198
|
+
if (!this.cart?.memory) return 0;
|
|
199
|
+
return this.cart.memory.buffer.byteLength;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Read `length` bytes from the cart's WASM linear memory at byte `offset`.
|
|
204
|
+
* @returns {Uint8Array} a copy (the heap can move on the next frame).
|
|
205
|
+
*/
|
|
206
|
+
readMemory(offset, length) {
|
|
207
|
+
if (!this.cart?.memory) throw new Error("no cart loaded — loadMedia first");
|
|
208
|
+
const heap = new Uint8Array(this.cart.memory.buffer);
|
|
209
|
+
const off = offset >>> 0;
|
|
210
|
+
const end = Math.min(heap.length, off + (length >>> 0));
|
|
211
|
+
if (off >= heap.length) {
|
|
212
|
+
throw new Error(`offset ${off} is past the ${heap.length}-byte WASM heap`);
|
|
213
|
+
}
|
|
214
|
+
return heap.slice(off, end);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Write bytes into the cart's WASM linear memory at byte `offset`. Useful for
|
|
219
|
+
* poking cart state during debugging (there are no cheats/watchpoints, but the
|
|
220
|
+
* heap is real and writable).
|
|
221
|
+
*/
|
|
222
|
+
writeMemory(offset, bytes) {
|
|
223
|
+
if (!this.cart?.memory) throw new Error("no cart loaded — loadMedia first");
|
|
224
|
+
const heap = new Uint8Array(this.cart.memory.buffer);
|
|
225
|
+
heap.set(bytes, offset >>> 0);
|
|
226
|
+
return bytes.length;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Enumerate the cart module's WASM exports (function/memory/global/table names + kinds). */
|
|
230
|
+
wasmExports() {
|
|
231
|
+
if (!this.cart?.instance) return [];
|
|
232
|
+
const ex = this.cart.instance.exports;
|
|
233
|
+
return Object.keys(ex).map((name) => ({
|
|
234
|
+
name,
|
|
235
|
+
kind:
|
|
236
|
+
typeof ex[name] === "function" ? "function"
|
|
237
|
+
: ex[name] instanceof WebAssembly.Memory ? "memory"
|
|
238
|
+
: ex[name] instanceof WebAssembly.Global ? "global"
|
|
239
|
+
: ex[name] instanceof WebAssembly.Table ? "table"
|
|
240
|
+
: typeof ex[name],
|
|
241
|
+
...(ex[name] instanceof WebAssembly.Global ? { value: ex[name].value } : {}),
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The cart's parsed WCInfo (fbPtr, savePtr/saveSize, width/height, abi). */
|
|
246
|
+
getInfo() {
|
|
247
|
+
return this.cart ? this.cart.getInfo() : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
cheatsSupported() { return false; }
|
|
251
|
+
|
|
252
|
+
reset() {
|
|
253
|
+
// Re-load would be needed for a true reset; expose frameCount reset as a soft reset hook.
|
|
254
|
+
this.status.frameCount = 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
destroy() {
|
|
258
|
+
if (this.cart) { try { this.cart.destroy(); } catch { /* ignore */ } }
|
|
259
|
+
this.cart = null;
|
|
260
|
+
this.state.lastFrame = null;
|
|
261
|
+
this.status.loaded = false;
|
|
262
|
+
}
|
|
263
|
+
}
|
package/src/mcp/server.js
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Self-re-exec with --experimental-vm-modules if it's missing. The jsgame host kind
|
|
3
|
+
// (rungame) sandboxes games in a vm.SourceTextModule realm, which requires that flag.
|
|
4
|
+
// It's harmless for everything else, so we enable it once up front rather than making
|
|
5
|
+
// users remember it. Guard against a re-exec loop via a sentinel env var.
|
|
6
|
+
if (
|
|
7
|
+
!process.execArgv.some((a) => a.includes("experimental-vm-modules")) &&
|
|
8
|
+
!process.env.ROMDEV_REEXEChild
|
|
9
|
+
) {
|
|
10
|
+
const { spawnSync } = await import("node:child_process");
|
|
11
|
+
const r = spawnSync(
|
|
12
|
+
process.execPath,
|
|
13
|
+
["--experimental-vm-modules", ...process.execArgv, process.argv[1], ...process.argv.slice(2)],
|
|
14
|
+
{ stdio: "inherit", env: { ...process.env, ROMDEV_REEXEChild: "1" } },
|
|
15
|
+
);
|
|
16
|
+
process.exit(r.status ?? 0);
|
|
17
|
+
}
|
|
18
|
+
|
|
2
19
|
// romdev — MCP server (Streamable HTTP).
|
|
3
20
|
//
|
|
4
21
|
// Exposes the libretro harness, save state, memory inspection, screenshot,
|
package/src/mcp/state.js
CHANGED
|
@@ -122,15 +122,39 @@ export function getHostOrNull(sessionKey) {
|
|
|
122
122
|
* @returns {LibretroHost}
|
|
123
123
|
*/
|
|
124
124
|
export function resetHost(sessionKey) {
|
|
125
|
-
|
|
126
|
-
if (existing && existing.status.loaded) {
|
|
127
|
-
try { existing.unloadMedia(); } catch {}
|
|
128
|
-
}
|
|
125
|
+
teardownHost(hosts.get(sessionKey));
|
|
129
126
|
const fresh = new LibretroHost();
|
|
130
127
|
hosts.set(sessionKey, fresh);
|
|
131
128
|
return fresh;
|
|
132
129
|
}
|
|
133
130
|
|
|
131
|
+
/** Tear down whatever host kind is present (LibretroHost.unloadMedia or a native
|
|
132
|
+
* host's destroy) — WasmcartHost/JsGameHost don't have unloadMedia. */
|
|
133
|
+
function teardownHost(existing) {
|
|
134
|
+
if (!existing) return;
|
|
135
|
+
try {
|
|
136
|
+
if (typeof existing.unloadMedia === "function" && existing.status?.loaded) {
|
|
137
|
+
existing.unloadMedia();
|
|
138
|
+
} else if (typeof existing.destroy === "function") {
|
|
139
|
+
existing.destroy();
|
|
140
|
+
}
|
|
141
|
+
} catch { /* ignore teardown errors */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Install an already-constructed host (a native-runtime kind: WasmcartHost /
|
|
146
|
+
* JsGameHost) as this session's active host, tearing down any previous one.
|
|
147
|
+
* Emulator platforms use resetHost + loadCore; native runtimes build their own
|
|
148
|
+
* host and hand it here.
|
|
149
|
+
* @param {string} sessionKey
|
|
150
|
+
* @param {object} hostInstance
|
|
151
|
+
*/
|
|
152
|
+
export function installHost(sessionKey, hostInstance) {
|
|
153
|
+
teardownHost(hosts.get(sessionKey));
|
|
154
|
+
hosts.set(sessionKey, hostInstance);
|
|
155
|
+
return hostInstance;
|
|
156
|
+
}
|
|
157
|
+
|
|
134
158
|
/** @param {string} sessionKey */
|
|
135
159
|
export function clearHost(sessionKey) {
|
|
136
160
|
const existing = hosts.get(sessionKey);
|
package/src/mcp/tools/frame.js
CHANGED
|
@@ -76,7 +76,7 @@ const NEARLY_BLANK_DOMINANT = 0.92;
|
|
|
76
76
|
*/
|
|
77
77
|
export async function computeVerify(host, frames, sessionKey) {
|
|
78
78
|
const platform = host.status.platform;
|
|
79
|
-
if (frames && frames > 0) host.stepFrames(frames);
|
|
79
|
+
if (frames && frames > 0) await host.stepFrames(frames);
|
|
80
80
|
const frameCount = host.status.frameCount;
|
|
81
81
|
|
|
82
82
|
// --- pixel content check (platform-agnostic) ---
|
|
@@ -317,7 +317,9 @@ export function compositeSideBySide(a, b, gap = 4) {
|
|
|
317
317
|
export function registerFrameTools(server, z, sessionKey) {
|
|
318
318
|
async function doStep({ frames }) {
|
|
319
319
|
const host = getHost(sessionKey);
|
|
320
|
-
|
|
320
|
+
// await: native-runtime hosts (jsgame) have an async stepFrames that yields for
|
|
321
|
+
// the game's async work; awaiting a sync LibretroHost return is a harmless no-op.
|
|
322
|
+
const n = await host.stepFrames(frames);
|
|
321
323
|
// Surface a co-drive conflict the moment the agent steps: a human
|
|
322
324
|
// actively playing in the playtest window means this step raced their
|
|
323
325
|
// real-time loop. Field only appears when the conflict is real.
|
|
@@ -786,7 +788,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
786
788
|
async function doStepAndShot({ frames, path: outPath, inline }) {
|
|
787
789
|
requireImageTarget(outPath, inline, "frame({op:'stepAndShot'})");
|
|
788
790
|
const host = getHost(sessionKey);
|
|
789
|
-
host.stepFrames(frames);
|
|
791
|
+
await host.stepFrames(frames);
|
|
790
792
|
const shot = host.screenshot();
|
|
791
793
|
const coDrive = humanCoDriveWarning(sessionKey);
|
|
792
794
|
if (!inline) {
|
package/src/mcp/tools/index.js
CHANGED
|
@@ -34,6 +34,7 @@ import { registerFreeSpaceTools } from "./free-space.js";
|
|
|
34
34
|
import { registerReinjectTools } from "./reinject.js";
|
|
35
35
|
import { registerSpliceChrTools } from "./splice-chr.js";
|
|
36
36
|
import { registerCartPartsTools } from "./cart-parts.js";
|
|
37
|
+
import { registerNativePackTools } from "./native-pack.js";
|
|
37
38
|
import { registerFontMapTools } from "./font-map.js";
|
|
38
39
|
import { registerDisasmTools } from "./disasm.js";
|
|
39
40
|
import { registerFindReferencesTools } from "./find-references.js";
|
|
@@ -173,7 +174,7 @@ const CATEGORIES = [
|
|
|
173
174
|
name: "assets",
|
|
174
175
|
description: "Convert PNGs to platform tile formats, encode WAVs to BRR, scan ROMs to identify them.",
|
|
175
176
|
useWhen: ["importing graphics or audio assets", "checking what a ROM file is"],
|
|
176
|
-
register: (s, z, k) => { registerAssetTools(s, z, k); registerAudioTools(s, z, k); registerRomIdTools(s, z, k); registerDiffRomsTools(s, z, k); registerFreeSpaceTools(s, z, k); registerReinjectTools(s, z, k); registerSpliceChrTools(s, z, k); registerCartPartsTools(s, z, k); registerFontMapTools(s, z, k); registerArtLoaderTools(s, z, k); registerSpritePipelineTools(s, z, k); registerLospecTools(s, z, k); registerMetaSpriteTools(s, z, k); },
|
|
177
|
+
register: (s, z, k) => { registerAssetTools(s, z, k); registerAudioTools(s, z, k); registerRomIdTools(s, z, k); registerDiffRomsTools(s, z, k); registerFreeSpaceTools(s, z, k); registerReinjectTools(s, z, k); registerSpliceChrTools(s, z, k); registerCartPartsTools(s, z, k); registerNativePackTools(s, z, k); registerFontMapTools(s, z, k); registerArtLoaderTools(s, z, k); registerSpritePipelineTools(s, z, k); registerLospecTools(s, z, k); registerMetaSpriteTools(s, z, k); },
|
|
177
178
|
},
|
|
178
179
|
{
|
|
179
180
|
name: "project",
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { resolveCore } from "../../cores/registry.js";
|
|
2
2
|
import {
|
|
3
3
|
clearHost, clearHostB, getHost, getHostB, getHostBOrNull, getHostOrNull,
|
|
4
|
-
rememberLastMedia, resetHost, resetHostB,
|
|
4
|
+
installHost, rememberLastMedia, resetHost, resetHostB,
|
|
5
5
|
} from "../state.js";
|
|
6
|
+
import { WasmcartHost } from "../../host/WasmcartHost.js";
|
|
7
|
+
import { JsGameHost } from "../../host/JsGameHost.js";
|
|
8
|
+
|
|
9
|
+
// Native-runtime platforms don't use a libretro core — they run a native WASM/JS
|
|
10
|
+
// game module in-process. loadMedia builds the matching host and installs it.
|
|
11
|
+
const NATIVE_RUNTIME_HOSTS = {
|
|
12
|
+
wasmcart: () => new WasmcartHost(),
|
|
13
|
+
jsgame: () => new JsGameHost(),
|
|
14
|
+
};
|
|
6
15
|
import { jsonContent, safeTool, textContent } from "../util.js";
|
|
7
16
|
import { resolveCheatCodeForApply } from "./cheats.js";
|
|
8
17
|
import { attachObserverFrame } from "./watch-memory.js";
|
|
@@ -15,10 +24,34 @@ export function registerLifecycleTools(server, z, sessionKey) {
|
|
|
15
24
|
// it gets its own fresh host and does NOT overwrite slot A's recovery
|
|
16
25
|
// breadcrumb, since B is transient scratch, not the session's main ROM.
|
|
17
26
|
async function doLoadMedia({ platform, path, base64, mediaKind, virtualName, cheats, slot }) {
|
|
18
|
-
const resolved = resolveCore(platform);
|
|
19
|
-
if (!resolved) throw new Error(`no core available for platform '${platform}'`);
|
|
20
27
|
if (!path && !base64) throw new Error("loadMedia: provide either `path` (file on disk) or `base64` (ROM bytes).");
|
|
21
28
|
if (path && base64) throw new Error("loadMedia: provide `path` OR `base64`, not both.");
|
|
29
|
+
|
|
30
|
+
// Native-runtime kinds (wasmcart, jsgame) bypass the libretro core path: build
|
|
31
|
+
// their own host, load the game module, install it as the session host. They
|
|
32
|
+
// share the frame/input/screenshot surface but not loadCore/cheats/regions.
|
|
33
|
+
if (NATIVE_RUNTIME_HOSTS[platform]) {
|
|
34
|
+
if (slot === "b") throw new Error(`slot 'b' (side-by-side) is not supported for '${platform}'`);
|
|
35
|
+
const host = NATIVE_RUNTIME_HOSTS[platform]();
|
|
36
|
+
const bytes = base64 ? new Uint8Array(Buffer.from(base64, "base64")) : undefined;
|
|
37
|
+
await host.loadMedia({ platform, ...(bytes ? { bytes } : { path }) });
|
|
38
|
+
installHost(sessionKey, host);
|
|
39
|
+
if (path || bytes) rememberLastMedia(sessionKey, { platform, path, fromBase64: !!bytes });
|
|
40
|
+
const caps = host.getCapabilities();
|
|
41
|
+
const result = {
|
|
42
|
+
loaded: true, platform, kind: caps.kind,
|
|
43
|
+
mediaKind: host.status.mediaKind,
|
|
44
|
+
...(bytes ? { bytes: bytes.length } : { path: host.status.mediaPath }),
|
|
45
|
+
fbWidth: host.status.fbWidth, fbHeight: host.status.fbHeight,
|
|
46
|
+
capabilities: caps,
|
|
47
|
+
};
|
|
48
|
+
// Push the first frame to the /livestream observer, same as an emulator load.
|
|
49
|
+
attachObserverFrame(result, host, `${platform} loaded`);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const resolved = resolveCore(platform);
|
|
54
|
+
if (!resolved) throw new Error(`no core available for platform '${platform}'`);
|
|
22
55
|
const slotB = slot === "b";
|
|
23
56
|
const host = slotB ? resetHostB(sessionKey) : resetHost(sessionKey);
|
|
24
57
|
await host.loadCore(resolved.jsPath, resolved.wasmPath, { hwRender: resolved.hwRender, noderawfs: resolved.noderawfs });
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// native-pack — the "compile" step for the native-runtime kinds (wasmcart / jsgame).
|
|
2
|
+
//
|
|
3
|
+
// These game artifacts are ZIP archives, so packing a source directory into one is the
|
|
4
|
+
// build verb for them (there is NO compiler here — wasmcart is language-agnostic, bring
|
|
5
|
+
// your own WASM; jsgame is plain JS). romdev just assembles the archive.
|
|
6
|
+
//
|
|
7
|
+
// .wasc (wasmcart): zip of { manifest.json, cart.wasm, assets/… }. If the source dir
|
|
8
|
+
// lacks a manifest but has a single .wasm, a minimal manifest is generated.
|
|
9
|
+
// .jsgame (jsgame): zip of the game directory as-is (must contain package.json with a
|
|
10
|
+
// "main", or an index.html / main.js entry — same as rungame expects).
|
|
11
|
+
|
|
12
|
+
import { readFileSync, readdirSync, statSync, writeFileSync, existsSync } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { zipSync } from "fflate";
|
|
15
|
+
import { jsonContent, safeTool } from "../util.js";
|
|
16
|
+
|
|
17
|
+
/** Recursively collect files under `dir` into a { relPath: Uint8Array } map for zipSync. */
|
|
18
|
+
function collectDir(dir, baseDir = dir, out = {}) {
|
|
19
|
+
for (const name of readdirSync(dir)) {
|
|
20
|
+
const full = path.join(dir, name);
|
|
21
|
+
const rel = path.relative(baseDir, full).split(path.sep).join("/");
|
|
22
|
+
const st = statSync(full);
|
|
23
|
+
if (st.isDirectory()) collectDir(full, baseDir, out);
|
|
24
|
+
else out[rel] = new Uint8Array(readFileSync(full));
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pack a wasmcart source dir (or a bare .wasm) into a .wasc archive.
|
|
31
|
+
* @param {object} a
|
|
32
|
+
* @param {string} [a.source] source directory (dev layout) to zip.
|
|
33
|
+
* @param {string} [a.wasm] a single cart .wasm (a manifest is generated around it).
|
|
34
|
+
* @param {string} [a.assets] optional assets dir (used with `wasm`).
|
|
35
|
+
* @param {string} a.outputPath where to write the .wasc.
|
|
36
|
+
* @param {string} [a.name] manifest name (generated-manifest case).
|
|
37
|
+
* @param {number} [a.players] manifest players count (generated-manifest case).
|
|
38
|
+
*/
|
|
39
|
+
export function packWasc({ source, wasm, assets, outputPath, name, players } = {}) {
|
|
40
|
+
if (!outputPath) throw new Error("pack(wasmcart): `outputPath` is required.");
|
|
41
|
+
let files;
|
|
42
|
+
|
|
43
|
+
if (source) {
|
|
44
|
+
if (!existsSync(source) || !statSync(source).isDirectory()) {
|
|
45
|
+
throw new Error(`pack(wasmcart): source '${source}' is not a directory.`);
|
|
46
|
+
}
|
|
47
|
+
files = collectDir(source);
|
|
48
|
+
if (!files["manifest.json"]) {
|
|
49
|
+
// No manifest in the dir — synthesize one pointing at the single .wasm present.
|
|
50
|
+
const wasmEntry = Object.keys(files).find((f) => f.endsWith(".wasm"));
|
|
51
|
+
if (!wasmEntry) throw new Error("pack(wasmcart): source dir has no manifest.json and no .wasm to derive one.");
|
|
52
|
+
const manifest = { name: name || path.basename(outputPath, ".wasc"), version: "1.0.0", entry: wasmEntry };
|
|
53
|
+
if (players) manifest.players = players;
|
|
54
|
+
files["manifest.json"] = new Uint8Array(Buffer.from(JSON.stringify(manifest, null, 2)));
|
|
55
|
+
}
|
|
56
|
+
} else if (wasm) {
|
|
57
|
+
if (!existsSync(wasm)) throw new Error(`pack(wasmcart): wasm '${wasm}' not found.`);
|
|
58
|
+
files = { "cart.wasm": new Uint8Array(readFileSync(wasm)) };
|
|
59
|
+
const manifest = { name: name || path.basename(outputPath, ".wasc"), version: "1.0.0", entry: "cart.wasm" };
|
|
60
|
+
if (players) manifest.players = players;
|
|
61
|
+
if (assets) {
|
|
62
|
+
if (!existsSync(assets) || !statSync(assets).isDirectory()) throw new Error(`pack(wasmcart): assets '${assets}' is not a directory.`);
|
|
63
|
+
manifest.assets = "assets/";
|
|
64
|
+
const assetFiles = collectDir(assets);
|
|
65
|
+
for (const [rel, buf] of Object.entries(assetFiles)) files["assets/" + rel] = buf;
|
|
66
|
+
}
|
|
67
|
+
files["manifest.json"] = new Uint8Array(Buffer.from(JSON.stringify(manifest, null, 2)));
|
|
68
|
+
} else {
|
|
69
|
+
throw new Error("pack(wasmcart): provide `source` (a dir) or `wasm` (a cart .wasm).");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const zipped = zipSync(files, { level: 6 });
|
|
73
|
+
writeFileSync(outputPath, zipped);
|
|
74
|
+
return { outputPath, entries: Object.keys(files).length, bytes: zipped.length };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Pack a jsgame source dir into a .jsgame archive (a plain zip of the dir).
|
|
79
|
+
* @param {object} a
|
|
80
|
+
* @param {string} a.source the game directory (must have package.json main / index.html / main.js).
|
|
81
|
+
* @param {string} a.outputPath where to write the .jsgame.
|
|
82
|
+
*/
|
|
83
|
+
export function packJsgame({ source, outputPath } = {}) {
|
|
84
|
+
if (!source || !existsSync(source) || !statSync(source).isDirectory()) {
|
|
85
|
+
throw new Error(`pack(jsgame): source '${source}' is not a directory.`);
|
|
86
|
+
}
|
|
87
|
+
if (!outputPath) throw new Error("pack(jsgame): `outputPath` is required.");
|
|
88
|
+
const files = collectDir(source);
|
|
89
|
+
// Sanity: a jsgame needs an entry rungame can resolve.
|
|
90
|
+
const hasEntry = files["package.json"] || files["index.html"] || files["main.js"] ||
|
|
91
|
+
Object.keys(files).some((f) => f.endsWith("/main.js") || f === "index.js");
|
|
92
|
+
if (!hasEntry) {
|
|
93
|
+
throw new Error("pack(jsgame): source has no entry (package.json 'main', index.html, or main.js). rungame won't be able to run it.");
|
|
94
|
+
}
|
|
95
|
+
const zipped = zipSync(files, { level: 6 });
|
|
96
|
+
writeFileSync(outputPath, zipped);
|
|
97
|
+
return { outputPath, entries: Object.keys(files).length, bytes: zipped.length };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Register the `pack` tool — the "build" verb for the native-runtime kinds. Packs a
|
|
102
|
+
* source directory into a distributable .wasc / .jsgame archive (a zip). NOT a compiler
|
|
103
|
+
* (wasmcart is any-language-to-WASM; jsgame is plain JS) — it just assembles the archive.
|
|
104
|
+
*/
|
|
105
|
+
export function registerNativePackTools(server, z) {
|
|
106
|
+
server.tool(
|
|
107
|
+
"pack",
|
|
108
|
+
"Package a native-runtime game's source into its distributable archive (the 'build' step " +
|
|
109
|
+
"for wasmcart/jsgame — a ZIP, NOT a compiler). `target`:\n" +
|
|
110
|
+
"• 'wasc' (wasmcart) — pass `source` (a dev dir: manifest.json + cart.wasm + assets/) OR " +
|
|
111
|
+
"`wasm` (a single cart .wasm; a minimal manifest is generated, with optional `assets` dir). " +
|
|
112
|
+
"Writes a .wasc. NOTE: romdev does NOT compile WASM — bring your own (any language → wasm).\n" +
|
|
113
|
+
"• 'jsgame' (jsgame) — pass `source` (the game dir; must have package.json 'main', index.html, " +
|
|
114
|
+
"or main.js). Writes a .jsgame. Pure JS, no build needed beyond the zip.",
|
|
115
|
+
{
|
|
116
|
+
target: z.enum(["wasc", "jsgame"]).describe("wasc = wasmcart .wasc; jsgame = jsgame .jsgame."),
|
|
117
|
+
source: z.string().optional().describe("Source directory to pack. (wasc: a dev-layout dir; jsgame: the game dir.)"),
|
|
118
|
+
wasm: z.string().optional().describe("target=wasc: a single cart .wasm to wrap (a manifest is generated). Use instead of `source`."),
|
|
119
|
+
assets: z.string().optional().describe("target=wasc with `wasm`: an assets directory to include under assets/."),
|
|
120
|
+
outputPath: z.string().describe("Absolute path to write the archive (.wasc / .jsgame)."),
|
|
121
|
+
name: z.string().optional().describe("target=wasc: manifest game name (default derived from outputPath)."),
|
|
122
|
+
players: z.number().int().min(1).max(4).optional().describe("target=wasc: manifest players count."),
|
|
123
|
+
},
|
|
124
|
+
safeTool(async (args) => {
|
|
125
|
+
if (args.target === "wasc") {
|
|
126
|
+
return jsonContent(packWasc(args));
|
|
127
|
+
}
|
|
128
|
+
if (args.target === "jsgame") {
|
|
129
|
+
return jsonContent(packJsgame(args));
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`pack: unknown target '${args.target}'.`);
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
package/src/playtest/playtest.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
ROMDEV_PIXEL_FORMAT_RGBA8888,
|
|
10
10
|
} from "../host/retroConstants.js";
|
|
11
11
|
import { log } from "../mcp/log.js";
|
|
12
|
-
import { initResampler, resampleS16Stereo } from "
|
|
12
|
+
import { initResampler, resampleS16Stereo } from "romdev-audio-resampler";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { existsSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
15
15
|
import { execFile } from "node:child_process";
|
|
@@ -1,19 +0,0 @@
|
|
|
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)"
|
|
@@ -1,75 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
/* ── resampler.c — WASM+SIMD linear resampler for the romdev playtest audio sink ─
|
|
2
|
-
*
|
|
3
|
-
* Resamples interleaved S16 STEREO PCM from a source rate to a device rate.
|
|
4
|
-
*
|
|
5
|
-
* WHY this exists: the libretro CONTRACT is that a core declares its native audio
|
|
6
|
-
* rate in get_system_av_info and emits raw samples at that rate; the FRONTEND is
|
|
7
|
-
* responsible for resampling to the audio device (RetroArch does this in C with a
|
|
8
|
-
* sinc resampler). Every romdev core sits at 31–48 kHz EXCEPT the GameTank ACP at
|
|
9
|
-
* ~13983 Hz — 2.3x lower than the next core. At that rate SDL's fixed device
|
|
10
|
-
* buffer (4096 samples ≈ 293 ms) starves between 60 fps ticks that each feed only
|
|
11
|
-
* ~233 samples → clicks and pops. So the playtest sink opens the device at 48 kHz
|
|
12
|
-
* and resamples low-rate cores up to it. Doing that per-frame in JS is wasteful;
|
|
13
|
-
* this is the native-speed SIMD path (linear interp, 4 output frames/iteration).
|
|
14
|
-
*
|
|
15
|
-
* Build: see build.sh (emcc -O3 -msimd128). Exports rs_alloc/rs_free/rs_resample.
|
|
16
|
-
*
|
|
17
|
-
* Contract:
|
|
18
|
-
* rs_resample(inPtr, inFrames, outPtr, outCap, srcRate, dstRate) -> outFrames
|
|
19
|
-
* inPtr : int16_t* interleaved L,R,L,R… at srcRate (inFrames stereo frames)
|
|
20
|
-
* outPtr : int16_t* interleaved buffer with room for outCap stereo frames
|
|
21
|
-
* returns the number of stereo frames written (<= outCap).
|
|
22
|
-
* The caller sizes outCap >= ceil(inFrames * dstRate/srcRate) + 1.
|
|
23
|
-
*/
|
|
24
|
-
#include <stdint.h>
|
|
25
|
-
#include <wasm_simd128.h>
|
|
26
|
-
#include <emscripten.h>
|
|
27
|
-
|
|
28
|
-
#define EXPORT EMSCRIPTEN_KEEPALIVE
|
|
29
|
-
|
|
30
|
-
/* scratch buffers live in the WASM heap, allocated from JS via rs_alloc. */
|
|
31
|
-
EXPORT void *rs_alloc(int bytes) { return __builtin_malloc((unsigned long)bytes); }
|
|
32
|
-
EXPORT void rs_free(void *p) { __builtin_free(p); }
|
|
33
|
-
|
|
34
|
-
/* Linear-resample interleaved S16 stereo. Returns stereo frames written.
|
|
35
|
-
*
|
|
36
|
-
* For output frame i: srcPos = i / ratio = i * srcRate/dstRate. We split srcPos
|
|
37
|
-
* into integer index i0 and fraction f in [0,1): out = in[i0]*(1-f) + in[i0+1]*f
|
|
38
|
-
* per channel. We vectorize across 4 consecutive output frames: compute their 4
|
|
39
|
-
* srcPos in f32x4, their 4 integer indices and 4 fractions, gather the 8 source
|
|
40
|
-
* samples (can't SIMD-gather in wasm128, so scalar gather) but do the lerp math in
|
|
41
|
-
* SIMD. The gather dominates, but keeping the arithmetic in f32x4 + a single
|
|
42
|
-
* saturating narrow per 4 frames is still a clear win over per-sample JS.
|
|
43
|
-
*/
|
|
44
|
-
EXPORT int rs_resample(const int16_t *in, int inFrames,
|
|
45
|
-
int16_t *out, int outCap,
|
|
46
|
-
int srcRate, int dstRate) {
|
|
47
|
-
if (inFrames < 2 || srcRate <= 0 || dstRate <= 0) return 0;
|
|
48
|
-
if (srcRate == dstRate) {
|
|
49
|
-
int n = inFrames < outCap ? inFrames : outCap;
|
|
50
|
-
for (int i = 0; i < n * 2; i++) out[i] = in[i];
|
|
51
|
-
return n;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/* step = srcRate/dstRate in source frames per output frame (fixed math in f64). */
|
|
55
|
-
const double step = (double)srcRate / (double)dstRate;
|
|
56
|
-
long outFrames = (long)((double)inFrames * (double)dstRate / (double)srcRate);
|
|
57
|
-
if (outFrames > outCap) outFrames = outCap;
|
|
58
|
-
const int maxI0 = inFrames - 2; /* so i0+1 is valid */
|
|
59
|
-
|
|
60
|
-
long i = 0;
|
|
61
|
-
/* SIMD body: 4 output frames per iteration. */
|
|
62
|
-
for (; i + 4 <= outFrames; i += 4) {
|
|
63
|
-
/* source positions for the 4 frames */
|
|
64
|
-
double p0 = (double)(i + 0) * step;
|
|
65
|
-
double p1 = (double)(i + 1) * step;
|
|
66
|
-
double p2 = (double)(i + 2) * step;
|
|
67
|
-
double p3 = (double)(i + 3) * step;
|
|
68
|
-
|
|
69
|
-
int i0_0 = (int)p0, i0_1 = (int)p1, i0_2 = (int)p2, i0_3 = (int)p3;
|
|
70
|
-
if (i0_0 > maxI0) i0_0 = maxI0; if (i0_1 > maxI0) i0_1 = maxI0;
|
|
71
|
-
if (i0_2 > maxI0) i0_2 = maxI0; if (i0_3 > maxI0) i0_3 = maxI0;
|
|
72
|
-
|
|
73
|
-
/* fractions as f32x4 */
|
|
74
|
-
v128_t frac = wasm_f32x4_make((float)(p0 - i0_0), (float)(p1 - i0_1),
|
|
75
|
-
(float)(p2 - i0_2), (float)(p3 - i0_3));
|
|
76
|
-
v128_t inv = wasm_f32x4_sub(wasm_f32x4_splat(1.0f), frac);
|
|
77
|
-
|
|
78
|
-
/* gather the 8 source samples per channel (scalar — no wasm128 gather). */
|
|
79
|
-
v128_t l0 = wasm_f32x4_make((float)in[(i0_0*2)], (float)in[(i0_1*2)],
|
|
80
|
-
(float)in[(i0_2*2)], (float)in[(i0_3*2)]);
|
|
81
|
-
v128_t l1 = wasm_f32x4_make((float)in[(i0_0*2)+2], (float)in[(i0_1*2)+2],
|
|
82
|
-
(float)in[(i0_2*2)+2], (float)in[(i0_3*2)+2]);
|
|
83
|
-
v128_t r0 = wasm_f32x4_make((float)in[(i0_0*2)+1], (float)in[(i0_1*2)+1],
|
|
84
|
-
(float)in[(i0_2*2)+1], (float)in[(i0_3*2)+1]);
|
|
85
|
-
v128_t r1 = wasm_f32x4_make((float)in[(i0_0*2)+3], (float)in[(i0_1*2)+3],
|
|
86
|
-
(float)in[(i0_2*2)+3], (float)in[(i0_3*2)+3]);
|
|
87
|
-
|
|
88
|
-
/* lerp: l = l0*inv + l1*frac */
|
|
89
|
-
v128_t lo = wasm_f32x4_add(wasm_f32x4_mul(l0, inv), wasm_f32x4_mul(l1, frac));
|
|
90
|
-
v128_t ro = wasm_f32x4_add(wasm_f32x4_mul(r0, inv), wasm_f32x4_mul(r1, frac));
|
|
91
|
-
|
|
92
|
-
/* round to nearest, convert to i32, store interleaved. */
|
|
93
|
-
v128_t half = wasm_f32x4_splat(0.5f);
|
|
94
|
-
v128_t lneg = wasm_f32x4_lt(lo, wasm_f32x4_splat(0.0f));
|
|
95
|
-
v128_t rneg = wasm_f32x4_lt(ro, wasm_f32x4_splat(0.0f));
|
|
96
|
-
lo = wasm_f32x4_add(lo, wasm_v128_bitselect(wasm_f32x4_splat(-0.5f), half, lneg));
|
|
97
|
-
ro = wasm_f32x4_add(ro, wasm_v128_bitselect(wasm_f32x4_splat(-0.5f), half, rneg));
|
|
98
|
-
v128_t li = wasm_i32x4_trunc_sat_f32x4(lo);
|
|
99
|
-
v128_t ri = wasm_i32x4_trunc_sat_f32x4(ro);
|
|
100
|
-
|
|
101
|
-
int li_[4], ri_[4];
|
|
102
|
-
wasm_v128_store(li_, li);
|
|
103
|
-
wasm_v128_store(ri_, ri);
|
|
104
|
-
for (int k = 0; k < 4; k++) {
|
|
105
|
-
int lv = li_[k], rv = ri_[k];
|
|
106
|
-
if (lv > 32767) lv = 32767; else if (lv < -32768) lv = -32768;
|
|
107
|
-
if (rv > 32767) rv = 32767; else if (rv < -32768) rv = -32768;
|
|
108
|
-
out[(i + k) * 2] = (int16_t)lv;
|
|
109
|
-
out[(i + k) * 2 + 1] = (int16_t)rv;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/* scalar tail */
|
|
114
|
-
for (; i < outFrames; i++) {
|
|
115
|
-
double p = (double)i * step;
|
|
116
|
-
int i0 = (int)p; if (i0 > maxI0) i0 = maxI0;
|
|
117
|
-
float f = (float)(p - i0), invf = 1.0f - f;
|
|
118
|
-
float l = in[i0*2] * invf + in[(i0+1)*2] * f;
|
|
119
|
-
float r = in[i0*2+1] * invf + in[(i0+1)*2+1] * f;
|
|
120
|
-
int lv = (int)(l < 0 ? l - 0.5f : l + 0.5f);
|
|
121
|
-
int rv = (int)(r < 0 ? r - 0.5f : r + 0.5f);
|
|
122
|
-
if (lv > 32767) lv = 32767; else if (lv < -32768) lv = -32768;
|
|
123
|
-
if (rv > 32767) rv = 32767; else if (rv < -32768) rv = -32768;
|
|
124
|
-
out[i*2] = (int16_t)lv;
|
|
125
|
-
out[i*2+1] = (int16_t)rv;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return (int)outFrames;
|
|
129
|
-
}
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
async function Module(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("module");var require=createRequire(import.meta.url)}var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=import.meta.url;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("fs");if(_scriptName.startsWith("file:")){scriptDirectory=require("path").dirname(require("url").fileURLToPath(_scriptName))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var isFileURI=filename=>filename.startsWith("file://");var readyPromiseResolve,readyPromiseReject;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;var HEAP64,HEAPU64;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["c"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){return locateFile("resampler.wasm")}return new URL("resampler.wasm",import.meta.url).href}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i<str.length;++i){var c=str.charCodeAt(i);if(c<=127){len++}else if(c<=2047){len+=2}else if(c>=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i<str.length;++i){var u=str.codePointAt(i);if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx<endPtr){var u0=heapOrArray[idx++];if(!(u0&128)){str+=String.fromCharCode(u0);continue}var u1=heapOrArray[idx++]&63;if((u0&224)==192){str+=String.fromCharCode((u0&31)<<6|u1);continue}var u2=heapOrArray[idx++]&63;if((u0&240)==224){u0=(u0&15)<<12|u1<<6|u2}else{u0=(u0&7)<<18|u1<<12|u2<<6|heapOrArray[idx++]&63}if(u0<65536){str+=String.fromCharCode(u0)}else{var ch=u0-65536;str+=String.fromCharCode(55296|ch>>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i<args.length;i++){var converter=toC[argTypes[i]];if(converter){if(stack===0)stack=stackSave();cArgs[i]=converter(args[i])}else{cArgs[i]=args[i]}}}var ret=func(...cArgs);function onDone(ret){if(stack!==0)stackRestore(stack);return convertReturnValue(ret)}ret=onDone(ret);return ret};var cwrap=(ident,returnType,argTypes,opts)=>{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;var _rs_alloc,_malloc,_rs_free,_free,_rs_resample,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_rs_alloc=Module["_rs_alloc"]=wasmExports["d"];_malloc=Module["_malloc"]=wasmExports["e"];_rs_free=Module["_rs_free"]=wasmExports["f"];_free=Module["_free"]=wasmExports["g"];_rs_resample=Module["_rs_resample"]=wasmExports["h"];__emscripten_stack_restore=wasmExports["i"];__emscripten_stack_alloc=wasmExports["j"];_emscripten_stack_get_current=wasmExports["k"];memory=wasmMemory=wasmExports["b"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={a:_emscripten_resize_heap};function run(){preRun();function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=await (createWasm());run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})}
|
|
2
|
-
;return moduleRtn}export default Module;
|
|
Binary file
|