romdevtools 0.22.1 → 0.24.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/AGENTS.md +169 -494
- package/CHANGELOG.md +103 -0
- package/examples/genesis/templates/platformer.c +5 -1
- package/examples/genesis/templates/two_plane_parallax.c +166 -0
- package/package.json +2 -2
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +225 -2
- package/src/host/framebuffer.js +37 -0
- package/src/http/skill-doc.js +1 -1
- package/src/mcp/tools/audio.js +2 -2
- package/src/mcp/tools/frame.js +13 -34
- package/src/mcp/tools/index.js +2 -2
- package/src/mcp/tools/input-layout.js +10 -0
- package/src/mcp/tools/input.js +26 -2
- package/src/mcp/tools/metasprite-tools.js +1 -1
- package/src/mcp/tools/platform-tools.js +18 -11
- package/src/mcp/tools/playtest.js +17 -2
- package/src/mcp/tools/project.js +9 -1
- package/src/mcp/tools/rendering-context.js +1 -1
- package/src/mcp/tools/symbols.js +130 -39
- package/src/mcp/tools/tile-inspect.js +1 -1
- package/src/mcp/tools/toolchain.js +3 -2
- package/src/mcp/tools/watch-memory.js +58 -6
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +155 -6
- package/src/platforms/atari2600/MENTAL_MODEL.md +37 -0
- package/src/platforms/atari7800/MENTAL_MODEL.md +36 -0
- package/src/platforms/c64/MENTAL_MODEL.md +83 -6
- package/src/platforms/gb/MENTAL_MODEL.md +74 -0
- package/src/platforms/gb/lib/c/SDCC_GOTCHAS.md +91 -0
- package/src/platforms/gba/MENTAL_MODEL.md +57 -3
- package/src/platforms/gba/lib/arm-archives/libc.a +0 -0
- package/src/platforms/gba/lib/arm-archives/libgcc.a +0 -0
- package/src/platforms/gba/lib/arm-archives/libnosys.a +0 -0
- package/src/platforms/gbc/MENTAL_MODEL.md +34 -0
- package/src/platforms/gbc/lib/c/SDCC_GOTCHAS.md +91 -0
- package/src/platforms/genesis/MENTAL_MODEL.md +180 -0
- package/src/platforms/genesis/TROUBLESHOOTING.md +32 -0
- package/src/platforms/genesis/lib/c/libc.a +0 -0
- package/src/platforms/genesis/lib/c/libgcc.a +0 -0
- package/src/platforms/genesis/lib/c/libm.a +0 -0
- package/src/platforms/gg/MENTAL_MODEL.md +24 -0
- package/src/platforms/gg/lib/c/gg_crt0.s +30 -0
- package/src/platforms/lynx/MENTAL_MODEL.md +33 -7
- package/src/platforms/msx/MENTAL_MODEL.md +27 -0
- package/src/platforms/nes/MENTAL_MODEL.md +35 -0
- package/src/platforms/sms/MENTAL_MODEL.md +51 -0
- package/src/platforms/sms/lib/c/sms_crt0.s +40 -0
- package/src/platforms/snes/MENTAL_MODEL.md +21 -0
- package/src/platforms/snes/TROUBLESHOOTING.md +43 -0
- package/src/playtest/playtest.js +48 -0
- package/src/toolchains/sdcc/preflight-lint.js +164 -8
- package/examples/msx/catch_game/_verify.mjs +0 -93
- package/examples/pce/catch_game/_verify.mjs +0 -75
|
@@ -267,6 +267,57 @@ Loadable via genesis_plus_gx (`loadMedia`).
|
|
|
267
267
|
- Game Gear-specific buttons add port $00 read (Start) and stereo
|
|
268
268
|
PSG control on port $06.
|
|
269
269
|
|
|
270
|
+
## MCP debug & inspection tooling
|
|
271
|
+
|
|
272
|
+
SMS and Game Gear share the genesis_plus_gx (gpgx, patched) core, so the
|
|
273
|
+
inspectors below behave identically on both. This section is the **canonical
|
|
274
|
+
shared reference**; the Game Gear MENTAL_MODEL points back here and only lists
|
|
275
|
+
its own deltas (12-bit CRAM, etc.). Reach for these when something renders
|
|
276
|
+
wrong and you can't see why from the source alone — they read the *live* core
|
|
277
|
+
state, which beats trusting comments.
|
|
278
|
+
|
|
279
|
+
- **`sprites({op:'inspect'})`** — decodes the live SAT (sprite attribute
|
|
280
|
+
table) and renders a sprite-sheet PNG. Reports each slot's X/Y/tile plus
|
|
281
|
+
`spriteTileDataBase` (the VRAM address the VDP is actually fetching sprite
|
|
282
|
+
tiles from). This is the tool that catches the $D0-terminator and the
|
|
283
|
+
R6/$2000-vs-$0000 sprite-bank footguns described above — trust its bytes
|
|
284
|
+
over any comment.
|
|
285
|
+
- **`palette({source:'live'})`** — reads CRAM and converts to RGB. On SMS
|
|
286
|
+
that's **6-bit BGR** (2-2-2, 32 bytes). On Game Gear it's **12-bit BGR**
|
|
287
|
+
(4-4-4, 64 bytes) — see the GG MENTAL_MODEL for that delta.
|
|
288
|
+
- **`tiles({op:'png'})`** — dumps VRAM tile patterns as a sheet. The format
|
|
289
|
+
is 4bpp bitplane-interleaved (32 bytes/tile); the 16 KB VRAM renders as a
|
|
290
|
+
512-tile sheet.
|
|
291
|
+
- **`cpu({op:'read'})`** — Z80 register dump: A/F, BC/DE/HL, IX/IY, the
|
|
292
|
+
shadow register set, the flag bits, and interrupt state (IM mode / IFF).
|
|
293
|
+
- **`audioDebug({op:'inspect', chip:'psg'})`** — decodes the live SN76489:
|
|
294
|
+
3 tone channels + 1 noise channel. The PSG region is shared by SMS, GG, and
|
|
295
|
+
Genesis (same gpgx region).
|
|
296
|
+
- **`background({view:'renderState'})`** — reads the VDP registers and reports
|
|
297
|
+
the derived addresses (name table, BG-tile base, sprite-tile base, SAT base),
|
|
298
|
+
the scroll registers, and the display-enable / mode state. Use this to
|
|
299
|
+
confirm the R2/R4/R5/R6 baseline matches where you actually uploaded data.
|
|
300
|
+
|
|
301
|
+
### Memory regions (`memory({op:'read', region:…})`)
|
|
302
|
+
|
|
303
|
+
| Region | Contents |
|
|
304
|
+
|-----------------|------------------------------------------------------|
|
|
305
|
+
| `sms_vram` | 16 KB VRAM — tiles + name table + SAT + sprite tiles |
|
|
306
|
+
| `sms_cram` | 32-byte palette (2-2-2 BGR) |
|
|
307
|
+
| `sms_vdp_regs` | the 11 VDP control registers ($00-$0A) |
|
|
308
|
+
| `sms_z80_regs` | Z80 register snapshot |
|
|
309
|
+
| `gg_vram` | Game Gear VRAM |
|
|
310
|
+
| `gg_cram` | Game Gear 64-byte palette (4-4-4 BGR) |
|
|
311
|
+
|
|
312
|
+
### Disassembly (`disasm({target:…})`)
|
|
313
|
+
|
|
314
|
+
`disasm({target:'rom'})`, `disasm({target:'references'})`, and
|
|
315
|
+
`disasm({target:'project'})` all run the live ROM through the native binutils
|
|
316
|
+
**z80 `objdump`** (WASM, `-m z80`). It has full prefix coverage —
|
|
317
|
+
CB/ED/DD/FD/DDCB/FDCB — and feeds the same auto-label, register-annotation,
|
|
318
|
+
file-offset, and `untilReturn` pipeline used by the NES and SNES
|
|
319
|
+
disassemblers.
|
|
320
|
+
|
|
270
321
|
## Horizontal scrolling (for side-scrollers)
|
|
271
322
|
|
|
272
323
|
The `platformer` scaffold is single-screen. To make it a side-scroller:
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
.globl l__INITIALIZER
|
|
23
23
|
.globl s__INITIALIZER
|
|
24
24
|
.globl s__INITIALIZED
|
|
25
|
+
.globl s__DATA
|
|
26
|
+
.globl l__DATA
|
|
25
27
|
|
|
26
28
|
;; ─── Reset vector at $0000 ────────────────────────────────────────
|
|
27
29
|
.area _HEADER (ABS)
|
|
@@ -72,7 +74,19 @@
|
|
|
72
74
|
;; call main. The initializer area is filled by sdcc when it sees
|
|
73
75
|
;; global initializations.
|
|
74
76
|
|
|
77
|
+
;; AREA ORDERING IS LOad-BEARING. `_INITIALIZER` (the ROM image of
|
|
78
|
+
;; every value-initialised `static` global) MUST be declared in the
|
|
79
|
+
;; ROM group here — BEFORE the `_DATA` RAM block. If it isn't, sdld
|
|
80
|
+
;; places `_INITIALIZER` in RAM right after `_INITIALIZED`, so the
|
|
81
|
+
;; gsinit copy below copies uninitialised RAM onto itself and every
|
|
82
|
+
;; `static uint8_t x = 5;` boots as 0. (Bug found 2026-06-08: a GBC
|
|
83
|
+
;; Columns agent's `static uint32_t rng = 0x1357;` booted as 0, so
|
|
84
|
+
;; the xorshift PRNG stayed 0 and every "random" roll came out the
|
|
85
|
+
;; same — a "monochrome RNG" that looked like an SDCC codegen bug
|
|
86
|
+
;; but was really this missing ROM placement. The sm83 GB crt0 has
|
|
87
|
+
;; always placed _INITIALIZER in ROM; the z80 crt0s never did.)
|
|
75
88
|
.area _HOME
|
|
89
|
+
.area _INITIALIZER
|
|
76
90
|
.area _CODE
|
|
77
91
|
.area _GSINIT
|
|
78
92
|
.area _GSFINAL
|
|
@@ -86,6 +100,32 @@
|
|
|
86
100
|
.area _CODE
|
|
87
101
|
|
|
88
102
|
gsinit:
|
|
103
|
+
;; ── Zero the BSS segment (`_DATA`). ──────────────────────────
|
|
104
|
+
;; Every uninitialised `static` global lands in `_DATA` and MUST
|
|
105
|
+
;; read back 0 at boot. Without this, `static uint8_t flag;` boots
|
|
106
|
+
;; with whatever power-on WRAM byte was there (gambatte/gpgx leave
|
|
107
|
+
;; garbage), and `if (flag)` spuriously fires. Mirrors the sm83 GB
|
|
108
|
+
;; crt0's gsinit_data loop.
|
|
109
|
+
ld bc, #l__DATA
|
|
110
|
+
ld a, b
|
|
111
|
+
or a, c
|
|
112
|
+
jr Z, gsinit_bss_done
|
|
113
|
+
ld hl, #s__DATA
|
|
114
|
+
ld (hl), #0x00
|
|
115
|
+
ld d, h
|
|
116
|
+
ld e, l
|
|
117
|
+
inc de
|
|
118
|
+
dec bc
|
|
119
|
+
ld a, b
|
|
120
|
+
or a, c
|
|
121
|
+
jr Z, gsinit_bss_done
|
|
122
|
+
ldir ; propagate the 0 across _DATA
|
|
123
|
+
gsinit_bss_done:
|
|
124
|
+
|
|
125
|
+
;; ── Copy `_INITIALIZER` (ROM) → `_INITIALIZED` (RAM). ────────
|
|
126
|
+
;; The value-initialised-statics path: `static uint8_t lives = 3;`
|
|
127
|
+
;; lives in _INITIALIZED at runtime; its initial value sits in
|
|
128
|
+
;; _INITIALIZER in ROM (now correctly ROM-placed, see above).
|
|
89
129
|
ld bc, #l__INITIALIZER
|
|
90
130
|
ld a, b
|
|
91
131
|
or a, c
|
|
@@ -208,6 +208,27 @@ per-voice vol/pitch/ADSR + `env` (0 = silent regardless of vol) + `bufLastSample
|
|
|
208
208
|
produced output" from "muted by mixer." GOTCHA: S-DSP FLG is $6C, KOFF is $5C
|
|
209
209
|
(many refs swap them); power-on FLG=$E0 means your driver MUST clear bit 6.
|
|
210
210
|
|
|
211
|
+
## MCP debug & inspection tooling
|
|
212
|
+
|
|
213
|
+
The shipped snes9x core is patched for deep introspection — both audio and
|
|
214
|
+
video are fully readable, so you assert live state instead of guessing:
|
|
215
|
+
|
|
216
|
+
- **Sprites:** `sprites({op:'inspect'})` decodes live OAM (per-sprite
|
|
217
|
+
renderable/hidden, resolved tile VRAM addr, CGRAM palette range, and the
|
|
218
|
+
uninitialized-OBJ-palette warning described under "The OBJ stable-path
|
|
219
|
+
recipe").
|
|
220
|
+
- **Palette:** `palette({source:'live'})` reads live CGRAM.
|
|
221
|
+
- **CPUs:** `cpu({op:'read', cpu:'main'})` for the 65816, `cpu({op:'read',
|
|
222
|
+
cpu:'spc700'})` for the sound CPU.
|
|
223
|
+
- **Audio:** the S-DSP is fully decodable — full per-voice state plus the
|
|
224
|
+
master mixer (see "Debugging sound" above for `audioDebug`).
|
|
225
|
+
- **Memory regions:** `memory({op:'read'})` exposes OAM, CGRAM, ARAM (SPC700
|
|
226
|
+
audio RAM), and **FillRAM**. Note the FillRAM quirk: snes9x mirrors the
|
|
227
|
+
PPU registers $2100-$213F (OBSEL/BGMODE/TM/TS/color-math, etc.) into
|
|
228
|
+
FillRAM indexed by the FULL address (e.g. `FillRAM[0x2101]` = OBSEL), so
|
|
229
|
+
the PPU register state is readable through the `snes_fillram` region — no
|
|
230
|
+
core patch needed.
|
|
231
|
+
|
|
211
232
|
## ROM layout (LoROM)
|
|
212
233
|
|
|
213
234
|
```
|
|
@@ -193,6 +193,49 @@ Expected. tcc-65816.wasm + wla-65816.wasm cold-load + compile takes
|
|
|
193
193
|
1-2s. Subsequent builds reuse the warm worker pool (R12 crash
|
|
194
194
|
isolation infrastructure). Steady-state builds are sub-second.
|
|
195
195
|
|
|
196
|
+
## "asm build fails with `ok:false` and an empty/cryptic `issues[]`" (asar idioms)
|
|
197
|
+
|
|
198
|
+
The `language:"asm"` SNES path runs through **asar**, which **silently crashes**
|
|
199
|
+
(a heap-pointer Emscripten exit code, no diagnostic) on a handful of source
|
|
200
|
+
idioms. romdev's asar wrapper has a preflight that catches the common cases and,
|
|
201
|
+
when asar dies with `ok:false` and no parsed errors, retries with `--verbose` and
|
|
202
|
+
synthesizes a fallback `issues[]` entry with a hint. The idioms to avoid:
|
|
203
|
+
|
|
204
|
+
- **`$ - label` size expressions** (current-PC minus a label) crash asar. Use an
|
|
205
|
+
explicit `end_label - start_label` difference instead:
|
|
206
|
+
```asm
|
|
207
|
+
; WRONG — crashes asar silently
|
|
208
|
+
my_size = $ - my_data
|
|
209
|
+
; RIGHT
|
|
210
|
+
my_data_end:
|
|
211
|
+
my_size = my_data_end - my_data
|
|
212
|
+
```
|
|
213
|
+
- **Opcode + operand arithmetic on an `=`-defined symbol**, e.g.
|
|
214
|
+
`STA SYMBOL + N` where `SYMBOL` is a `=` constant (not a label), can also crash
|
|
215
|
+
silently. The preflight flags the label-arithmetic-across-bank case
|
|
216
|
+
(`label-arithmetic constant`); for the rest, if you see `ok:false` with no real
|
|
217
|
+
error, suspect a `SYMBOL + N` and rewrite to a plain label or pre-compute the
|
|
218
|
+
address.
|
|
219
|
+
- **Bank-border crossed.** If your `org` + `dw`/data runs past `$00FFFF` you've
|
|
220
|
+
crossed a bank boundary and the layout is wrong. Native interrupt vectors live
|
|
221
|
+
at `$FFE4-$FFEE`, emulation vectors at `$FFF4-$FFFF` — keep your header/vector
|
|
222
|
+
block where the layout expects it. Use
|
|
223
|
+
`scaffold({op:'snippets', platform:"snes", mode:"get", name:"lorom_header.asm"})`
|
|
224
|
+
for the canonical layout (and `lorom_multibank.asm` for multi-bank).
|
|
225
|
+
|
|
226
|
+
(This is the asar/asm path. The default PVSnesLib **C** path goes through
|
|
227
|
+
tcc-65816 + wla-65816 and has its own C89 trap, above.)
|
|
228
|
+
|
|
229
|
+
## "CHR and tilemap overlap in VRAM / background renders as garbage tiles"
|
|
230
|
+
|
|
231
|
+
SNES CHR (tile patterns) and the BG tilemap share the 64 KB VRAM, addressed in
|
|
232
|
+
**words**. CHR starts at word `$0000`; if your CHR is 16 KB it occupies words
|
|
233
|
+
`$0000-$1FFF`, so a tilemap placed at word `$2000` collides with the tail of CHR
|
|
234
|
+
and you get garbage. **Put the tilemap at word `$4000` or later when your CHR is
|
|
235
|
+
big.** Verify the bases with `background({view:'renderState', platform:'snes'})`
|
|
236
|
+
(it decodes the BG char/map base) and cross-check the tile region with
|
|
237
|
+
`tiles({op:'png'})` at the CHR base.
|
|
238
|
+
|
|
196
239
|
## "I want to use real graphics from a PNG"
|
|
197
240
|
|
|
198
241
|
`gfx4snes` (the PVSnesLib companion tool) converts PNG → .pic + .pal.
|
package/src/playtest/playtest.js
CHANGED
|
@@ -231,6 +231,32 @@ const KEY_TO_LIBRETRO_BIT = {
|
|
|
231
231
|
w: 11, // R shoulder
|
|
232
232
|
};
|
|
233
233
|
|
|
234
|
+
// C64-only keyboard fallback: PC key → the virtual C64 button name the host's
|
|
235
|
+
// C64 layer maps to the key matrix (Space/Run-Stop/Return/F1-F7). Lets a human
|
|
236
|
+
// with NO controller still reach the C64 keyboard keys games need to start.
|
|
237
|
+
// (Arrows + Z give the joystick + Fire via KEY_TO_LIBRETRO_BIT above.)
|
|
238
|
+
const C64_KEYBOARD_FALLBACK = {
|
|
239
|
+
f1: "c64_f1", f2: "c64_f3", f3: "c64_f5", f4: "c64_f7", // F1-F4 → C64 F1/F3/F5/F7
|
|
240
|
+
space: "west", // Space
|
|
241
|
+
return: "r2", // Return (also START via the standard map — harmless)
|
|
242
|
+
escape: "l2", // Run/Stop (note: ESC also closes — see handler order)
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// Human-readable C64 controls (controller + keyboard), relayed to the user when
|
|
246
|
+
// a C64 game is in the playtest window so they're not guessing.
|
|
247
|
+
export const C64_BINDINGS_HELP = `C64 — a CONTROLLER alone is enough (no keyboard needed):
|
|
248
|
+
D-pad / Left stick Joystick (port 2 by default)
|
|
249
|
+
Z / bottom face Fire
|
|
250
|
+
X face / Space key Space
|
|
251
|
+
L2 Run/Stop
|
|
252
|
+
R2 / Enter Return
|
|
253
|
+
Right stick ↑/←/→/↓ F1 / F3 / F5 / F7 (the 1-player / start keys)
|
|
254
|
+
Top face F1 (also)
|
|
255
|
+
|
|
256
|
+
No controller? Keyboard fallback: Arrows = joystick, Z = Fire, F1-F4 = C64
|
|
257
|
+
F1/F3/F5/F7, Space = Space, Enter = Return, ESC = Run/Stop (hold; ESC tapped
|
|
258
|
+
also closes the window). Switch joystick port with input({op:'joyport'}).`;
|
|
259
|
+
|
|
234
260
|
// Human-readable summary printed by --help and at playtest startup.
|
|
235
261
|
export const KEYBOARD_BINDINGS_HELP = `Keyboard:
|
|
236
262
|
Arrow keys D-pad
|
|
@@ -578,6 +604,7 @@ export async function playtest(args) {
|
|
|
578
604
|
// into its own port object; the agent's setInput is overwritten each
|
|
579
605
|
// tick (matching prior behavior). Select+Start on any controller quits.
|
|
580
606
|
let quit = false;
|
|
607
|
+
const isC64 = h.status?.platform === "c64";
|
|
581
608
|
function readControllerInto(port, inst) {
|
|
582
609
|
if (!inst) return;
|
|
583
610
|
const btn = inst.buttons || {};
|
|
@@ -595,6 +622,18 @@ export async function playtest(args) {
|
|
|
595
622
|
else if (lx < -STICK_DEADZONE) port.left = true;
|
|
596
623
|
if (ly > STICK_DEADZONE) port.down = true;
|
|
597
624
|
else if (ly < -STICK_DEADZONE) port.up = true;
|
|
625
|
+
// C64: the RIGHT stick selects the function keys (F1/F3/F5/F7) — the
|
|
626
|
+
// Batocera/RetroDeck convention so a controller alone reaches the keyboard
|
|
627
|
+
// keys C64 setup screens need. Emitted as virtual buttons the host's C64
|
|
628
|
+
// layer maps to the key matrix; harmless on other platforms (no mapping).
|
|
629
|
+
if (isC64) {
|
|
630
|
+
const rx = axes.rightStickX ?? 0;
|
|
631
|
+
const ry = axes.rightStickY ?? 0;
|
|
632
|
+
if (ry < -STICK_DEADZONE) port.c64_f1 = true; // up → F1
|
|
633
|
+
else if (ry > STICK_DEADZONE) port.c64_f7 = true; // down → F7
|
|
634
|
+
if (rx < -STICK_DEADZONE) port.c64_f3 = true; // left → F3
|
|
635
|
+
else if (rx > STICK_DEADZONE) port.c64_f5 = true; // right → F5
|
|
636
|
+
}
|
|
598
637
|
}
|
|
599
638
|
|
|
600
639
|
const port0 = {};
|
|
@@ -619,6 +658,15 @@ export async function playtest(args) {
|
|
|
619
658
|
for (const [keyName, bit] of Object.entries(KEY_TO_LIBRETRO_BIT)) {
|
|
620
659
|
if (heldKeys.has(keyName)) port0[bitToName(bit)] = true;
|
|
621
660
|
}
|
|
661
|
+
// C64 keyboard fallback (no controller / mixing): map PC keys to the C64
|
|
662
|
+
// KEYBOARD keys games need — the host's C64 layer routes these virtual
|
|
663
|
+
// button names to the key matrix. (Arrows + Z=Fire already give the
|
|
664
|
+
// joystick above.) The agent relays these to the human.
|
|
665
|
+
if (isC64) {
|
|
666
|
+
for (const [keyName, vbtn] of Object.entries(C64_KEYBOARD_FALLBACK)) {
|
|
667
|
+
if (heldKeys.has(keyName)) port0[vbtn] = true;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
622
670
|
const isRewinding = heldKeys.has("r") && rewindBuffer.length > 0;
|
|
623
671
|
if (isRewinding) {
|
|
624
672
|
// Restore the previous snapshot and run one frame to produce its visual.
|
|
@@ -152,12 +152,29 @@ export function lintSdccSource(source, file = "main.c", opts = {}) {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
// ─── __xdata / VRAM byte-copy miscompile ────────────────────────
|
|
155
|
-
// SDCC sm83 miscompiles `for (i...) dst[i] = src[i];` when dst is an
|
|
155
|
+
// SDCC sm83 miscompiles `for (i...) dst[i] = src[i];` ONLY when dst is an
|
|
156
156
|
// __xdata pointer (e.g. into VRAM $8000) — it writes through the return
|
|
157
|
-
// address and crashes the CPU.
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
157
|
+
// address and crashes the CPU. A plain WRAM array copy (`static uint8_t
|
|
158
|
+
// rb[78]; ... rb[i]=grid[i];`) is perfectly fine. The old lint flagged the
|
|
159
|
+
// SHAPE unconditionally as a "warning" — every WRAM copy in every genre
|
|
160
|
+
// scaffold cried wolf, training agents to distrust the linter. We now
|
|
161
|
+
// classify the DESTINATION identifier before deciding the severity:
|
|
162
|
+
//
|
|
163
|
+
// • PROVABLY VRAM/__xdata → "warning" (the real crash-class footgun)
|
|
164
|
+
// - dst is declared as a POINTER (`type *dst`) — only pointers can
|
|
165
|
+
// alias __xdata; an indexed write through one is the bug.
|
|
166
|
+
// - dst is a known-VRAM name (vram*, VRAM, *vram*, bgmap, _VRAM*).
|
|
167
|
+
// - dst is assigned from a cast/literal in $8000-$9FFF anywhere in
|
|
168
|
+
// the source (e.g. `dst = (uint8_t*)0x9800;`).
|
|
169
|
+
// • PLAIN RAM ARRAY → SUPPRESS (declared `type dst[N];` here).
|
|
170
|
+
// • UNKNOWN (bare ident, → "info" — visible, not scary. Better to
|
|
171
|
+
// no decl in this TU) occasionally downgrade a real VRAM case to
|
|
172
|
+
// info than to keep crying wolf on WRAM.
|
|
173
|
+
//
|
|
174
|
+
// The crash cases that MATTER (the documented memcpy_vram footgun) are
|
|
175
|
+
// pointer-to-VRAM, which the "provably VRAM" path still catches as a
|
|
176
|
+
// warning.
|
|
177
|
+
const dstClass = classifyCopyDest(lines);
|
|
161
178
|
for (let i = 0; i < lines.length; i++) {
|
|
162
179
|
const code = lines[i].replace(/\/\/.*$/, "").replace(/\/\*.*?\*\//g, "");
|
|
163
180
|
// indexed-to-indexed copy: ident[idx] = ident[idx]; (same index token)
|
|
@@ -168,17 +185,72 @@ export function lintSdccSource(source, file = "main.c", opts = {}) {
|
|
|
168
185
|
// Require a for-loop driving this copy (this line or the 2 above).
|
|
169
186
|
const ctx = (lines[i] + "\n" + (lines[i - 1] || "") + "\n" + (lines[i - 2] || ""));
|
|
170
187
|
if (!/\bfor\s*\(/.test(ctx)) continue;
|
|
188
|
+
const dst = cp[1];
|
|
189
|
+
// A VRAM-suggestive NAME promotes an otherwise-unknown dest to VRAM even
|
|
190
|
+
// with no decl in this TU (e.g. `vram_buf[i] = tiles[i];`). A declared
|
|
191
|
+
// plain array still wins as "array" (suppress) — names rarely collide.
|
|
192
|
+
const klass = dstClass.get(dst) || (isVramName(dst) ? "vram" : "unknown");
|
|
193
|
+
if (klass === "array") continue; // plain WRAM array — provably safe, suppress
|
|
194
|
+
const isVram = klass === "vram";
|
|
171
195
|
issues.push({
|
|
172
|
-
|
|
196
|
+
// Provably-VRAM → warning (the crash-class footgun). Unknown bare
|
|
197
|
+
// pointer → info (visible, not "your code is broken"). Never critical:
|
|
198
|
+
// even the VRAM case only miscompiles, it isn't an unconditional hang.
|
|
199
|
+
severity: isVram ? "warning" : "info",
|
|
173
200
|
file,
|
|
174
201
|
line: i + 1,
|
|
175
202
|
stage: "lint",
|
|
176
|
-
message:
|
|
177
|
-
|
|
203
|
+
message: isVram
|
|
204
|
+
? `byte-copy loop \`${dst}[${idx1}] = ${cp[3]}[${idx2}]\` into VRAM/__xdata — ${portLabel} miscompiles this`
|
|
205
|
+
: `byte-copy loop \`${dst}[${idx1}] = ${cp[3]}[${idx2}]\` — safe for WRAM arrays, but miscompiles if '${dst}' points into VRAM/__xdata`,
|
|
206
|
+
details: isVram
|
|
207
|
+
? `${portLabel} miscompiles this pattern when '${dst}' points into VRAM ($8000-$9FFF) or another __xdata region — it writes through the return address and crashes the CPU (PC near $002B, sprites/tiles never show). Use \`memcpy_vram(${dst}, ${cp[3]}, n)\` (in gb_runtime.c) instead. See GB TROUBLESHOOTING § the #1 SDCC footgun.`
|
|
208
|
+
: `If '${dst}' is a plain WRAM array (\`type ${dst}[N];\`) this is FINE — ignore. ${portLabel} only miscompiles it when '${dst}' is a pointer into VRAM ($8000-$9FFF)/__xdata, where it writes through the return address and crashes the CPU. If '${dst}' is a VRAM pointer, use \`memcpy_vram(${dst}, ${cp[3]}, n)\` instead. See GB TROUBLESHOOTING § the #1 SDCC footgun.`,
|
|
178
209
|
ref: "xdata-copy-miscompile",
|
|
179
210
|
});
|
|
180
211
|
}
|
|
181
212
|
|
|
213
|
+
// ─── hardcoded $C000-area WRAM pointer overlaps the C statics ───
|
|
214
|
+
// SDCC links `_DATA`/`_INITIALIZED` (value-init statics) + `_BSS` (zero-init
|
|
215
|
+
// statics) at the BOTTOM of WRAM starting $C000. A program that ALSO pokes a
|
|
216
|
+
// hardcoded pointer into that low range (e.g. `(uint8_t*)0xC000`) scribbles
|
|
217
|
+
// over its own statics — the seed of a PRNG, a collision grid, the score —
|
|
218
|
+
// and the symptom looks EXACTLY like an SDCC codegen bug (a 32-bit xorshift
|
|
219
|
+
// that "degenerates" because its seed got clobbered, never a real
|
|
220
|
+
// miscompile). This was the real root cause behind a GBC Columns agent's
|
|
221
|
+
// "monochrome RNG" report (2026-06-08); the math itself compiles correctly.
|
|
222
|
+
//
|
|
223
|
+
// ONLY for the sm83/z80 GB/SMS-family, whose WRAM base is $C000. Flag the
|
|
224
|
+
// low 256 bytes ($C000-$C0FF) where _DATA/_INITIALIZED live (small projects'
|
|
225
|
+
// statics sit here; $C100 is shadow_oam; $C200+ is the documented-safe
|
|
226
|
+
// scratch floor). INFO severity — visible, not "your code is broken": a
|
|
227
|
+
// hardcoded low pointer is occasionally legitimate (e.g. you've checked the
|
|
228
|
+
// map). NEVER critical.
|
|
229
|
+
if (port === "sm83" || port === "z80") {
|
|
230
|
+
// pointer cast/decl/assignment to a $C0xx literal: `(uint8_t*)0xC000`,
|
|
231
|
+
// `uint8_t *p = (uint8_t*)0xc010;`, `p = 0xC0FF;`
|
|
232
|
+
const ptrLit = /\(\s*(?:volatile\s+|const\s+|unsigned\s+|signed\s+)*[A-Za-z_]\w*\s*\*+\s*\)\s*0x(C0[0-9a-fA-F]{2})\b/;
|
|
233
|
+
const seen = new Set();
|
|
234
|
+
for (let i = 0; i < lines.length; i++) {
|
|
235
|
+
const code = lines[i].replace(/\/\/.*$/, "").replace(/\/\*.*?\*\//g, "");
|
|
236
|
+
const m = code.match(ptrLit);
|
|
237
|
+
if (!m) continue;
|
|
238
|
+
const addr = parseInt(m[1], 16); // already the full $C0xx address
|
|
239
|
+
const key = i + ":" + addr;
|
|
240
|
+
if (seen.has(key)) continue;
|
|
241
|
+
seen.add(key);
|
|
242
|
+
issues.push({
|
|
243
|
+
severity: "info",
|
|
244
|
+
file,
|
|
245
|
+
line: i + 1,
|
|
246
|
+
stage: "lint",
|
|
247
|
+
message: `hardcoded WRAM pointer $${addr.toString(16).toUpperCase()} overlaps the C static-data segment ($C000-)`,
|
|
248
|
+
details: `${portLabel} links your value- and zero-initialised \`static\` globals (PRNG seeds, grids, scores) at the BOTTOM of WRAM from $C000. A hardcoded pointer into $C000-$C0FF can scribble over them — the classic symptom is a PRNG/array that looks "miscompiled" (e.g. an xorshift whose seed got clobbered so every roll is identical) when the math is actually fine. Prefer a \`static\` array and let the linker place it; if you must hardcode, use $C200+ and verify with the linker map (build with includeSymbols:true → check s__DATA/s__BSS). $C100 is shadow_oam. See ${port === "sm83" ? "GB/GBC" : "SMS/GG"} SDCC_GOTCHAS.md § "sm83 codegen traps in plain game logic".`,
|
|
249
|
+
ref: "wram-static-overlap",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
182
254
|
// Mid-block declarations (rough heuristic — flags any `type name [=...] ;`
|
|
183
255
|
// that appears after a non-decl, non-blank statement at deeper indent
|
|
184
256
|
// than the function opening brace).
|
|
@@ -206,6 +278,90 @@ export function lintSources(sources, opts = {}) {
|
|
|
206
278
|
|
|
207
279
|
// ─── helpers ───────────────────────────────────────────────────────
|
|
208
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Known-VRAM symbol names (GB/GBC/SMS conventions). Case-insensitive;
|
|
283
|
+
* substring "vram" matches vram_ptr / pVRAM / VRAMbase, plus the common
|
|
284
|
+
* GB BG-map / tile-data symbols. A dest with such a name is treated as a
|
|
285
|
+
* VRAM pointer even with no visible declaration.
|
|
286
|
+
* @param {string} n
|
|
287
|
+
* @returns {boolean}
|
|
288
|
+
*/
|
|
289
|
+
function isVramName(n) {
|
|
290
|
+
return /vram/i.test(n) || /^(?:bgmap|tilemap|tiledata|chrram|_VRAM)/i.test(n);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Classify each identifier that is the destination of a copy loop into one
|
|
295
|
+
* of: "vram" (provably a VRAM/__xdata pointer → real crash-class footgun),
|
|
296
|
+
* "array" (declared as a plain `type name[N];` RAM array → provably safe,
|
|
297
|
+
* suppress the warning), or absent (unknown — caller treats as "info").
|
|
298
|
+
*
|
|
299
|
+
* This is a whole-TU pass: a name is classified by scanning the ENTIRE
|
|
300
|
+
* source, so a `uint8_t *dst;` decl far above the loop, or a later
|
|
301
|
+
* `dst = (uint8_t*)0x9800;` assignment, still classifies it as VRAM.
|
|
302
|
+
*
|
|
303
|
+
* Precedence: VRAM wins over array (a name that is BOTH a pointer and,
|
|
304
|
+
* say, shadowed by an array elsewhere should still be treated as the
|
|
305
|
+
* dangerous case — but in practice a single name is one or the other).
|
|
306
|
+
*
|
|
307
|
+
* @param {string[]} lines source split into lines
|
|
308
|
+
* @returns {Map<string,"vram"|"array">}
|
|
309
|
+
*/
|
|
310
|
+
function classifyCopyDest(lines) {
|
|
311
|
+
/** @type {Map<string,"vram"|"array">} */
|
|
312
|
+
const klass = new Map();
|
|
313
|
+
const setVram = (n) => { klass.set(n, "vram"); };
|
|
314
|
+
const setArray = (n) => { if (klass.get(n) !== "vram") klass.set(n, "array"); };
|
|
315
|
+
|
|
316
|
+
// A literal/cast value lands in VRAM if it's 0x8000–0x9FFF.
|
|
317
|
+
const inVramRange = (hexOrDec) => {
|
|
318
|
+
const v = /^0x/i.test(hexOrDec) ? parseInt(hexOrDec, 16) : parseInt(hexOrDec, 10);
|
|
319
|
+
return Number.isFinite(v) && v >= 0x8000 && v <= 0x9fff;
|
|
320
|
+
};
|
|
321
|
+
// Type keywords that introduce a declaration (subset is fine — we only
|
|
322
|
+
// need to tell "pointer decl" from "array decl").
|
|
323
|
+
const TYPE = "(?:unsigned\\s+|signed\\s+)?(?:char|short|int|long|void|u?int(?:8|16|32|64)_t|u8|u16|u32|u64|uint8|uint16|uint32|uint64|int8|int16|int32|int64|size_t|[A-Z][A-Za-z0-9_]*_t)";
|
|
324
|
+
const QUAL = "(?:static\\s+|const\\s+|register\\s+|volatile\\s+|extern\\s+|auto\\s+|__xdata\\s+|__at\\s*\\([^)]*\\)\\s*)*";
|
|
325
|
+
// Pointer declaration: `<quals> <type> * name` (one or more `*`).
|
|
326
|
+
const ptrDeclRe = new RegExp(`\\b${QUAL}${TYPE}\\s*\\*+\\s*([A-Za-z_]\\w*)`, "g");
|
|
327
|
+
// Array declaration: `<quals> <type> name[ ... ]` (NOT a pointer).
|
|
328
|
+
const arrDeclRe = new RegExp(`\\b${QUAL}${TYPE}\\s+([A-Za-z_]\\w*)\\s*\\[`, "g");
|
|
329
|
+
// Pointer assigned a VRAM literal/cast: name = (cast?) 0x8xxx/0x9xxx
|
|
330
|
+
// e.g. dst = (uint8_t*)0x9800; p = (void*)0x8000; q = 0x8800;
|
|
331
|
+
const vramAssignRe = /\b([A-Za-z_]\w*)\s*=\s*(?:\([^)]*\)\s*)?(0x[0-9a-fA-F]+|\d{4,})\b/g;
|
|
332
|
+
// Pointer DECL with an inline VRAM initializer:
|
|
333
|
+
// uint8_t *dst = (uint8_t*)0x8000;
|
|
334
|
+
const ptrInitVramRe = new RegExp(`\\b${QUAL}${TYPE}\\s*\\*+\\s*([A-Za-z_]\\w*)\\s*=\\s*(?:\\([^)]*\\)\\s*)?(0x[0-9a-fA-F]+|\\d{4,})`, "g");
|
|
335
|
+
|
|
336
|
+
for (let i = 0; i < lines.length; i++) {
|
|
337
|
+
const code = lines[i].replace(/\/\/.*$/, "").replace(/\/\*.*?\*\//g, "");
|
|
338
|
+
|
|
339
|
+
// 1) pointer decl with a VRAM-range initializer → VRAM (strongest).
|
|
340
|
+
ptrInitVramRe.lastIndex = 0;
|
|
341
|
+
for (let m; (m = ptrInitVramRe.exec(code)); ) {
|
|
342
|
+
if (inVramRange(m[2])) setVram(m[1]);
|
|
343
|
+
}
|
|
344
|
+
// 2) any name assigned a VRAM-range literal/cast → VRAM.
|
|
345
|
+
vramAssignRe.lastIndex = 0;
|
|
346
|
+
for (let m; (m = vramAssignRe.exec(code)); ) {
|
|
347
|
+
if (inVramRange(m[2])) setVram(m[1]);
|
|
348
|
+
}
|
|
349
|
+
// 3) pointer declarations → VRAM-candidate (only pointers can alias
|
|
350
|
+
// __xdata; an indexed write through one is the documented bug).
|
|
351
|
+
ptrDeclRe.lastIndex = 0;
|
|
352
|
+
for (let m; (m = ptrDeclRe.exec(code)); ) setVram(m[1]);
|
|
353
|
+
// 4) plain array declarations → safe RAM array (unless already VRAM).
|
|
354
|
+
arrDeclRe.lastIndex = 0;
|
|
355
|
+
for (let m; (m = arrDeclRe.exec(code)); ) setArray(m[1]);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 5) name-based VRAM override: any dest named like VRAM is VRAM even if
|
|
359
|
+
// it was (mis)classified as an array by a same-named decl elsewhere.
|
|
360
|
+
for (const n of klass.keys()) if (isVramName(n)) setVram(n);
|
|
361
|
+
|
|
362
|
+
return klass;
|
|
363
|
+
}
|
|
364
|
+
|
|
209
365
|
/**
|
|
210
366
|
* Detect mid-block variable declarations (C89 violation). Simple state
|
|
211
367
|
* machine: track block depth + whether we've seen a non-decl statement
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { buildForPlatform } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/toolchains/index.js";
|
|
3
|
-
import { resolveCore } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/cores/registry.js";
|
|
4
|
-
import { LibretroHost } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/host/LibretroHost.js";
|
|
5
|
-
|
|
6
|
-
const LIBDIR = "/home/monteslu/code/cliemu/romdev/packages/romdev/src/platforms/msx/lib/c";
|
|
7
|
-
const DIR = "/home/monteslu/code/cliemu/romdev/packages/romdev/examples/msx/catch_game";
|
|
8
|
-
|
|
9
|
-
const SRC = readFileSync(`${DIR}/main.c`, "utf8");
|
|
10
|
-
const LIB = readFileSync(`${LIBDIR}/msx_vdp.c`, "utf8");
|
|
11
|
-
const CRT0 = readFileSync(`${LIBDIR}/msx_crt0.s`, "utf8");
|
|
12
|
-
const HDR = readFileSync(`${LIBDIR}/msx_hw.h`, "utf8");
|
|
13
|
-
|
|
14
|
-
const PLATFORM = "msx";
|
|
15
|
-
|
|
16
|
-
console.log("== building ==");
|
|
17
|
-
const build = await buildForPlatform({
|
|
18
|
-
platform: PLATFORM,
|
|
19
|
-
sources: { "main.c": SRC, "msx_vdp.c": LIB, "msx_crt0.s": CRT0 },
|
|
20
|
-
includes: { "msx_hw.h": HDR },
|
|
21
|
-
crt0: ".module empty\n",
|
|
22
|
-
sourceName: "main.c",
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
if (!build.binary) {
|
|
26
|
-
console.log(build.log || build.stderr || JSON.stringify(build));
|
|
27
|
-
console.log("BUILD FAILED stage=", build.stage, "exit=", build.exitCode);
|
|
28
|
-
process.exit(1);
|
|
29
|
-
}
|
|
30
|
-
console.log("build OK, bytes=", build.binary.length);
|
|
31
|
-
|
|
32
|
-
const c = resolveCore(PLATFORM);
|
|
33
|
-
const h = new LibretroHost();
|
|
34
|
-
await h.loadCore(c.jsPath, c.wasmPath);
|
|
35
|
-
await h.loadMedia({ platform: PLATFORM, bytes: build.binary });
|
|
36
|
-
|
|
37
|
-
// ---- honest pixel helpers over raw RGBA ----
|
|
38
|
-
function nonBlackFraction(rgba) {
|
|
39
|
-
let nb = 0;
|
|
40
|
-
for (let i = 0; i < rgba.length; i += 4) {
|
|
41
|
-
if (rgba[i] > 16 || rgba[i + 1] > 16 || rgba[i + 2] > 16) nb++;
|
|
42
|
-
}
|
|
43
|
-
return nb / (rgba.length / 4);
|
|
44
|
-
}
|
|
45
|
-
// Find the brightest-pixel X centroid in a horizontal band of rows (the basket
|
|
46
|
-
// rides near the bottom; the basket sprite is white). Returns -1 if no bright px.
|
|
47
|
-
function brightCentroidX(frame, y0, y1) {
|
|
48
|
-
const { width, height, rgba } = frame;
|
|
49
|
-
let sx = 0, n = 0;
|
|
50
|
-
for (let y = y0; y < Math.min(y1, height); y++) {
|
|
51
|
-
for (let x = 0; x < width; x++) {
|
|
52
|
-
const i = (y * width + x) * 4;
|
|
53
|
-
// white basket: all channels high
|
|
54
|
-
if (rgba[i] > 180 && rgba[i + 1] > 180 && rgba[i + 2] > 180) { sx += x; n++; }
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return n ? sx / n : -1;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// boot past the C-BIOS logo + a few game frames
|
|
61
|
-
for (let i = 0; i < 320; i++) h.stepFrames(1);
|
|
62
|
-
|
|
63
|
-
const sa = h.screenshotRgba();
|
|
64
|
-
writeFileSync(`${DIR}/shot_before.png`, Buffer.from(h.screenshot().pngBase64, "base64"));
|
|
65
|
-
// basket band: BASKET_Y=160 in a 192-tall internal buffer; band 150..180
|
|
66
|
-
const fracA = nonBlackFraction(sa.rgba);
|
|
67
|
-
const basketA = brightCentroidX(sa, Math.floor(sa.height * 0.78), Math.floor(sa.height * 0.95));
|
|
68
|
-
console.log(`before: ${sa.width}x${sa.height} nonBlack=${(fracA*100).toFixed(2)}% basketX=${basketA.toFixed(1)}`);
|
|
69
|
-
|
|
70
|
-
// Drive RIGHT for many frames
|
|
71
|
-
for (let i = 0; i < 120; i++) { h.setInput({ ports: [{ right: true }] }); h.stepFrames(1); }
|
|
72
|
-
const sb = h.screenshotRgba();
|
|
73
|
-
writeFileSync(`${DIR}/shot_after_right.png`, Buffer.from(h.screenshot().pngBase64, "base64"));
|
|
74
|
-
const basketB = brightCentroidX(sb, Math.floor(sb.height * 0.78), Math.floor(sb.height * 0.95));
|
|
75
|
-
console.log(`after RIGHT: basketX=${basketB.toFixed(1)}`);
|
|
76
|
-
|
|
77
|
-
// Drive LEFT for many frames
|
|
78
|
-
for (let i = 0; i < 200; i++) { h.setInput({ ports: [{ left: true }] }); h.stepFrames(1); }
|
|
79
|
-
const sc = h.screenshotRgba();
|
|
80
|
-
writeFileSync(`${DIR}/shot_after_left.png`, Buffer.from(h.screenshot().pngBase64, "base64"));
|
|
81
|
-
const basketC = brightCentroidX(sc, Math.floor(sc.height * 0.78), Math.floor(sc.height * 0.95));
|
|
82
|
-
console.log(`after LEFT: basketX=${basketC.toFixed(1)}`);
|
|
83
|
-
|
|
84
|
-
// ---- verdict ----
|
|
85
|
-
const visible = fracA > 0.005; // playfield is not black
|
|
86
|
-
const movedRight = basketB > basketA + 5; // basket moved right with input
|
|
87
|
-
const movedLeft = basketC < basketB - 5; // basket moved back left
|
|
88
|
-
console.log("VISIBLE:", visible, "MOVED_RIGHT:", movedRight, "MOVED_LEFT:", movedLeft);
|
|
89
|
-
if (visible && movedRight && movedLeft) {
|
|
90
|
-
console.log("VERIFIED_OK");
|
|
91
|
-
} else {
|
|
92
|
-
console.log("VERIFY_INCOMPLETE");
|
|
93
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import { buildForPlatform } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/toolchains/index.js";
|
|
3
|
-
import { resolveCore } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/cores/registry.js";
|
|
4
|
-
import { LibretroHost } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/host/LibretroHost.js";
|
|
5
|
-
|
|
6
|
-
const LIB = "/home/monteslu/code/cliemu/romdev/packages/romdev/src/platforms/pce/lib/c";
|
|
7
|
-
const DIR = "/home/monteslu/code/cliemu/romdev/packages/romdev/examples/pce/catch_game";
|
|
8
|
-
|
|
9
|
-
const read = (p) => fs.readFileSync(p, "utf8");
|
|
10
|
-
|
|
11
|
-
const sources = {
|
|
12
|
-
"main.c": read(DIR + "/main.c"),
|
|
13
|
-
"pce_video.c": read(LIB + "/pce_video.c"),
|
|
14
|
-
"pce_input.c": read(LIB + "/pce_input.c"),
|
|
15
|
-
"pce_sound.c": read(LIB + "/pce_sound.c"),
|
|
16
|
-
};
|
|
17
|
-
const headers = { "pce_hw.h": read(LIB + "/pce_hw.h") };
|
|
18
|
-
|
|
19
|
-
console.log("Building catch_game...");
|
|
20
|
-
const build = await buildForPlatform({
|
|
21
|
-
platform: "pce",
|
|
22
|
-
sources,
|
|
23
|
-
headers,
|
|
24
|
-
includes: headers,
|
|
25
|
-
sourceName: "main.c",
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
if (!build || !build.binary) {
|
|
29
|
-
console.log("BUILD FAILED");
|
|
30
|
-
console.log(JSON.stringify(build, null, 2));
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
33
|
-
console.log("BUILD OK, binary bytes =", build.binary.length);
|
|
34
|
-
if (build.log) console.log("log tail:", String(build.log).slice(-400));
|
|
35
|
-
|
|
36
|
-
const PLATFORM = "pce";
|
|
37
|
-
const h = new LibretroHost();
|
|
38
|
-
const c = resolveCore(PLATFORM);
|
|
39
|
-
await h.loadCore(c.jsPath, c.wasmPath);
|
|
40
|
-
await h.loadMedia({ platform: PLATFORM, bytes: build.binary });
|
|
41
|
-
|
|
42
|
-
// boot
|
|
43
|
-
for (let i = 0; i < 120; i++) h.stepFrames(1);
|
|
44
|
-
|
|
45
|
-
function nonBlack(shot) {
|
|
46
|
-
const buf = Buffer.from(shot.pngBase64, "base64");
|
|
47
|
-
// crude: count distinct-ish bytes; rely on file size + later pixel scan
|
|
48
|
-
return buf.length;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// screenshot BEFORE input
|
|
52
|
-
let shotA = h.screenshot();
|
|
53
|
-
fs.writeFileSync(DIR + "/shot_before.png", Buffer.from(shotA.pngBase64, "base64"));
|
|
54
|
-
console.log("before:", shotA.width, "x", shotA.height, "png bytes", nonBlack(shotA));
|
|
55
|
-
|
|
56
|
-
// drive RIGHT for ~60 frames to move catcher and let fruit fall/catch
|
|
57
|
-
for (let i = 0; i < 90; i++) {
|
|
58
|
-
h.setInput({ ports: [{ right: true }] });
|
|
59
|
-
h.stepFrames(1);
|
|
60
|
-
}
|
|
61
|
-
// then LEFT for a bit
|
|
62
|
-
for (let i = 0; i < 60; i++) {
|
|
63
|
-
h.setInput({ ports: [{ left: true }] });
|
|
64
|
-
h.stepFrames(1);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
let shotB = h.screenshot();
|
|
68
|
-
fs.writeFileSync(DIR + "/shot_after.png", Buffer.from(shotB.pngBase64, "base64"));
|
|
69
|
-
console.log("after:", shotB.width, "x", shotB.height, "png bytes", nonBlack(shotB));
|
|
70
|
-
|
|
71
|
-
// pixel diff: decode both PNGs to raw via the host's framebuffer if available
|
|
72
|
-
const same = shotA.pngBase64 === shotB.pngBase64;
|
|
73
|
-
console.log("identical PNG?", same);
|
|
74
|
-
|
|
75
|
-
console.log("done — see shot_before.png / shot_after.png");
|