diff options
| author | gbc dev <gbc@localhost> | 2026-07-15 00:37:04 +0200 |
|---|---|---|
| committer | gbc dev <gbc@localhost> | 2026-07-15 00:37:04 +0200 |
| commit | 12e67588053160d2f9599a09ef932f9b37b1b873 (patch) | |
| tree | f1db2e3e8fb585bc76610995d2437aeb6ee9e51a | |
| parent | control: socket-based debug channel + gbctl CLI driver (diff) | |
| download | sl0pboy-12e67588053160d2f9599a09ef932f9b37b1b873.tar.gz sl0pboy-12e67588053160d2f9599a09ef932f9b37b1b873.tar.xz sl0pboy-12e67588053160d2f9599a09ef932f9b37b1b873.zip | |
apu: register-level emulation of 0xFF10-0xFF3F (fixes music fades)
The APU was a black hole: reads returned 0xFF, writes were dropped. That hangs
any game that fades music out by decrementing the NR50/rAUDVOL volume register
until it reads 0 (Pokemon's StopMusic spin-wait) - the write never stuck, the
read never hit 0, infinite loop. Store the 48 sound registers and return them
with the standard hardware read-back OR-masks (NR50 mask 0x00 = exact readback).
No synthesis, just correct register behavior.
| -rw-r--r-- | src/gb.c | 16 | ||||
| -rw-r--r-- | src/gb.h | 5 | ||||
| -rw-r--r-- | tools/gbshot.py | 75 | ||||
| -rw-r--r-- | tools/warp.py | 29 |
4 files changed, 123 insertions, 2 deletions
@@ -64,7 +64,19 @@ u8 gb_read(GB *gb, u16 addr) { if (addr == 0xFF02) return gb->sc | 0x7E; if (addr >= 0xFF04 && addr <= 0xFF07) return timer_read(gb, addr); if (addr == 0xFF0F) return gb->iff | 0xE0; - if (addr >= 0xFF10 && addr <= 0xFF3F) return 0xFF; // APU (stub) + if (addr >= 0xFF10 && addr <= 0xFF3F) { + // Register-level APU: return stored value OR'd with the bits that read + // back as 1 on real hardware (write-only / unused bits). NR50 (0xFF24) + // has mask 0x00, so it reads back exactly what was written, which is + // what the game's volume fade-out relies on. + static const u8 apu_or[0x30] = { + 0x80,0x3F,0x00,0xFF,0xBF, 0xFF, 0x3F,0x00,0xFF,0xBF, + 0x7F,0xFF,0x9F,0xFF,0xBF, 0xFF, 0xFF,0x00,0x00,0xBF, + 0x00,0x00,0x70, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 /* FF30-FF3F wave RAM: exact */ + }; + return gb->apu[addr - 0xFF10] | apu_or[addr - 0xFF10]; + } if (addr >= 0xFF40 && addr <= 0xFF4B) { if (addr == 0xFF46) return gb->dma; return ppu_read_reg(gb, addr); @@ -114,7 +126,7 @@ void gb_write(GB *gb, u16 addr, u8 val) { } if (addr >= 0xFF04 && addr <= 0xFF07) { timer_write(gb, addr, val); return; } if (addr == 0xFF0F) { gb->iff = val & 0x1F; return; } - if (addr >= 0xFF10 && addr <= 0xFF3F) return; // APU stub + if (addr >= 0xFF10 && addr <= 0xFF3F) { gb->apu[addr - 0xFF10] = val; return; } if (addr >= 0xFF40 && addr <= 0xFF4B) { if (addr == 0xFF46) { oam_dma(gb, val); return; } ppu_write_reg(gb, addr, val); @@ -94,6 +94,11 @@ struct GB { // serial u8 sb, sc; + // APU registers 0xFF10-0xFF3F. We don't synthesize sound, but games read + // these back (e.g. the music fade-out decrements NR50/rAUDVOL until it + // reads 0), so they must behave as real storage, not a 0xFF black hole. + u8 apu[0x30]; + // DMA u8 dma; diff --git a/tools/gbshot.py b/tools/gbshot.py new file mode 100644 index 0000000..95a45f1 --- /dev/null +++ b/tools/gbshot.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""gbshot.py <sock> <out.bmp> — reconstruct the current GB screen from VRAM +(BG + window + sprites, both CGB banks) and save a 24-bit BMP. Grayscale from +the 2bpp tile values (enough to read menus/sprites). Uses the gbc control socket.""" +import socket, sys, struct + +def main(): + sock, out = sys.argv[1], sys.argv[2] + c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); c.settimeout(6); c.connect(sock); c.recv(200) + def cmd(l): + c.sendall((l+"\n").encode()); b=b"" + while b"\n" not in b: b+=c.recv(65536) + return b.split(b"\n",1)[0].decode() + def rd(a,n): + out=[] + while n>0: + k=min(4096,n); h=cmd(f"read 0x{a:04X} {k}").split()[-1] + out+=[int(h[i*2:i*2+2],16) for i in range(k)]; a+=k; n-=k + return out + reg=lambda a: rd(a,1)[0] + LCDC=reg(0xFF40);SCY=reg(0xFF42);SCX=reg(0xFF43);WY=reg(0xFF4A);WX=reg(0xFF4B) + cmd("write 0xFF4F 00"); v0=rd(0x8000,0x2000) + cmd("write 0xFF4F 01"); v1=rd(0x8000,0x2000) + cmd("write 0xFF4F 00"); oam=rd(0xFE00,0xA0) + vb=lambda bank,addr:(v0 if bank==0 else v1)[addr-0x8000] + bgmap=0x9C00 if (LCDC>>3)&1 else 0x9800 + wmap =0x9C00 if (LCDC>>6)&1 else 0x9800 + uns=(LCDC>>4)&1 + ta=lambda idx:0x8000+idx*16 if uns else 0x9000+((idx^0x80)-0x80)*16 + shade=[255,170,85,0] + fb=[[255]*160 for _ in range(144)] + def fetch(mapbase,mx,my): + ent=mapbase+(my//8)*32+(mx//8) + idx=vb(0,ent);attr=vb(1,ent) + bank=(attr>>3)&1;xf=(attr>>5)&1;yf=(attr>>6)&1 + a=ta(idx); ry=(7-(my%8)) if yf else (my%8); rx=(7-(mx%8)) if xf else (mx%8) + b1=vb(bank,a+ry*2);b2=vb(bank,a+ry*2+1) + return shade[((b1>>(7-rx))&1)|(((b2>>(7-rx))&1)<<1)] + winon=(LCDC>>5)&1 + for sy in range(144): + for sx in range(160): + if winon and sy>=WY and sx>=WX-7: + fb[sy][sx]=fetch(wmap,sx-(WX-7),sy-WY) + elif LCDC&1: + fb[sy][sx]=fetch(bgmap,(sx+SCX)&0xFF,(sy+SCY)&0xFF) + h=16 if (LCDC>>2)&1 else 8 + if (LCDC>>1)&1: + for i in range(40): + y=oam[i*4]-16;x=oam[i*4+1]-8;t=oam[i*4+2];a=oam[i*4+3] + bank=(a>>3)&1;xf=(a>>5)&1;yf=(a>>6)&1 + if h==16:t&=0xFE + for part in range(h//8): + tp=t+(part if not yf else (h//8-1-part)) + base=0x8000+tp*16 + for ty in range(8): + ry=7-ty if yf else ty + b1=vb(bank,base+ry*2);b2=vb(bank,base+ry*2+1) + yy=y+part*8+ty + if 0<=yy<144: + for tx in range(8): + rx=7-tx if xf else tx + val=((b1>>(7-rx))&1)|(((b2>>(7-rx))&1)<<1) + if val==0: continue + xx=x+tx + if 0<=xx<160: fb[yy][xx]=shade[val] + W,H=160,144;rowpad=(-W*3)%4;data=bytearray() + for ry in range(H-1,-1,-1): + for x in range(W): + g=fb[ry][x];data+=bytes((g,g,g)) + data+=b"\x00"*rowpad + hdr=b"BM"+struct.pack("<IHHI",54+len(data),0,0,54)+struct.pack("<IiiHHIIiiII",40,W,H,1,24,0,len(data),2835,2835,0,0) + open(out,"wb").write(hdr+data) + print(f"saved {out} (map=0x{reg(0xD35D):02X} pos {reg(0xD360)},{reg(0xD361)})") + +if __name__=="__main__": main() diff --git a/tools/warp.py b/tools/warp.py new file mode 100644 index 0000000..71e4a1c --- /dev/null +++ b/tools/warp.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""warp.py <sock> <mapid_hex> — Phase-A RAM-poke fly-warp to any map id. +Sets wDestinationMap + BIT_FLY_WARP, then releases the music-fade spin-wait by +zeroing wAudioFadeOutControl until the map actually changes.""" +import socket, sys, time + +WDEST=0xD719; WFLAGS=0xD731; WAUDIO=0xCFC6; WCURMAP=0xD35D + +def main(): + sock=sys.argv[1]; dest=int(sys.argv[2],16) + c=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM);c.settimeout(4);c.connect(sock);c.recv(200) + def cmd(l): + c.sendall((l+"\n").encode()); b=b"" + while b"\n" not in b: b+=c.recv(65536) + return b.split(b"\n",1)[0].decode() + rd=lambda a:int(cmd(f"read 0x{a:04X} 1").split()[-1],16) + before=rd(WCURMAP) + cmd(f"write 0x{WDEST:04X} {dest:02X}") + cmd(f"write 0x{WFLAGS:04X} {rd(WFLAGS)|0x08:02X}") + ok=False + for _ in range(25): + cmd(f"write 0x{WAUDIO:04X} 00") # release spin-wait if the game entered it + time.sleep(0.25) + if rd(WCURMAP)!=before: ok=True; break + time.sleep(1.2) + m=rd(WCURMAP); y=rd(0xD360); x=rd(0xD361) + print(f"warp -> map 0x{m:02X} pos ({y},{x}) {'OK' if ok and m==dest else 'CHECK'}") + +if __name__=="__main__": main() |
