From 0709af27e23805ed8c505a9fcaad243e32efd414 Mon Sep 17 00:00:00 2001 From: Fiskbit <2811896+Fiskbit@users.noreply.github.com> Date: Sat, 23 May 2026 06:09:06 -0700 Subject: [PATCH 1/4] NES: Reimplement PAL forced OAM refresh according to new research. On PAL, vblank is so long that OAM may decay during it. The PPU works around this by incrementing the OAM1 address every odd dot (delayed by 1, so happening on the following even dot) during scanlines 265-310. The PPU reads from OAM1 on every dot during vblank/fblank, so by repeatedly visiting all the addresses, it reads every row and keeps OAM fresh. When implementing this, I found that OAM was corrupting reliably. This is because the forced refresh normally leaves the OAM1 address set to $8C when rendering begins, which triggers the 2C02E+ OAM corruption bug. However, all 3 types of OAM corruption are actually fixed on PAL, so this change also restricts OAM corruption to the NTSC console region. All tests continue to pass with this change. blargg's oam_read test fails as it should, but not as severely as it does on real hardware. I'm not clear on why, but certainly part of it is that this change allows OAM1 to be incremented twice on a single dot. Adding hacky support for limiting this to just 1 increment makes it fail a little more severely, but still not as bad as on hardware (and that change is out of scope for this commit). My best guess right now is that it has to do with the precise timing of the $2004 writes, as the OAM write and increment delays depend on the timing of the end of the CPU write. --- Core/NES/NesPpu.cpp | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/Core/NES/NesPpu.cpp b/Core/NES/NesPpu.cpp index 8966407c9..1adfc68ea 100644 --- a/Core/NES/NesPpu.cpp +++ b/Core/NES/NesPpu.cpp @@ -454,17 +454,20 @@ template void NesPpu::WriteRam(uint16_t addr, uint8_t value) break; case PpuRegisters::SpriteData: - if((_scanline >= 240 && (_region != ConsoleRegion::Pal || _scanline < _palSpriteEvalScanline)) || !IsRenderingEnabled()) { + //TODO: The $2003 increment can be eaten if it happens at the same time as another increment (from sprite processing or PAL's forced refresh). + //The increment appears to happen 1.5-2.5 dots after the write ends. + if(_scanline >= 240 || !IsRenderingEnabled()) { if((_spriteRamAddr & 0x03) == 0x02) { //"The three unimplemented bits of each sprite's byte 2 do not exist in the PPU and always read back as 0 on PPU revisions that allow reading PPU OAM through OAMDATA ($2004)" value &= 0xE3; } WriteSpriteRam(_spriteRamAddr, value); _emu->ProcessPpuWrite(_spriteRamAddr, value, MemoryType::NesSpriteRam); - _spriteRamAddr = (_spriteRamAddr + 1) & 0xFF; + _spriteRamAddr++; } else { //"Writes to OAMDATA during rendering (on the pre-render line and the visible lines 0-239, provided either sprite or background rendering is enabled) do not modify values in OAM, //but do perform a glitchy increment of OAMADDR, bumping only the high 6 bits" + //TODO: This should actually write the contents of the OAM buffer to the current OAM1 or OAM2 byte, 1.0-2.0 dots after the write ends. _spriteRamAddr = (_spriteRamAddr + 4) & 0xFC; _emu->BreakIfDebugging(CpuType::Nes, BreakSource::NesInvalidOamWrite); } @@ -1017,7 +1020,7 @@ template void NesPpu::ProcessSpriteEvaluationEnd() template void NesPpu::ProcessSpriteEvaluation() { - if(_prevRenderingEnabled || (_region == ConsoleRegion::Pal && _scanline >= _palSpriteEvalScanline)) { + if(_prevRenderingEnabled) { if(_cycle < 65) { //Clear secondary OAM at between cycle 1 and 64 _oamCopybuffer = 0xFF; @@ -1301,14 +1304,14 @@ template void NesPpu::Exec() } _preventVblFlag = false; } else if(_region == ConsoleRegion::Pal && _scanline >= _palSpriteEvalScanline) { - //"On a PAL machine, because of its extended vertical blank, the PPU begins refreshing OAM roughly 21 scanlines after NMI[2], to prevent it - //from decaying during the longer hiatus of rendering. Additionally, it will continue to refresh during the visible portion of the screen - //even if rendering is disabled. Because of this, OAM DMA must be done near the beginning of vertical blank on PAL, and everywhere else - //it is liable to conflict with the refresh. Since the refresh can't be disabled like on the NTSC hardware, OAM decay does not occur at all on the PAL NES." - if(_cycle <= 256) { - ProcessSpriteEvaluation(); - } else if(_cycle >= 257 && _cycle < 320) { - _spriteRamAddr = 0; + //"During scanlines 265-310, the PAL PPU does not perform sprite evaluation at all - instead, it simply increments the OAM address register + //every 2 pixels (at x=2, x=4, x=6, x=8, ..., x=334, x=336, x=338, and x=340, but not at x=0)." + if(((_cycle & 1) == 0) && (_cycle != 0)) { + _spriteRamAddr++; + + if(_enableOamDecay) { + _oamDecayCycles[_spriteRamAddr >> 3] = _console->GetCpu()->GetCycleCount(); + } } } } else { @@ -1390,9 +1393,9 @@ template void NesPpu::ProcessScanlineFirstCycle() template void NesPpu::CorruptOamRow(uint8_t sourceRow, uint8_t destRow) { - if(sourceRow != destRow) { - uint8_t sourceByte = (sourceRow << 3) & 0xFF; - uint8_t destByte = (destRow << 3) & 0xFF; + if(sourceRow != destRow && _region == ConsoleRegion::Ntsc) { + uint8_t sourceByte = sourceRow << 3; + uint8_t destByte = destRow << 3; memcpy(_spriteRam + destByte, _spriteRam + sourceByte, 8); _secondarySpriteRam[destRow] = _secondarySpriteRam[sourceRow]; From a8936a6e106e606ed683cbbffd816e581406175e Mon Sep 17 00:00:00 2001 From: Fiskbit <2811896+Fiskbit@users.noreply.github.com> Date: Sun, 24 May 2026 00:07:42 -0700 Subject: [PATCH 2/4] Update PR based on feedback. --- Core/NES/NesPpu.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Core/NES/NesPpu.cpp b/Core/NES/NesPpu.cpp index 1adfc68ea..68777a05b 100644 --- a/Core/NES/NesPpu.cpp +++ b/Core/NES/NesPpu.cpp @@ -463,7 +463,9 @@ template void NesPpu::WriteRam(uint16_t addr, uint8_t value) } WriteSpriteRam(_spriteRamAddr, value); _emu->ProcessPpuWrite(_spriteRamAddr, value, MemoryType::NesSpriteRam); - _spriteRamAddr++; + if(_region == ConsoleRegion::Ntsc || _scanline < _palSpriteEvalScanline || (((_cycle & 1) == 1) && (_cycle != 339))) { + _spriteRamAddr++; + } } else { //"Writes to OAMDATA during rendering (on the pre-render line and the visible lines 0-239, provided either sprite or background rendering is enabled) do not modify values in OAM, //but do perform a glitchy increment of OAMADDR, bumping only the high 6 bits" @@ -1306,7 +1308,8 @@ template void NesPpu::Exec() } else if(_region == ConsoleRegion::Pal && _scanline >= _palSpriteEvalScanline) { //"During scanlines 265-310, the PAL PPU does not perform sprite evaluation at all - instead, it simply increments the OAM address register //every 2 pixels (at x=2, x=4, x=6, x=8, ..., x=334, x=336, x=338, and x=340, but not at x=0)." - if(((_cycle & 1) == 0) && (_cycle != 0)) { + //This code only runs for dots 1-340, so we don't need to check dot 0 explicitly. + if((_cycle & 1) == 0) { _spriteRamAddr++; if(_enableOamDecay) { From e0ac5f14948ff50d9dde1ce3129ea5c946490fa1 Mon Sep 17 00:00:00 2001 From: Fiskbit <2811896+Fiskbit@users.noreply.github.com> Date: Sun, 24 May 2026 01:40:01 -0700 Subject: [PATCH 3/4] NES: Dendy UM6561AF-2 has OAM corruption except on $2003 writes. Based on my testing, frame-start (2C02E+ style) and mid-frame rendering toggle (NTSC style) OAM corruption happen on Dendy, but not $2003 write OAM corruption. So, this changes corruption emulation to match that. Tested with oam_flicker_test, oam_flicker_test_reenable, oam_read, oam_stress, and Huge Insect. --- Core/NES/NesPpu.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/NES/NesPpu.cpp b/Core/NES/NesPpu.cpp index 68777a05b..115055e16 100644 --- a/Core/NES/NesPpu.cpp +++ b/Core/NES/NesPpu.cpp @@ -1396,7 +1396,7 @@ template void NesPpu::ProcessScanlineFirstCycle() template void NesPpu::CorruptOamRow(uint8_t sourceRow, uint8_t destRow) { - if(sourceRow != destRow && _region == ConsoleRegion::Ntsc) { + if(sourceRow != destRow && _region != ConsoleRegion::Pal) { uint8_t sourceByte = sourceRow << 3; uint8_t destByte = destRow << 3; From a39cf9a1368e72df0f90eb4a7577ec8f8bd68de3 Mon Sep 17 00:00:00 2001 From: Fiskbit <2811896+Fiskbit@users.noreply.github.com> Date: Sun, 24 May 2026 02:09:40 -0700 Subject: [PATCH 4/4] Update PR based on more feedback. --- Core/NES/NesPpu.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/NES/NesPpu.cpp b/Core/NES/NesPpu.cpp index 115055e16..7a981b9cf 100644 --- a/Core/NES/NesPpu.cpp +++ b/Core/NES/NesPpu.cpp @@ -463,7 +463,7 @@ template void NesPpu::WriteRam(uint16_t addr, uint8_t value) } WriteSpriteRam(_spriteRamAddr, value); _emu->ProcessPpuWrite(_spriteRamAddr, value, MemoryType::NesSpriteRam); - if(_region == ConsoleRegion::Ntsc || _scanline < _palSpriteEvalScanline || (((_cycle & 1) == 1) && (_cycle != 339))) { + if(_region == ConsoleRegion::Ntsc || _scanline < _palSpriteEvalScanline || ((_cycle & 1) && (_cycle != 339))) { _spriteRamAddr++; } } else { @@ -1309,7 +1309,7 @@ template void NesPpu::Exec() //"During scanlines 265-310, the PAL PPU does not perform sprite evaluation at all - instead, it simply increments the OAM address register //every 2 pixels (at x=2, x=4, x=6, x=8, ..., x=334, x=336, x=338, and x=340, but not at x=0)." //This code only runs for dots 1-340, so we don't need to check dot 0 explicitly. - if((_cycle & 1) == 0) { + if(!(_cycle & 1)) { _spriteRamAddr++; if(_enableOamDecay) {