romdevtools 0.27.0 → 0.28.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 +5 -3
- package/CHANGELOG.md +309 -0
- package/README.md +1 -1
- package/examples/README.md +1 -1
- package/examples/atari2600/templates/platformer.asm +18 -9
- package/examples/atari2600/templates/racing.asm +25 -4
- package/examples/atari2600/templates/shmup.asm +30 -5
- package/examples/atari2600/templates/sports.asm +41 -9
- package/examples/atari7800/templates/hello_sprite.c +8 -4
- package/examples/atari7800/templates/platformer.c +12 -8
- package/examples/atari7800/templates/puzzle.c +7 -4
- package/examples/atari7800/templates/racing.c +5 -2
- package/examples/atari7800/templates/shmup.c +8 -4
- package/examples/atari7800/templates/sports.c +6 -3
- package/examples/c64/templates/platformer.c +28 -24
- package/examples/c64/templates/puzzle.c +77 -16
- package/examples/c64/templates/racing.c +9 -0
- package/examples/c64/templates/shmup.c +13 -1
- package/examples/c64/templates/sports.c +9 -4
- package/examples/gb/templates/platformer.c +6 -2
- package/examples/gb/templates/puzzle.c +279 -101
- package/examples/gb/templates/racing.c +13 -1
- package/examples/gb/templates/shmup.c +13 -1
- package/examples/gb/templates/sports.c +9 -3
- package/examples/gba/templates/platformer.c +7 -13
- package/examples/gba/templates/puzzle.c +93 -15
- package/examples/gba/templates/racing.c +13 -1
- package/examples/gba/templates/shmup.c +13 -1
- package/examples/gba/templates/sports.c +17 -5
- package/examples/gbc/templates/platformer.c +6 -2
- package/examples/gbc/templates/puzzle.c +878 -178
- package/examples/gbc/templates/racing.c +13 -1
- package/examples/gbc/templates/shmup.c +13 -1
- package/examples/gbc/templates/sports.c +9 -3
- package/examples/genesis/templates/puzzle.c +76 -15
- package/examples/genesis/templates/racing.c +13 -1
- package/examples/genesis/templates/shmup_2p.c +13 -1
- package/examples/gg/templates/platformer.c +4 -0
- package/examples/gg/templates/puzzle.c +80 -14
- package/examples/gg/templates/racing.c +17 -1
- package/examples/gg/templates/shmup.c +17 -1
- package/examples/gg/templates/sports.c +4 -0
- package/examples/lynx/templates/platformer.c +25 -6
- package/examples/lynx/templates/puzzle.c +77 -14
- package/examples/lynx/templates/shmup.c +13 -1
- package/examples/lynx/templates/sports.c +5 -2
- package/examples/msx/platformer/main.c +2 -0
- package/examples/msx/puzzle/main.c +78 -15
- package/examples/msx/racing/main.c +1 -0
- package/examples/msx/shmup/main.c +1 -0
- package/examples/msx/sports/main.c +3 -2
- package/examples/nes/templates/platformer.c +11 -3
- package/examples/nes/templates/puzzle.c +81 -21
- package/examples/nes/templates/racing.c +15 -1
- package/examples/nes/templates/shmup.c +1 -0
- package/examples/nes/templates/sports.c +1 -0
- package/examples/pce/platformer/main.c +3 -1
- package/examples/pce/puzzle/main.c +78 -12
- package/examples/pce/racing/main.c +1 -0
- package/examples/pce/shmup/main.c +5 -4
- package/examples/pce/sports/main.c +4 -3
- package/examples/sms/templates/platformer.c +4 -0
- package/examples/sms/templates/puzzle.c +80 -14
- package/examples/sms/templates/racing.c +17 -1
- package/examples/sms/templates/shmup.c +17 -1
- package/examples/sms/templates/shmup_2p.c +17 -1
- package/examples/sms/templates/sports.c +4 -0
- package/examples/snes/templates/platformer.c +32 -15
- package/examples/snes/templates/puzzle.c +84 -16
- package/examples/snes/templates/racing.c +20 -1
- package/examples/snes/templates/shmup.c +20 -2
- package/examples/snes/templates/sports.c +7 -0
- package/package.json +12 -12
- package/src/cores/wasm/bluemsx_libretro.js +1 -1
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +1 -1
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +1 -1
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +1 -1
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +1 -1
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +1 -1
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +1 -1
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +1 -1
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +1 -1
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- 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 +245 -10
- package/src/mcp/server.js +6 -0
- package/src/mcp/tools/disasm-rebuild.js +315 -65
- package/src/mcp/tools/disasm.js +149 -28
- package/src/mcp/tools/find-references.js +216 -51
- package/src/mcp/tools/frame.js +11 -4
- package/src/mcp/tools/index.js +15 -1
- package/src/mcp/tools/input.js +26 -3
- package/src/mcp/tools/memory.js +208 -39
- package/src/mcp/tools/playtest.js +56 -4
- package/src/mcp/tools/project.js +35 -9
- package/src/mcp/tools/toolchain.js +43 -10
- package/src/mcp/tools/watch-memory.js +141 -24
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
- package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
- package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
- package/src/platforms/atari7800/MENTAL_MODEL.md +27 -6
- package/src/platforms/gb/MENTAL_MODEL.md +16 -1
- package/src/platforms/gb/TROUBLESHOOTING.md +42 -0
- package/src/platforms/gb/lib/c/patch-header.js +7 -4
- package/src/platforms/gbc/MENTAL_MODEL.md +12 -0
- package/src/platforms/gbc/TROUBLESHOOTING.md +21 -0
- package/src/platforms/gbc/lib/c/font.h +43 -0
- package/src/platforms/gbc/lib/c/patch-header.js +7 -4
- package/src/platforms/genesis/MENTAL_MODEL.md +40 -6
- package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
- package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
- package/src/platforms/gg/TROUBLESHOOTING.md +13 -17
- package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
- package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
- package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
- package/src/platforms/msx/MENTAL_MODEL.md +6 -0
- package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
- package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
- package/src/platforms/msx/lib/c/msx_hw.h +2 -0
- package/src/platforms/msx/lib/c/msx_vdp.c +45 -0
- package/src/platforms/nes/MENTAL_MODEL.md +10 -3
- package/src/platforms/nes/lib/c/nes_runtime.c +41 -0
- package/src/platforms/nes/lib/c/nes_runtime.h +2 -0
- package/src/platforms/pce/MENTAL_MODEL.md +9 -0
- package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
- package/src/platforms/pce/lib/c/pce_hw.h +2 -1
- package/src/platforms/pce/lib/c/pce_sound.c +22 -0
- package/src/platforms/sms/MENTAL_MODEL.md +5 -0
- package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
- package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
- package/src/platforms/snes/MENTAL_MODEL.md +5 -0
- package/src/playtest/playtest.js +73 -3
- package/src/toolchains/index.js +37 -8
|
@@ -59,15 +59,34 @@ thousands of bytes and you'll drown).
|
|
|
59
59
|
on-screen value. `region` defaults to `system_ram`.
|
|
60
60
|
2. Change the value in-game (take damage, score a point), then
|
|
61
61
|
`memory({op:'searchNext', compare:'eq', value})` — or `compare:'gt'|'lt'|'changed'|'unchanged'|
|
|
62
|
-
'inc'|'dec'` when you don't know the new value.
|
|
62
|
+
'inc'|'dec'` when you don't know the new value. The relative compares work as the
|
|
63
|
+
FIRST narrow too (baselines are recorded at seed). Repeat until a handful remain.
|
|
63
64
|
3. Confirm: `memory({op:'write'})` the candidate and watch the screen react.
|
|
64
65
|
|
|
65
66
|
This is the Cheat-Engine/RetroArch loop. It is THE bread-and-butter primitive.
|
|
66
67
|
|
|
68
|
+
**Stored ≠ displayed.** When a correct-looking seed returns 0, the byte usually isn't the
|
|
69
|
+
raw number: seed `as:'bcd'` for packed-BCD scores (2 decimal digits per byte — very common
|
|
70
|
+
on NES), or `as:'digits'` for one byte per ON-SCREEN digit at any constant tile base (HUD
|
|
71
|
+
tile-index buffers; the matched base is reported per candidate, and `searchNext` keeps
|
|
72
|
+
comparing in the same representation). For displayed−1 lives or ÷10 scores, just seed the
|
|
73
|
+
transformed number. If an INPUT drives the value (position, velocity, charge), skip the
|
|
74
|
+
loop entirely: `memory({op:'diffRuns', portsA:[{right:true}]})` isolates it in one call.
|
|
75
|
+
|
|
67
76
|
`memory({op:'snapshot'})` + `memory({op:'diff'})` is for "which bytes did THIS one event touch?",
|
|
68
77
|
not for value hunting. `memory({op:'diff'})` defaults to a **clustered summary** (ranges +
|
|
69
78
|
stride) so it won't flood you — a reported stride (e.g. "islands at 0x80") is
|
|
70
|
-
usually a struct/entity array, each island one record.
|
|
79
|
+
usually a struct/entity array, each island one record. Small clusters (≤8 bytes) carry
|
|
80
|
+
`before`/`after` hex inline, and `minDelta:N` drops |after−before| < N so RNG/counter
|
|
81
|
+
wiggle disappears from the report.
|
|
82
|
+
|
|
83
|
+
**"Which byte does this INPUT drive?" → `memory({op:'diffRuns'})`** — runs the same start
|
|
84
|
+
state twice (savestate restore in between) under two different held inputs (`portsA` vs
|
|
85
|
+
`portsB`, default released) for `frames` each, and returns only the bytes that DIVERGE
|
|
86
|
+
between the runs, with run-A/run-B values on small clusters. One call replaces the whole
|
|
87
|
+
save → hold → step → dump → restore → hold-other → dump → diff loop; the frame counter and
|
|
88
|
+
all input-independent churn cancel out automatically. (The emulator is left at the end of
|
|
89
|
+
run B.)
|
|
71
90
|
|
|
72
91
|
---
|
|
73
92
|
|
|
@@ -140,7 +159,12 @@ the copy reads from, then `breakpoint({on:'write'})` on THAT.
|
|
|
140
159
|
**Precision — exact vs sampled.** The default `breakpoint({on:'write'})` is a core-level write
|
|
141
160
|
watchpoint: it returns the EXACT writing instruction's PC, captured inside the CPU write
|
|
142
161
|
path — correct even for NMI/IRQ-driven writes (the common case where a frame-sampled PC
|
|
143
|
-
is just the idle loop). On
|
|
162
|
+
is just the idle loop). On ALL 14 platforms, every hit (write/read/pc) also carries
|
|
163
|
+
**`registersAtHit`** — the full register file frozen AT the hit instant — and the CPU
|
|
164
|
+
stays FROZEN until the hit is cleared. Use registersAtHit instead of a follow-up
|
|
165
|
+
`cpu({op:'read'})`: pre-0.28.0 the live registers kept running after a hit (on gpgx they
|
|
166
|
+
drifted hundreds of instructions — address registers read that way were someone else's
|
|
167
|
+
values). On a banked mapper it reports the `bank` (NES/GB/SMS-GG) so you
|
|
144
168
|
can pass `{startAddress, bank}` to `disasm({target:'rom'})`. The lighter
|
|
145
169
|
`breakpoint({on:'write', precision:'sampled'})` (a.k.a. `watch({on:'mem'})`) steps until the byte changes
|
|
146
170
|
and returns a frame-boundary PC — a lead, not a guarantee under interrupts; use it for the
|
|
@@ -197,6 +221,16 @@ pushes a sentinel return, and runs until it returns. Most of these formats have
|
|
|
197
221
|
you can usually craft a replacement by hand. (sandbox:false leaves the dest buffer
|
|
198
222
|
live for `memory({op:'read'})`; sandbox:true restores the game untouched.)
|
|
199
223
|
|
|
224
|
+
**Pass `pure:true` — on every platform.** A non-pure call that spans frames runs the
|
|
225
|
+
game's OWN frame logic concurrently (VBlank handlers via RAM vectors, music
|
|
226
|
+
drivers) — which can overwrite the dest buffer mid-call and hand you poisoned
|
|
227
|
+
"ground truth" (a real session spent hours diffing a CORRECT reimplementation
|
|
228
|
+
against it). With `pure:true` the game's handlers CANNOT run: Genesis/SMS/GG step
|
|
229
|
+
only the CPU (`pureMode:'cpu-only'`); everywhere else interrupt DELIVERY is
|
|
230
|
+
suppressed for the duration (`'irq-blocked'` — pending lines stay pending, video
|
|
231
|
+
advances harmlessly); the 2600 has no interrupts (`'no-interrupts'`). Non-pure
|
|
232
|
+
results carry a ⚠ caveat whenever frame logic ran.
|
|
233
|
+
|
|
200
234
|
## 5e. Re-inject an edited asset — the round-trip (don't reimplement the compressor)
|
|
201
235
|
|
|
202
236
|
Once you can SEE the decompressed bytes (5c) and you've edited them, put them BACK
|
|
@@ -315,9 +349,14 @@ Once you know WHAT to change, the write loop is a handful of calls — no custom
|
|
|
315
349
|
confirm a patch landed where you meant.
|
|
316
350
|
- **`disasm({target:'references', path, platform, address})`** — find every instruction that
|
|
317
351
|
references a target address, classified `call/jump/branch/read/write/use/ref` (walks the
|
|
318
|
-
vector table too). The fast "who touches this?" for a STATIC image.
|
|
319
|
-
|
|
320
|
-
|
|
352
|
+
vector table too). The fast "who touches this?" for a STATIC image. EVERY banked format
|
|
353
|
+
is scanned PER BANK — NES mappers (refs carry `prgBank`), and SNES multi-bank LoROM,
|
|
354
|
+
GB/GBC MBC, SMS/GG Sega-mapper, MSX megaROM, Atari 2600 F8/F6/F4, Atari 7800 SuperGame,
|
|
355
|
+
and >32KB HuCards (refs carry `romBank`) — so a hit in bank 12 of a 128KB cart shows up,
|
|
356
|
+
not just the first bank. Zero-page direct + indexed operands match, and `#$nn` immediates
|
|
357
|
+
are excluded (values, not addresses). Limitation: direct addressing only —
|
|
358
|
+
indirect/computed jumps aren't detected (use the runtime `watch`/`breakpoint` tools in
|
|
359
|
+
§5/§5d for those).
|
|
321
360
|
- **`cart({op:'extract', path, outputDir})`** — split a ROM into standard parts (NES header/
|
|
322
361
|
prg/chr; SNES copier_header+rom+internal header; Genesis vectors/header/body; GB boot/
|
|
323
362
|
header/body) + a `manifest.json` (mapper, mirroring…). **`cart({op:'wrap'})`** is the inverse:
|
|
@@ -330,8 +369,9 @@ watch the screen react — cheaper than shipping a wrong ROM patch.
|
|
|
330
369
|
|
|
331
370
|
For a STRUCTURAL hack (new logic, not a byte poke), turn the whole ROM into a
|
|
332
371
|
re-buildable project in one call: `disasm({target:'project', path, outputDir})`. It splits
|
|
333
|
-
the ROM into regions (per-
|
|
334
|
-
for
|
|
372
|
+
the ROM into regions (per-bank on EVERY banked format: 16KB banks for NES/GB/SMS-GG/MSX/
|
|
373
|
+
7800-SuperGame, 32KB for SNES LoROM, 4KB for banked 2600, 8KB pages for >32KB HuCards;
|
|
374
|
+
one flat region for Genesis/C64/Lynx/GBA and small carts), disassembles each through the CPU's
|
|
335
375
|
native objdump, then **reassembles + verifies byte-exact** against the original; any line
|
|
336
376
|
that won't reproduce faithfully heals to a `.byte`/`db` of its real bytes, so the emitted
|
|
337
377
|
`.asm` ALWAYS rebuilds (`roundTrip.allByteExact`). `readablePercent` per region tells you
|
|
@@ -343,22 +383,27 @@ rebuild exists — a `rebuild.json` of the precise `build({...})` args. So the l
|
|
|
343
383
|
|
|
344
384
|
**Two rebuild tiers** (the disasm emits each CPU's native-reassembler syntax — ca65 for
|
|
345
385
|
6502/65816, GNU `as` for m68k/arm/z80/gbz80 — which only some `build()` toolchains consume):
|
|
346
|
-
- **One-call `build()` rebuild, byte-identical** — **NES
|
|
347
|
-
|
|
348
|
-
|
|
386
|
+
- **One-call `build()` rebuild, byte-identical** — **NES (NROM *and* banked mappers), C64,
|
|
387
|
+
Atari 7800 (flat *and* SuperGame banked), Lynx, PC Engine (flat *and* banked HuCards)**.
|
|
388
|
+
Feed `rebuild.json` straight to `build`. Banked projects ship a HEADER segment with the
|
|
389
|
+
original header bytes (16 iNES / 128 .a78 / 512 copier), per-bank segment wrappers, and a
|
|
390
|
+
generated multi-bank `.cfg` referenced via `linkerConfigPath` (so the cfg never streams
|
|
391
|
+
through context). (Lynx: `build()` yields the headerless image; prepend the shipped
|
|
392
|
+
`lnx_header.bin` for the full `.lnx`.)
|
|
349
393
|
- **Native-recipe rebuild (`buildCall:null`), byte-identical, steps in `BUILD.md`** — **SMS,
|
|
350
394
|
GG, MSX, GB, GBC, Genesis, GBA, Atari 2600**. Their `build()` toolchains (SDCC/RGBDS/asar/
|
|
351
395
|
dasm/vasm) can't reassemble ca65/GNU-as syntax, so `BUILD.md` gives the proven native
|
|
352
|
-
`as`/`ld`/`objcopy` chain
|
|
353
|
-
|
|
354
|
-
strip a copier header) — `BUILD.md` flags it.
|
|
396
|
+
`as`/`ld`/`objcopy` chain — per-bank on banked carts (Sega-mapper SMS/GG, MSX megaROMs,
|
|
397
|
+
banked 2600 get per-bank wrappers + cfg blobs and a bank-by-bank recipe).
|
|
355
398
|
|
|
356
399
|
**Rebuilding a commercial NES (NROM CHR-ROM) game — `build({inesHeader})`:** the most common
|
|
357
400
|
NES RE rebuild. `build({output:'rom', platform:'nes', inesHeader:{prgBanks, chrBanks, mapper,
|
|
358
401
|
mirroring}, sourcesPaths:{…the PRG…}, binaryIncludePaths:{"chr.bin":…}})` auto-emits the
|
|
359
402
|
16-byte iNES header + CHARS-segment wiring + flat NROM `.cfg` — no hand-derived header bytes.
|
|
360
|
-
`disasm({target:'project'})` puts exactly this call in `rebuild.json
|
|
361
|
-
|
|
403
|
+
`disasm({target:'project'})` puts exactly this call in `rebuild.json` for NROM; banked
|
|
404
|
+
mappers get the per-bank segment + multi-bank `.cfg` form instead (see the one-call tier
|
|
405
|
+
above). (For homebrew C that ships fixed tile art, `linkerConfig:"chr-rom"` is the
|
|
406
|
+
segment-split equivalent.)
|
|
362
407
|
|
|
363
408
|
**Readability caveats** (the bytes are ALWAYS correct; only instruction-vs-`.byte` coverage
|
|
364
409
|
varies): SNES and large Genesis ROMs come back byte-exact but DATA-ONLY (flat whole-ROM
|
|
@@ -398,6 +443,7 @@ For sprite/tile edits (not text), don't hand-roll the tile-format math:
|
|
|
398
443
|
|---|---|
|
|
399
444
|
| Find a value's address | `memory({op:'search'})` → `memory({op:'searchNext'})` (NOT full-RAM diff) |
|
|
400
445
|
| Which bytes did one event touch | `memory({op:'snapshot'})` → `memory({op:'diff'})` (summary) |
|
|
446
|
+
| Which byte does an INPUT drive | `memory({op:'diffRuns', portsA, portsB?})` (A/B divergence, one call) |
|
|
401
447
|
| Is on-screen text a string or a bitmap | `text({op:'learn'})` (reports pre-rendered graphic) |
|
|
402
448
|
| Is a "table" really ASCII/code | `memory({op:'classify'})` |
|
|
403
449
|
| Confirm a patch is in the running ROM | `memory({op:'readCart'})` |
|
|
@@ -406,7 +452,8 @@ For sprite/tile edits (not text), don't hand-roll the tile-format math:
|
|
|
406
452
|
| Which instruction READ a byte | `breakpoint({on:'read', address})` (read-side `breakpoint({on:'write'})`) |
|
|
407
453
|
| Single-step the CPU | `frame({op:'stepInstruction'})` (+ `cpu({op:'read'})` to watch regs) |
|
|
408
454
|
| Set a CPU register | `cpu({op:'setReg', regId, value})` |
|
|
409
|
-
| Decompress a compressed asset | `cpu({op:'decompress'})` / `cpu({op:'call'})` (run the ROM's own codec) |
|
|
455
|
+
| Decompress a compressed asset | `cpu({op:'decompress'})` / `cpu({op:'call', pure:true})` (run the ROM's own codec, interference-free) |
|
|
456
|
+
| Where does this on-screen graphic come from | `watch({on:'copy', start, end})` (all 14 — writer PC per VRAM write; Genesis DMA also via `watch({on:'dma'})`) |
|
|
410
457
|
| Re-inject edited bytes the game accepts | `romPatch({op:'makeStored'})` (verbatim-expand block) → `romPatch({op:'findFree'})` → `romPatch({op:'relocate'})` |
|
|
411
458
|
| Find the pointer that loads an asset | `romPatch({op:'findPointer', romOffset})` |
|
|
412
459
|
| FIND the unknown routine touching X | `watch({on:'range', start,end})` (all hits) / `watch({on:'pc'})` (coverage) |
|
|
@@ -215,7 +215,11 @@ What you can read:
|
|
|
215
215
|
registers.
|
|
216
216
|
- **`disasm({target:'rom'})`** and **`disasm({target:'references'})`** —
|
|
217
217
|
both anchor to the top of the bank (`$F000-$FFFF`) and label the vector
|
|
218
|
-
table (NMI / RESET / IRQ at `$FFFA`).
|
|
218
|
+
table (NMI / RESET / IRQ at `$FFFA`). On banked carts (F8 = 8 KB,
|
|
219
|
+
F6 = 16 KB, F4 = 32 KB) `references` scans EVERY 4 KB bank at `$F000`,
|
|
220
|
+
refs tagged `romBank`; `disasm({target:'project'})` likewise emits one
|
|
221
|
+
region per bank plus per-bank `BANKn` wrappers and a multi-area `.cfg`
|
|
222
|
+
blob for the native ca65/ld65 rebuild.
|
|
219
223
|
|
|
220
224
|
Memory regions for **`memory({op:'read'})`**:
|
|
221
225
|
|
|
@@ -159,3 +159,43 @@ snippet for one approach.
|
|
|
159
159
|
## "First build is slow but later ones are fast"
|
|
160
160
|
|
|
161
161
|
Expected. dasm cold-load is ~500ms. Steady-state builds < 100ms.
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
## Pressing RIGHT also "presses" LEFT (or the player can't move at all)
|
|
165
|
+
|
|
166
|
+
The classic `LDA SWCHA / ASL / BCS … / ASL / BCS …` carry-chain only works if
|
|
167
|
+
NOTHING between the shifts touches A. The moment a branch body does
|
|
168
|
+
`LDA P_X` (a bounds check, a compare), the next `ASL` shifts your *position*
|
|
169
|
+
instead of SWCHA — and since positions are < $80, carry comes back clear and
|
|
170
|
+
the "other direction" fires too. Net effect: moves cancel, the sprite sticks
|
|
171
|
+
to one edge. **Re-load SWCHA and AND a single bit per direction instead:**
|
|
172
|
+
|
|
173
|
+
```asm
|
|
174
|
+
LDA SWCHA
|
|
175
|
+
AND #$80 ; bit7 = P0 Right (active LOW: 0 = pressed)
|
|
176
|
+
BNE .noRight
|
|
177
|
+
...move right (clobber A freely)...
|
|
178
|
+
.noRight:
|
|
179
|
+
LDA SWCHA
|
|
180
|
+
AND #$40 ; bit6 = P0 Left
|
|
181
|
+
BNE .noLeft
|
|
182
|
+
...
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Jump plays its sound but the player never leaves the ground
|
|
186
|
+
|
|
187
|
+
Signed-velocity clamps must check the SIGN first. An unsigned
|
|
188
|
+
`CMP #$F8 / BCS keep` "terminal velocity" clamp also catches every POSITIVE
|
|
189
|
+
(rising) velocity — +6 is less than $F8 unsigned — so the jump impulse is
|
|
190
|
+
instantly slammed to falling and the whole arc resolves inside one frame
|
|
191
|
+
(SFX plays, screen blips, no visible jump). Clamp only while falling:
|
|
192
|
+
|
|
193
|
+
```asm
|
|
194
|
+
LDA P_VY
|
|
195
|
+
BPL .vyok ; rising → terminal clamp doesn't apply
|
|
196
|
+
CMP #$F8
|
|
197
|
+
BCS .vyok ; -8..-1 → fine
|
|
198
|
+
LDA #$F8 ; clamp to -8
|
|
199
|
+
STA P_VY
|
|
200
|
+
.vyok:
|
|
201
|
+
```
|
|
@@ -237,10 +237,23 @@ read:
|
|
|
237
237
|
|
|
238
238
|
```c
|
|
239
239
|
uint8_t pad = ~SWCHA;
|
|
240
|
-
if (pad & JOY_UP) /* P1 up */
|
|
241
|
-
if (pad & JOY_DOWN) /* P1 down */
|
|
242
|
-
if (pad & JOY_LEFT) /* P1 left */
|
|
243
240
|
if (pad & JOY_RIGHT) /* P1 right */
|
|
241
|
+
if (pad & JOY_LEFT) /* P1 left */
|
|
242
|
+
if (pad & JOY_DOWN) /* P1 down */
|
|
243
|
+
if (pad & JOY_UP) /* P1 up */
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**The bit order is the #1 7800 input footgun.** From bit 7 down the P1 nibble
|
|
247
|
+
is **Right ($80), Left ($40), Down ($20), Up ($10)** — same as the 2600. Defining
|
|
248
|
+
`JOY_UP 0x80 … JOY_RIGHT 0x10` (the "reads naturally" order) is exactly
|
|
249
|
+
REVERSED, and the symptom is bizarre enough to misdiagnose: up/down steer
|
|
250
|
+
left/right and vice versa. Always:
|
|
251
|
+
|
|
252
|
+
```c
|
|
253
|
+
#define JOY_RIGHT 0x80
|
|
254
|
+
#define JOY_LEFT 0x40
|
|
255
|
+
#define JOY_DOWN 0x20
|
|
256
|
+
#define JOY_UP 0x10
|
|
244
257
|
```
|
|
245
258
|
|
|
246
259
|
Fire button on `INPT4` at `$0C`, also active low.
|
|
@@ -343,9 +356,17 @@ What you can read:
|
|
|
343
356
|
P / SP / PC) read from prosystem's `sally` globals.
|
|
344
357
|
- **`background({view:'renderState'})`** — the MARIA CTRL bits, DPP,
|
|
345
358
|
CHARBASE, and the current `dlistPtr`.
|
|
346
|
-
- **`disasm({target:'rom'})`**
|
|
347
|
-
|
|
348
|
-
|
|
359
|
+
- **`disasm({target:'rom'})`** — defaults to the top 16 KB
|
|
360
|
+
(`$C000-$FFFF`), where the reset vector lands.
|
|
361
|
+
- **`disasm({target:'references'})`** — scans the WHOLE cart: flat carts
|
|
362
|
+
(≤48 KB) in one pass at their top-of-space org, SuperGame banked carts
|
|
363
|
+
(>48 KB) per 16 KB bank (last bank fixed at `$C000`, others at `$8000`),
|
|
364
|
+
refs tagged `romBank`. A 128-byte `.a78` header is stripped automatically.
|
|
365
|
+
- **`disasm({target:'project'})`** — flat carts rebuild with one flat cc65
|
|
366
|
+
build; SuperGame carts get per-bank regions + NES-style glue (HEADER
|
|
367
|
+
segment with the original 128 header bytes, `BANKn` wrappers, multi-bank
|
|
368
|
+
`.cfg` via `linkerConfigPath`) — a one-call byte-identical
|
|
369
|
+
`build()` rebuild either way.
|
|
349
370
|
|
|
350
371
|
Memory regions for **`memory({op:'read'})`**:
|
|
351
372
|
|
|
@@ -68,7 +68,10 @@ returns nothing.
|
|
|
68
68
|
`disasm({target:'project'})` route through the native binutils z80 `objdump` in
|
|
69
69
|
its `gbz80` machine (WASM, `-m gbz80`) — full CB-prefix coverage plus the
|
|
70
70
|
SM83-specific opcodes (`ld (hl+),a`, `ldh`, `reti`, `ld hl,sp+e8`). One z80-elf
|
|
71
|
-
binutils serves both plain Z80 (SMS/GG/MSX) and the GB CPU.
|
|
71
|
+
binutils serves both plain Z80 (SMS/GG/MSX) and the GB CPU. MBC-banked carts
|
|
72
|
+
(>32 KB) are scanned per 16 KB bank by `references` (bank 0 @ `$0000`, banks
|
|
73
|
+
1+ @ their `$4000` window; refs tagged `romBank`) and split per-bank by
|
|
74
|
+
`disasm({target:'project'})`.
|
|
72
75
|
|
|
73
76
|
## Five silent-failure footguns to know before you start (R26 + R27)
|
|
74
77
|
|
|
@@ -103,6 +106,18 @@ check these first. All five have shipped fixes in the bundled runtime
|
|
|
103
106
|
(volatile-safe by construction) or cast through `volatile uint8_t *`.
|
|
104
107
|
See `gb_runtime/lib/c/SDCC_GOTCHAS.md` § "Writes to VRAM" for detail.
|
|
105
108
|
|
|
109
|
+
3b. **OAM DMA goes FIRST after `wait_vblank()` — before any staging work.**
|
|
110
|
+
The vblank window is ~10 scanlines (~1140 cycles) and SDCC call overhead
|
|
111
|
+
is brutal: even a few dozen `oam_set()` CALLS before the flush push the
|
|
112
|
+
DMA out of vblank into active display, where it tears the sprites on one
|
|
113
|
+
FIXED scanline every frame (the "horizontal line a third of the way down"
|
|
114
|
+
glitch). The robust frame shape is: stage OAM/BG writes into RAM any time
|
|
115
|
+
during the frame, then `wait_vblank(); oam_dma_flush();` as the very first
|
|
116
|
+
thing, then a small bounded batch of BG map writes. One frame of sprite
|
|
117
|
+
latency is imperceptible. Also: statics belong at `dataLoc 0xC200` or
|
|
118
|
+
above (the project recipe sets this) so they can't collide with
|
|
119
|
+
`shadow_oam` at $C100.
|
|
120
|
+
|
|
106
121
|
4. **OAM DMA must run from HRAM.** During the ~160 µs OAM DMA window
|
|
107
122
|
the CPU can ONLY fetch from HRAM ($FF80-$FFFE). The bundled
|
|
108
123
|
`oam_dma_copy()` now installs a 9-byte stub at $FF80 and CALLs it;
|
|
@@ -166,6 +166,48 @@ at $0150. If you see code in that window, either:
|
|
|
166
166
|
works; don't override it for GB/GBC unless you know what you're
|
|
167
167
|
doing.
|
|
168
168
|
|
|
169
|
+
## "BG map updates randomly don't stick" / a tile updates one frame late forever
|
|
170
|
+
|
|
171
|
+
The core (like real hardware mid-frame) DROPS writes to VRAM ($8000-$9FFF)
|
|
172
|
+
that land outside vblank while the LCD is on — silently. A game loop that
|
|
173
|
+
pokes the BG map "whenever the state changes" will have SOME of those pokes
|
|
174
|
+
land mid-frame and vanish: stale cells, a piece that visually lags the
|
|
175
|
+
logical grid, glitches that move around as code timing shifts.
|
|
176
|
+
|
|
177
|
+
The robust pattern (used by the bundled puzzle scaffolds):
|
|
178
|
+
|
|
179
|
+
1. **COLLECT** — during the frame, don't touch VRAM. Append (addr, tile)
|
|
180
|
+
pairs to a small RAM queue whenever game state changes a cell.
|
|
181
|
+
2. **FLUSH** — immediately after `wait_vblank()` (right after the OAM DMA),
|
|
182
|
+
drain the queue with pure writes. No scanning, no logic — vblank is only
|
|
183
|
+
~1140 cycles, so the flush must be writes only and bounded.
|
|
184
|
+
3. **Scrub** — repaint one or two rows per frame round-robin as insurance,
|
|
185
|
+
so any cell that ever got dropped self-heals within a second.
|
|
186
|
+
|
|
187
|
+
If you must write outside that structure, turn the LCD off first (only
|
|
188
|
+
acceptable during init/load screens — mid-game it flashes white).
|
|
189
|
+
|
|
190
|
+
## "BG map updates randomly don't stick" / a tile updates one frame late forever
|
|
191
|
+
|
|
192
|
+
The core (like real hardware mid-frame) DROPS writes to VRAM ($8000-$9FFF)
|
|
193
|
+
that land outside vblank while the LCD is on — silently. A game loop that
|
|
194
|
+
pokes the BG map "whenever the state changes" will have SOME of those pokes
|
|
195
|
+
land mid-frame and vanish: stale cells, a piece that visually lags the
|
|
196
|
+
logical grid, glitches that move around as code timing shifts.
|
|
197
|
+
|
|
198
|
+
The robust pattern (used by the bundled puzzle scaffolds):
|
|
199
|
+
|
|
200
|
+
1. **COLLECT** — during the frame, don't touch VRAM. Append (addr, tile)
|
|
201
|
+
pairs to a small RAM queue whenever game state changes a cell.
|
|
202
|
+
2. **FLUSH** — immediately after `wait_vblank()` (right after the OAM DMA),
|
|
203
|
+
drain the queue with pure writes. No scanning, no logic — vblank is only
|
|
204
|
+
~1140 cycles, so the flush must be writes only and bounded.
|
|
205
|
+
3. **Scrub** — repaint one or two rows per frame round-robin as insurance,
|
|
206
|
+
so any cell that ever got dropped self-heals within a second.
|
|
207
|
+
|
|
208
|
+
If you must write outside that structure, turn the LCD off first (only
|
|
209
|
+
acceptable during init/load screens — mid-game it flashes white).
|
|
210
|
+
|
|
169
211
|
## Debug recipes
|
|
170
212
|
|
|
171
213
|
A few high-leverage tools you might not know exist:
|
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
//
|
|
8
8
|
// WHY: most libretro GB cores (gambatte) refuse to load a ROM whose
|
|
9
9
|
// Nintendo logo at $0104-$0133 doesn't match the canonical bytes or
|
|
10
|
-
// whose header checksum at $014D doesn't validate.
|
|
11
|
-
//
|
|
12
|
-
// pipeline
|
|
13
|
-
//
|
|
10
|
+
// whose header checksum at $014D doesn't validate.
|
|
11
|
+
//
|
|
12
|
+
// NOTE: romdev's own build pipeline DOES auto-patch the header now (it
|
|
13
|
+
// runs a bundled rgbfix after every gb/gbc link — see the
|
|
14
|
+
// "rgbfix (auto header fix)" line in build logs), so you only need this
|
|
15
|
+
// script when rebuilding the project OUTSIDE romdev with stock SDCC and
|
|
16
|
+
// no RGBDS installed. It's what keeps the scaffold self-contained.
|
|
14
17
|
//
|
|
15
18
|
// The bundled gb_crt0.s reserves $0100-$014F for the header window,
|
|
16
19
|
// so the bytes patched in here land on actual cartridge-header
|
|
@@ -31,6 +31,18 @@ the same wall.
|
|
|
31
31
|
cast through `volatile uint8_t *`. See `lib/c/SDCC_GOTCHAS.md`
|
|
32
32
|
§ "Writes to VRAM".
|
|
33
33
|
|
|
34
|
+
3b. **OAM DMA goes FIRST after `wait_vblank()` — before any staging work.**
|
|
35
|
+
The vblank window is ~10 scanlines (~1140 cycles) and SDCC call overhead
|
|
36
|
+
is brutal: even a few dozen `oam_set()` CALLS before the flush push the
|
|
37
|
+
DMA out of vblank into active display, where it tears the sprites on one
|
|
38
|
+
FIXED scanline every frame (the "horizontal line a third of the way down"
|
|
39
|
+
glitch). The robust frame shape is: stage OAM/BG writes into RAM any time
|
|
40
|
+
during the frame, then `wait_vblank(); oam_dma_flush();` as the very first
|
|
41
|
+
thing, then a small bounded batch of BG map writes. One frame of sprite
|
|
42
|
+
latency is imperceptible. Also: statics belong at `dataLoc 0xC200` or
|
|
43
|
+
above (the project recipe sets this) so they can't collide with
|
|
44
|
+
`shadow_oam` at $C100.
|
|
45
|
+
|
|
34
46
|
4. **OAM DMA must run from HRAM.** During the ~160 µs OAM DMA window
|
|
35
47
|
the CPU can ONLY fetch from HRAM ($FF80-$FFFE). The bundled
|
|
36
48
|
`oam_dma_copy()` installs a 9-byte stub at $FF80 and CALLs it; the
|
|
@@ -120,6 +120,27 @@ in DMG mode. To switch a DMG ROM to CGB:
|
|
|
120
120
|
2. Run `romPatch({op:'gbHeader', path:"out.gbc"})` — also fixes the global
|
|
121
121
|
checksum that the boot ROM checks
|
|
122
122
|
|
|
123
|
+
## "BG map updates randomly don't stick" / a tile updates one frame late forever
|
|
124
|
+
|
|
125
|
+
The core (like real hardware mid-frame) DROPS writes to VRAM ($8000-$9FFF)
|
|
126
|
+
that land outside vblank while the LCD is on — silently. A game loop that
|
|
127
|
+
pokes the BG map "whenever the state changes" will have SOME of those pokes
|
|
128
|
+
land mid-frame and vanish: stale cells, a piece that visually lags the
|
|
129
|
+
logical grid, glitches that move around as code timing shifts.
|
|
130
|
+
|
|
131
|
+
The robust pattern (used by the bundled puzzle scaffolds):
|
|
132
|
+
|
|
133
|
+
1. **COLLECT** — during the frame, don't touch VRAM. Append (addr, tile)
|
|
134
|
+
pairs to a small RAM queue whenever game state changes a cell.
|
|
135
|
+
2. **FLUSH** — immediately after `wait_vblank()` (right after the OAM DMA),
|
|
136
|
+
drain the queue with pure writes. No scanning, no logic — vblank is only
|
|
137
|
+
~1140 cycles, so the flush must be writes only and bounded.
|
|
138
|
+
3. **Scrub** — repaint one or two rows per frame round-robin as insurance,
|
|
139
|
+
so any cell that ever got dropped self-heals within a second.
|
|
140
|
+
|
|
141
|
+
If you must write outside that structure, turn the LCD off first (only
|
|
142
|
+
acceptable during init/load screens — mid-game it flashes white).
|
|
143
|
+
|
|
123
144
|
## "Sound is the same as DMG"
|
|
124
145
|
|
|
125
146
|
That's correct — CGB has the **identical** 4-channel APU as DMG. The
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* AUTO-GENERATED by gen_font.py — 5x7 font, GB 2bpp, ink=value 3. */
|
|
2
|
+
#ifndef FONT_H
|
|
3
|
+
#define FONT_H
|
|
4
|
+
#define FONT_GLYPHS 36
|
|
5
|
+
static const uint8_t font_data[576] = {
|
|
6
|
+
0x38, 0x38, 0x44, 0x44, 0x4C, 0x4C, 0x54, 0x54, 0x64, 0x64, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* 0 */
|
|
7
|
+
0x10, 0x10, 0x30, 0x30, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x38, 0x38, 0x00, 0x00, /* 1 */
|
|
8
|
+
0x38, 0x38, 0x44, 0x44, 0x04, 0x04, 0x08, 0x08, 0x10, 0x10, 0x20, 0x20, 0x7C, 0x7C, 0x00, 0x00, /* 2 */
|
|
9
|
+
0x7C, 0x7C, 0x08, 0x08, 0x10, 0x10, 0x08, 0x08, 0x04, 0x04, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* 3 */
|
|
10
|
+
0x08, 0x08, 0x18, 0x18, 0x28, 0x28, 0x48, 0x48, 0x7C, 0x7C, 0x08, 0x08, 0x08, 0x08, 0x00, 0x00, /* 4 */
|
|
11
|
+
0x7C, 0x7C, 0x40, 0x40, 0x78, 0x78, 0x04, 0x04, 0x04, 0x04, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* 5 */
|
|
12
|
+
0x18, 0x18, 0x20, 0x20, 0x40, 0x40, 0x78, 0x78, 0x44, 0x44, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* 6 */
|
|
13
|
+
0x7C, 0x7C, 0x04, 0x04, 0x08, 0x08, 0x10, 0x10, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, /* 7 */
|
|
14
|
+
0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* 8 */
|
|
15
|
+
0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x3C, 0x3C, 0x04, 0x04, 0x08, 0x08, 0x30, 0x30, 0x00, 0x00, /* 9 */
|
|
16
|
+
0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x7C, 0x7C, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x00, 0x00, /* A */
|
|
17
|
+
0x78, 0x78, 0x44, 0x44, 0x44, 0x44, 0x78, 0x78, 0x44, 0x44, 0x44, 0x44, 0x78, 0x78, 0x00, 0x00, /* B */
|
|
18
|
+
0x38, 0x38, 0x44, 0x44, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* C */
|
|
19
|
+
0x70, 0x70, 0x48, 0x48, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x48, 0x48, 0x70, 0x70, 0x00, 0x00, /* D */
|
|
20
|
+
0x7C, 0x7C, 0x40, 0x40, 0x40, 0x40, 0x78, 0x78, 0x40, 0x40, 0x40, 0x40, 0x7C, 0x7C, 0x00, 0x00, /* E */
|
|
21
|
+
0x7C, 0x7C, 0x40, 0x40, 0x40, 0x40, 0x78, 0x78, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00, 0x00, /* F */
|
|
22
|
+
0x38, 0x38, 0x44, 0x44, 0x40, 0x40, 0x5C, 0x5C, 0x44, 0x44, 0x44, 0x44, 0x3C, 0x3C, 0x00, 0x00, /* G */
|
|
23
|
+
0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x7C, 0x7C, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x00, 0x00, /* H */
|
|
24
|
+
0x38, 0x38, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x38, 0x38, 0x00, 0x00, /* I */
|
|
25
|
+
0x1C, 0x1C, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x48, 0x48, 0x30, 0x30, 0x00, 0x00, /* J */
|
|
26
|
+
0x44, 0x44, 0x48, 0x48, 0x50, 0x50, 0x60, 0x60, 0x50, 0x50, 0x48, 0x48, 0x44, 0x44, 0x00, 0x00, /* K */
|
|
27
|
+
0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x7C, 0x7C, 0x00, 0x00, /* L */
|
|
28
|
+
0x44, 0x44, 0x6C, 0x6C, 0x54, 0x54, 0x54, 0x54, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x00, 0x00, /* M */
|
|
29
|
+
0x44, 0x44, 0x44, 0x44, 0x64, 0x64, 0x54, 0x54, 0x4C, 0x4C, 0x44, 0x44, 0x44, 0x44, 0x00, 0x00, /* N */
|
|
30
|
+
0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* O */
|
|
31
|
+
0x78, 0x78, 0x44, 0x44, 0x44, 0x44, 0x78, 0x78, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00, 0x00, /* P */
|
|
32
|
+
0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x54, 0x54, 0x48, 0x48, 0x34, 0x34, 0x00, 0x00, /* Q */
|
|
33
|
+
0x78, 0x78, 0x44, 0x44, 0x44, 0x44, 0x78, 0x78, 0x50, 0x50, 0x48, 0x48, 0x44, 0x44, 0x00, 0x00, /* R */
|
|
34
|
+
0x3C, 0x3C, 0x40, 0x40, 0x40, 0x40, 0x38, 0x38, 0x04, 0x04, 0x04, 0x04, 0x78, 0x78, 0x00, 0x00, /* S */
|
|
35
|
+
0x7C, 0x7C, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x00, 0x00, /* T */
|
|
36
|
+
0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* U */
|
|
37
|
+
0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x28, 0x28, 0x10, 0x10, 0x00, 0x00, /* V */
|
|
38
|
+
0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x54, 0x54, 0x54, 0x54, 0x6C, 0x6C, 0x44, 0x44, 0x00, 0x00, /* W */
|
|
39
|
+
0x44, 0x44, 0x44, 0x44, 0x28, 0x28, 0x10, 0x10, 0x28, 0x28, 0x44, 0x44, 0x44, 0x44, 0x00, 0x00, /* X */
|
|
40
|
+
0x44, 0x44, 0x44, 0x44, 0x28, 0x28, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x00, 0x00, /* Y */
|
|
41
|
+
0x7C, 0x7C, 0x04, 0x04, 0x08, 0x08, 0x10, 0x10, 0x20, 0x20, 0x40, 0x40, 0x7C, 0x7C, 0x00, 0x00, /* Z */
|
|
42
|
+
};
|
|
43
|
+
#endif
|
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
//
|
|
8
8
|
// WHY: most libretro GB cores (gambatte) refuse to load a ROM whose
|
|
9
9
|
// Nintendo logo at $0104-$0133 doesn't match the canonical bytes or
|
|
10
|
-
// whose header checksum at $014D doesn't validate.
|
|
11
|
-
//
|
|
12
|
-
// pipeline
|
|
13
|
-
//
|
|
10
|
+
// whose header checksum at $014D doesn't validate.
|
|
11
|
+
//
|
|
12
|
+
// NOTE: romdev's own build pipeline DOES auto-patch the header now (it
|
|
13
|
+
// runs a bundled rgbfix after every gb/gbc link — see the
|
|
14
|
+
// "rgbfix (auto header fix)" line in build logs), so you only need this
|
|
15
|
+
// script when rebuilding the project OUTSIDE romdev with stock SDCC and
|
|
16
|
+
// no RGBDS installed. It's what keeps the scaffold self-contained.
|
|
14
17
|
//
|
|
15
18
|
// The bundled gb_crt0.s reserves $0100-$014F for the header window,
|
|
16
19
|
// so the bytes patched in here land on actual cartridge-header
|
|
@@ -50,9 +50,13 @@ memory({op:'read', region:'system_ram', offset: sym.ramOffset, length:2})
|
|
|
50
50
|
- **`static` file-local globals resolve too** (SGDK emits per-symbol sections).
|
|
51
51
|
A non-`static` global that's never *read* can be optimised away at -O2 — mark
|
|
52
52
|
game-state vars you inspect `volatile` (you want that anyway).
|
|
53
|
-
- **
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
- **WRAM (`system_ram`) is normalized to CPU byte order** — offset X is the
|
|
54
|
+
byte the 68k sees at $FF0000+X, words read big-endian as expected, and
|
|
55
|
+
offsets line up with disassembly addresses and cheat-DB maps. (gpgx stores
|
|
56
|
+
work RAM host-LE word-swapped internally; the host un-swaps it. Before
|
|
57
|
+
0.28.0 the raw swapped layout leaked through — value-search/diff loops were
|
|
58
|
+
self-consistent, but any offset cross-referenced against a `move.b $FFxxxx`
|
|
59
|
+
in a disassembly was off-by-XOR-1.)
|
|
56
60
|
- **PC → which function?** `symbols({op:'addr', pc, symbolsText: b.mapText})` maps
|
|
57
61
|
a live `cpu({op:'read'}).pc` to the enclosing C function.
|
|
58
62
|
|
|
@@ -427,10 +431,40 @@ Video is deeply readable; the FM audio chip is only partially exposed:
|
|
|
427
431
|
`getPsgState` decodes the SN76489 (3 tone + 1 noise channels).
|
|
428
432
|
- **Memory regions:** `memory({op:'read'})` exposes CRAM, VSRAM, VDP_REGS,
|
|
429
433
|
Z80_RAM (the sound CPU's RAM), M68K work RAM, YM2612, PSG, and VRAM.
|
|
430
|
-
Remember the gpgx byte-swap quirk
|
|
434
|
+
Remember the gpgx byte-swap quirk for VRAM: it reads host-LE
|
|
431
435
|
word-byte-swapped (a 16-bit value's two bytes are swapped at the offset)
|
|
432
|
-
—
|
|
433
|
-
|
|
436
|
+
— use tiles({op:'pixels'}) to decode in render order. M68K work RAM
|
|
437
|
+
(`system_ram`) is NOT affected: it's normalized to CPU byte order (see
|
|
438
|
+
"Reading your C globals headlessly").
|
|
439
|
+
|
|
440
|
+
## Break-instant truth: registersAtHit + pure calls (0.28.0)
|
|
441
|
+
|
|
442
|
+
gpgx schedules its CPUs per scanline, so a `breakpoint` hit mid-frame used to
|
|
443
|
+
leave the LIVE register file hundreds of instructions past the hit by the time
|
|
444
|
+
you could read it — chasing pointer registers read that way burned a real
|
|
445
|
+
session for ~2h. Fixed two ways:
|
|
446
|
+
|
|
447
|
+
- **`registersAtHit`** — `breakpoint({on:'pc'|'write'|'read'})` hits now carry
|
|
448
|
+
the FULL register file (d0-d7/a0-a7/pc/sr/sp) frozen by the core at the hit
|
|
449
|
+
instant. Use it, never a follow-up `cpu({op:'read'})`. The reported `pc` for
|
|
450
|
+
write/read hits is the EXECUTING instruction's first byte (pre-0.28.0 it was
|
|
451
|
+
the post-prefetch PC — one instruction late). On a pc-break the 68k also
|
|
452
|
+
stays FROZEN for the rest of the frame, so even live reads agree.
|
|
453
|
+
- **`cpu({op:'call', pure:true})`** — steps ONLY the 68k: no VDP lines, no
|
|
454
|
+
Z80, no interrupts raised. Without it, a driven routine that spans frames
|
|
455
|
+
runs the game's own VBlank logic concurrently — which can stomp the output
|
|
456
|
+
buffer you're capturing (a real session diffed a CORRECT codec
|
|
457
|
+
reimplementation against that poisoned "ground truth" for hours). Prefer
|
|
458
|
+
`pure:true` for every decompressor/codec call; non-pure results carry a ⚠
|
|
459
|
+
caveat when frame logic ran. (SMS/GG get the same via the shared core; the
|
|
460
|
+
OTHER platforms get the same guarantee via interrupt blocking —
|
|
461
|
+
`pureMode:'irq-blocked'` — so the technique transfers everywhere.)
|
|
462
|
+
- **`watch({on:'copy'})`** — the CPU-port complement of `watch({on:'dma'})`:
|
|
463
|
+
logs every data-port write landing in a VRAM window with the executing
|
|
464
|
+
instruction's PC. Use `dma` when the upload is DMA'd (most Genesis
|
|
465
|
+
graphics), `copy` when the game pokes the data port directly (the
|
|
466
|
+
"video_ram writes don't reach the renderer" class of confusion — `copy`
|
|
467
|
+
shows you who's writing and where).
|
|
434
468
|
|
|
435
469
|
## ROM layout
|
|
436
470
|
|
|
@@ -32,6 +32,42 @@ void sfx_noise(u8 length_frames) {
|
|
|
32
32
|
sfx_remaining[3] = length_frames;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/* ── background music: a 16-step melody loop on PSG channel 2 ───────
|
|
36
|
+
* Ticked from sfx_update(), so every scaffold that already calls
|
|
37
|
+
* sfx_init() + sfx_update() gets continuous music for free ("no sound"
|
|
38
|
+
* was the #1 playtest complaint — a lone 6-frame blip on a rare event
|
|
39
|
+
* reads as silence). sfx_music(0) turns it off. SFX own channels 0-1 +
|
|
40
|
+
* noise, so effects always cut through. */
|
|
41
|
+
static const u16 music_hz[16] = {
|
|
42
|
+
262, 330, 392, 523, 392, 330, 262, 0, /* C4 E4 G4 C5 G4 E4 C4 - */
|
|
43
|
+
220, 262, 330, 440, 330, 262, 220, 0, /* A3 C4 E4 A4 E4 C4 A3 - */
|
|
44
|
+
};
|
|
45
|
+
static u8 music_enabled = 1;
|
|
46
|
+
static u8 music_step, music_timer;
|
|
47
|
+
|
|
48
|
+
void sfx_music(u8 on) {
|
|
49
|
+
music_enabled = on;
|
|
50
|
+
music_step = 0;
|
|
51
|
+
music_timer = 0;
|
|
52
|
+
if (!on) PSG_setEnvelope(2, PSG_ENVELOPE_MIN);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static void music_tick(void) {
|
|
56
|
+
if (!music_enabled) return;
|
|
57
|
+
if (music_timer == 0) {
|
|
58
|
+
u16 hz = music_hz[music_step & 15];
|
|
59
|
+
if (hz) {
|
|
60
|
+
PSG_setFrequency(2, hz);
|
|
61
|
+
PSG_setEnvelope(2, 5); /* moderate, under the SFX */
|
|
62
|
+
} else {
|
|
63
|
+
PSG_setEnvelope(2, PSG_ENVELOPE_MIN);
|
|
64
|
+
}
|
|
65
|
+
music_step++;
|
|
66
|
+
}
|
|
67
|
+
music_timer++;
|
|
68
|
+
if (music_timer >= 9) music_timer = 0; /* ~6.6 notes/sec */
|
|
69
|
+
}
|
|
70
|
+
|
|
35
71
|
void sfx_update(void) {
|
|
36
72
|
for (u8 i = 0; i < 4; i++) {
|
|
37
73
|
if (sfx_remaining[i] > 0) {
|
|
@@ -41,6 +77,7 @@ void sfx_update(void) {
|
|
|
41
77
|
}
|
|
42
78
|
}
|
|
43
79
|
}
|
|
80
|
+
music_tick();
|
|
44
81
|
}
|
|
45
82
|
|
|
46
83
|
void sfx_off(void) {
|
|
@@ -42,6 +42,7 @@ void sfx_noise(u8 length_frames);
|
|
|
42
42
|
* decrement the auto-silence countdown. Without this, notes never
|
|
43
43
|
* stop ringing. */
|
|
44
44
|
void sfx_update(void);
|
|
45
|
+
void sfx_music(u8 on); /* background melody loop on PSG ch2 — ON by default; 0 = off */
|
|
45
46
|
|
|
46
47
|
/* Power down all PSG channels immediately. */
|
|
47
48
|
void sfx_off(void);
|
|
@@ -92,20 +92,16 @@ the P2 read + force `p2 = 0` so the AI fallback always engages.
|
|
|
92
92
|
The bundled GG `sports.c` already does this — copy that pattern when
|
|
93
93
|
porting other SMS multiplayer code.
|
|
94
94
|
|
|
95
|
-
## "
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
The bundled scaffolds build without a header — sufficient for the
|
|
111
|
-
emulator-driven workflow. Add one before shipping to a cartridge.
|
|
95
|
+
## "TMR SEGA" header / ROM boots in the wrong video mode
|
|
96
|
+
|
|
97
|
+
The build pipeline now stamps the 16-byte header at `$7FF0` automatically
|
|
98
|
+
("TMR SEGA" + checksum + the region/size byte at `$7FFF`) and pads every
|
|
99
|
+
image to 32 KB — you never hand-write it for romdev builds.
|
|
100
|
+
|
|
101
|
+
The byte that matters is `$7FFF`: **high nibble = region, low nibble = ROM
|
|
102
|
+
size**. romdev writes `$7C` (GG international, 32 KB) on `.gg` builds.
|
|
103
|
+
If you patch a ROM by hand and leave an SMS region nibble there (`$4C` =
|
|
104
|
+
SMS export), gpgx boots the `.gg` file in **SMS compatibility mode** —
|
|
105
|
+
256×192 timing, SMS palette depth — and everything renders dark and
|
|
106
|
+
mis-cropped even though your code is fine. Check `$7FFF` first when a GG
|
|
107
|
+
ROM suddenly looks like an SMS ROM.
|