From 4e1cca6a5cf6923b8ab21ea31aabfae6bf7d525b Mon Sep 17 00:00:00 2001 From: TakuikaNinja Date: Tue, 3 Nov 2026 18:52:01 +1300 Subject: [PATCH 1/5] FDS: Implement DRAM refresh watchdog - Timing matches FDS-4030D1-Addr test - No regressions in timer IRQ test suite --- Core/NES/Mappers/FDS/Fds.cpp | 86 +++++++++++++++++++++++++++++++----- Core/NES/Mappers/FDS/Fds.h | 9 ++++ 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/Core/NES/Mappers/FDS/Fds.cpp b/Core/NES/Mappers/FDS/Fds.cpp index 5f9c7317cb..15f4e4ee0a 100644 --- a/Core/NES/Mappers/FDS/Fds.cpp +++ b/Core/NES/Mappers/FDS/Fds.cpp @@ -137,9 +137,10 @@ void Fds::WriteFdsDisk(uint8_t value) void Fds::ClockIrq() { + //Timer IRQ if(_irqEnabled) { if(_irqCounter == 0) { - _cpu->SetIrqSource(IRQSource::External); + _timerIrqPending = true; _irqCounter = _irqReloadValue; if(!_irqRepeatEnabled) { _irqEnabled = false; @@ -148,10 +149,47 @@ void Fds::ClockIrq() _irqCounter--; } } + + //DRAM refresh watchdog + if(_diskRegEnabled) { + //7-bit refresh counter: "...increment on every CPU cycle spent accessing memory outside of PRG-RAM, + // pause on CPU cycles accessing PRG-RAM" + if(!_dramAccessed) { + if(_dramRow == 0x7F) { + _dramRow = 0; + _watchdogTimer = 3245; //"...reload with 3245 when refresh counter overflows" + } else { + _dramRow++; + } + } else { + _dramAccessed = false; + } + + //"IRQ is asserted when the [12-bit] cycle timer underflows while $4023.D7 = 1." + if(_watchdogTimer == 0) { + _watchdogTimer = 4095; + //TODO Does the pending flag stay set after an underflow? + //(i.e. would enabling the IRQ afterwards assert IRQ?) + _watchdogIrqPending = true; + } else { + _watchdogTimer--; + } + } +} + +void Fds::UpdateIrqState() +{ + if(_timerIrqPending || (_watchdogIrqEnabled && _watchdogIrqPending)) { + _cpu->SetIrqSource(IRQSource::External); + } else if(!_timerIrqPending && !_watchdogIrqPending) { + _cpu->ClearIrqSource(IRQSource::External); + } } uint8_t Fds::ReadRam(uint16_t addr) { + _dramAccessed = (addr >= 0x6000 && addr < 0xE000); + if(addr == 0xE18C && !_gameStarted && (_memoryManager->DebugRead(0x100) & 0xC0)) { //$E18B is the NMI entry point (using $E18C due to dummy reads) //When NMI occurs while $100 & $C0 != 0, it typically means that the game is starting. @@ -215,6 +253,12 @@ uint8_t Fds::ReadRam(uint16_t addr) return BaseMapper::InternalRead(addr); } +void Fds::WriteRam(uint16_t addr, uint8_t value) +{ + _dramAccessed = (addr >= 0x6000 && addr < 0xE000); + BaseMapper::WriteRam(addr, value); +} + void Fds::ProcessAutoDiskInsert() { if(IsAutoInsertDiskEnabled()) { @@ -270,7 +314,6 @@ void Fds::EndFrame() - Should allow for accurate handling of level 2->3 load bug in Ai Senshi Nicol - Proper CRC handling using $4025 bits 4 and 6 - Verify End of Head handling (may affect Kosodate Gokko and FMC Disk Card Checker) - - DRAM refresh watchdog implementation (must track PRG-RAM vs external access cycles...) (Ongoing research, please consult TakuikaNinja for further details) **/ void Fds::ProcessCpuClock() @@ -278,6 +321,7 @@ void Fds::ProcessCpuClock() BaseProcessCpuClock(); ClockIrq(); + UpdateIrqState(); _audio->Clock(); if(_diskNumber == Fds::NoDiskInserted || !_motorOn) { @@ -438,7 +482,8 @@ void Fds::WriteRegister(uint16_t addr, uint8_t value) if(_irqEnabled) { _irqCounter = _irqReloadValue; } else { - _cpu->ClearIrqSource(IRQSource::External); + _timerIrqPending = false; + UpdateIrqState(); } break; @@ -446,6 +491,7 @@ void Fds::WriteRegister(uint16_t addr, uint8_t value) _diskRegEnabled = value & 0x01; //TODO This is actually an audio reset, rename this variable in Fds.h _soundRegEnabled = value & 0x02; + _watchdogIrqEnabled = value & 0x80; if(!_diskRegEnabled) { _irqEnabled = false; @@ -464,6 +510,12 @@ void Fds::WriteRegister(uint16_t addr, uint8_t value) //Disabling disk registers forces $4026 (external connector) = 0x7F _extConWriteReg = 0x7F; + //Reset DRAM refresh watchdog + _dramRow = 0; + _watchdogTimer = 4095; + + _timerIrqPending = false; + _watchdogIrqPending = false; _cpu->ClearIrqSource(IRQSource::External); _cpu->ClearIrqSource(IRQSource::FdsDisk); } @@ -530,17 +582,18 @@ uint8_t Fds::ReadRegister(uint16_t addr) case 0x4030: //These 2 pins are open bus value &= 0x24; - /**TODO: - - DRAM refresh watchdog IRQ status is returned in bit 1, needs implementation - - End of Head is returned in bit 6, needs verification - **/ - value |= _cpu->HasIrqSource(IRQSource::External) ? 0x01 : 0x00; - //value |= _transferComplete ? 0x02 : 0x00; + /**TODO End of Head is returned in bit 6, needs verification + **/ + value |= _timerIrqPending ? 0x01 : 0x00; + value |= _watchdogIrqPending ? 0x02 : 0x00; value |= GetMirroringType() == MirroringType::Horizontal ? 0x08 : 0x00; value |= _useQdFormat && _badCrc ? 0x10 : 0x00; //value |= _endOfHead ? 0x40 : 0x00; value |= _transferComplete ? 0x80 : 0x00; + //"Reading this register acknowledges all IRQs from the FDS except for the byte transfer flag" + _timerIrqPending = false; + _watchdogIrqPending = false; _cpu->ClearIrqSource(IRQSource::External); return value; @@ -629,6 +682,12 @@ void Fds::Serialize(Serializer& s) SV(_successiveChecks); SV(_previousDiskNumber); SV(_crcAccumulator); + SV(_timerIrqPending); + SV(_watchdogIrqPending); + SV(_watchdogIrqEnabled); + SV(_dramAccessed); + SV(_dramRow); + SV(_watchdogTimer); if(s.IsSaving()) { for(int i = 0; i < (int)_fdsDiskSides.size(); i++) { @@ -698,9 +757,14 @@ vector Fds::GetMapperStateEntries() entries.push_back(MapperStateEntry("$4022.1", "IRQ Enabled", _irqEnabled, MapperStateValueType::Bool)); entries.push_back(MapperStateEntry("", "IRQ Counter", _irqCounter, MapperStateValueType::Number16)); + entries.push_back(MapperStateEntry("", "DRAM Refresh Watchdog")); + entries.push_back(MapperStateEntry("", "DRAM Row", _dramRow, MapperStateValueType::Number8)); + entries.push_back(MapperStateEntry("", "Watchdog Timer", _watchdogTimer, MapperStateValueType::Number16)); + entries.push_back(MapperStateEntry("", "I/O Control")); entries.push_back(MapperStateEntry("$4023.0", "Disk Registers Enabled", _diskRegEnabled, MapperStateValueType::Bool)); entries.push_back(MapperStateEntry("$4023.1", "Audio Enabled", _soundRegEnabled, MapperStateValueType::Bool)); + entries.push_back(MapperStateEntry("$4023.7", "DRAM Refresh Watchdog IRQ Enabled", _watchdogIrqEnabled, MapperStateValueType::Bool)); entries.push_back(MapperStateEntry("", "Disk Drive")); entries.push_back(MapperStateEntry("$4025", "Drive Control")); @@ -713,8 +777,8 @@ vector Fds::GetMapperStateEntries() entries.push_back(MapperStateEntry("$4025.7", "Disk IRQ Enabled", _diskIrqEnabled, MapperStateValueType::Bool)); entries.push_back(MapperStateEntry("$4030", "Drive/IRQ Status")); - entries.push_back(MapperStateEntry("$4030.0", "Timer IRQ", _cpu->HasIrqSource(IRQSource::External), MapperStateValueType::Bool)); - //entries.push_back(MapperStateEntry("$4030.1", "DRAM Refresh Watchdog IRQ", false, MapperStateValueType::Bool)); + entries.push_back(MapperStateEntry("$4030.0", "Timer IRQ", _timerIrqPending, MapperStateValueType::Bool)); + entries.push_back(MapperStateEntry("$4030.1", "DRAM Refresh Watchdog IRQ", _watchdogIrqPending, MapperStateValueType::Bool)); entries.push_back(MapperStateEntry("$4030.3", "Mirroring", GetMirroringType() == MirroringType::Horizontal ? "Horizontal" : "Vertical", GetMirroringType() == MirroringType::Horizontal ? 1 : 0)); entries.push_back(MapperStateEntry("$4030.5", "CRC Error", (_useQdFormat && _badCrc), MapperStateValueType::Bool)); //entries.push_back(MapperStateEntry("$4030.6", "End of Head", _endOfHead, MapperStateValueType::Bool)); diff --git a/Core/NES/Mappers/FDS/Fds.h b/Core/NES/Mappers/FDS/Fds.h index 256b5972fe..5ce2865580 100644 --- a/Core/NES/Mappers/FDS/Fds.h +++ b/Core/NES/Mappers/FDS/Fds.h @@ -26,6 +26,7 @@ class Fds : public BaseMapper uint16_t _irqCounter = 0; bool _irqEnabled = false; bool _irqRepeatEnabled = false; + bool _timerIrqPending = false; bool _diskRegEnabled = true; bool _soundRegEnabled = true; @@ -81,6 +82,12 @@ class Fds : public BaseMapper bool _gameStarted = false; bool _needSave = false; + bool _watchdogIrqEnabled = false; + bool _watchdogIrqPending = false; + bool _dramAccessed = false; + uint8_t _dramRow = 0; // 7 bits + uint16_t _watchdogTimer = 4095; // 12 bits + protected: uint16_t GetPrgPageSize() override { return 0x2000; } uint16_t GetChrPageSize() override { return 0x2000; } @@ -104,6 +111,7 @@ class Fds : public BaseMapper void ProcessAutoDiskInsert(); void ClockIrq(); + void UpdateIrqState(); void ProcessCpuClock() override; void UpdateCrc(uint8_t value); @@ -112,6 +120,7 @@ class Fds : public BaseMapper uint8_t ReadRegister(uint16_t addr) override; uint8_t ReadRam(uint16_t addr) override; + void WriteRam(uint16_t addr, uint8_t value) override; void Serialize(Serializer& s) override; vector GetMapperStateEntries() override; From 581bce159c7b3637d5aed35e09367b9a6e1c5b7c Mon Sep 17 00:00:00 2001 From: TakuikaNinja Date: Tue, 3 Nov 2026 22:07:17 +1300 Subject: [PATCH 2/5] FDS: Handle savestates which predate the watchdog --- Core/NES/Mappers/FDS/Fds.cpp | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/Core/NES/Mappers/FDS/Fds.cpp b/Core/NES/Mappers/FDS/Fds.cpp index 15f4e4ee0a..b9da482266 100644 --- a/Core/NES/Mappers/FDS/Fds.cpp +++ b/Core/NES/Mappers/FDS/Fds.cpp @@ -188,7 +188,7 @@ void Fds::UpdateIrqState() uint8_t Fds::ReadRam(uint16_t addr) { - _dramAccessed = (addr >= 0x6000 && addr < 0xE000); + _dramAccessed |= (addr >= 0x6000 && addr < 0xE000); if(addr == 0xE18C && !_gameStarted && (_memoryManager->DebugRead(0x100) & 0xC0)) { //$E18B is the NMI entry point (using $E18C due to dummy reads) @@ -255,7 +255,7 @@ uint8_t Fds::ReadRam(uint16_t addr) void Fds::WriteRam(uint16_t addr, uint8_t value) { - _dramAccessed = (addr >= 0x6000 && addr < 0xE000); + _dramAccessed |= (addr >= 0x6000 && addr < 0xE000); BaseMapper::WriteRam(addr, value); } @@ -682,12 +682,6 @@ void Fds::Serialize(Serializer& s) SV(_successiveChecks); SV(_previousDiskNumber); SV(_crcAccumulator); - SV(_timerIrqPending); - SV(_watchdogIrqPending); - SV(_watchdogIrqEnabled); - SV(_dramAccessed); - SV(_dramRow); - SV(_watchdogTimer); if(s.IsSaving()) { for(int i = 0; i < (int)_fdsDiskSides.size(); i++) { @@ -695,6 +689,25 @@ void Fds::Serialize(Serializer& s) SVVectorI(ipsData); } } else { + if(!s.ContainsKey("_timerIrqPending")) { + //Backwards compatibility with savetates that predate the watchdog's discovery + _timerIrqPending = _cpu->HasIrqSource(IRQSource::External); //External source was previously reserved for the timer IRQ + _watchdogIrqPending = false; + //$4023.D7 is missing, so assume based on $4023.D0 state + //(most commercial games don't touch the BIOS init value of $83) + _watchdogIrqEnabled = _diskRegEnabled; + _dramAccessed = false; + _dramRow = 0; + _watchdogTimer = 4095; + } else { + SV(_timerIrqPending); + SV(_watchdogIrqPending); + SV(_watchdogIrqEnabled); + SV(_dramAccessed); + SV(_dramRow); + SV(_watchdogTimer); + } + for(int i = 0; i < (int)_fdsDiskSides.size(); i++) { vector ipsData; SVVectorI(ipsData); From d7874adee0d411db86e8c249d7de93a6c5ee54c5 Mon Sep 17 00:00:00 2001 From: TakuikaNinja Date: Tue, 3 Nov 2026 23:22:16 +1300 Subject: [PATCH 3/5] FDS: Document purpose of BIOS Delay131 routine --- UI/Debugger/Labels/DefaultLabelHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UI/Debugger/Labels/DefaultLabelHelper.cs b/UI/Debugger/Labels/DefaultLabelHelper.cs index 94f5123188..c835f0b4d7 100644 --- a/UI/Debugger/Labels/DefaultLabelHelper.cs +++ b/UI/Debugger/Labels/DefaultLabelHelper.cs @@ -295,7 +295,7 @@ private static void SetDefaultNesLabels() LabelManager.SetLabel(0x04A0, MemoryType.NesPrgRom, "FileMatchTest", "Input: $02-$03 = Pointer to File ID list " + Environment.NewLine + "Output: " + Environment.NewLine + "Desc: Uses a byte string pointed at by Ptr($02) to tell the disk system which files to load. The file ID's number is searched for in the string. If an exact match is found, [$09] is 0'd, and [$0e] is incremented. If no matches are found after 20 bytes, or a -1 entry is encountered, [$09] is set to -1. If the first byte in the string is -1, the BootID number is used for matching files (any FileID that is not greater than the BootID qualifies as a match)."); LabelManager.SetLabel(0x04DA, MemoryType.NesPrgRom, "SkipFiles", "Input: $06 = # of files to skip " + Environment.NewLine + "Output: " + Environment.NewLine + "Desc: Skips over a specified number of files."); - LabelManager.SetLabel(0x0149, MemoryType.NesPrgRom, "Delay131", "Input: " + Environment.NewLine + "Output: " + Environment.NewLine + "Affects: " + Environment.NewLine + "Desc: 131 clock cycle delay (including the JSR instruction to call it) used by the \"BIOS acknowledge and delay\" IRQ handler."); + LabelManager.SetLabel(0x0149, MemoryType.NesPrgRom, "Delay131", "Input: " + Environment.NewLine + "Output: " + Environment.NewLine + "Affects: " + Environment.NewLine + "Desc: 131 clock cycle delay (including the JSR instruction to call it) used by the \"BIOS acknowledge and delay\" IRQ handler. Intended for refreshing DRAM rows when the DRAM refresh watchdog has expired."); LabelManager.SetLabel(0x0153, MemoryType.NesPrgRom, "Delayms", "Input: Y = Delay in ms (approximate) " + Environment.NewLine + "Output: " + Environment.NewLine + "Affects: X, Y " + Environment.NewLine + "Desc: Delay routine, where the time in CPU cycles is 1790*Y+5. Y = 0 is treated as 256."); LabelManager.SetLabel(0x0161, MemoryType.NesPrgRom, "DisPFObj", "Input: $fe = PPUMASK value " + Environment.NewLine + "Output: " + Environment.NewLine + "Affects: A, $fe " + Environment.NewLine + "Desc: Disable rendering for both sprites and background."); LabelManager.SetLabel(0x016B, MemoryType.NesPrgRom, "EnPFObj", "Input: $fe = PPUMASK value " + Environment.NewLine + "Output: " + Environment.NewLine + "Affects: A, $fe " + Environment.NewLine + "Desc: Enable rendering for both sprites and background."); From b8234ee7f72d80bfac9d01310eef87417c7a56f3 Mon Sep 17 00:00:00 2001 From: TakuikaNinja Date: Wed, 4 Nov 2026 16:11:05 +1300 Subject: [PATCH 4/5] FDS: Watchdog accuracy improvements - Checked against older FDS-4030D1-Micro test --- Core/NES/Mappers/FDS/Fds.cpp | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/Core/NES/Mappers/FDS/Fds.cpp b/Core/NES/Mappers/FDS/Fds.cpp index b9da482266..347c3a6ce2 100644 --- a/Core/NES/Mappers/FDS/Fds.cpp +++ b/Core/NES/Mappers/FDS/Fds.cpp @@ -150,8 +150,16 @@ void Fds::ClockIrq() } } - //DRAM refresh watchdog + //DRAM refresh watchdog: https://forums.nesdev.org/viewtopic.php?t=26359 if(_diskRegEnabled) { + //"IRQ is asserted when the [12-bit] cycle timer underflows while $4023.D7 = 1." + if(_watchdogTimer == 0) { + _watchdogTimer = 4095; + _watchdogIrqPending = _watchdogIrqEnabled; + } else { + _watchdogTimer--; + } + //7-bit refresh counter: "...increment on every CPU cycle spent accessing memory outside of PRG-RAM, // pause on CPU cycles accessing PRG-RAM" if(!_dramAccessed) { @@ -161,28 +169,17 @@ void Fds::ClockIrq() } else { _dramRow++; } - } else { - _dramAccessed = false; - } - - //"IRQ is asserted when the [12-bit] cycle timer underflows while $4023.D7 = 1." - if(_watchdogTimer == 0) { - _watchdogTimer = 4095; - //TODO Does the pending flag stay set after an underflow? - //(i.e. would enabling the IRQ afterwards assert IRQ?) - _watchdogIrqPending = true; - } else { - _watchdogTimer--; } } + _dramAccessed = false; } void Fds::UpdateIrqState() { - if(_timerIrqPending || (_watchdogIrqEnabled && _watchdogIrqPending)) { - _cpu->SetIrqSource(IRQSource::External); - } else if(!_timerIrqPending && !_watchdogIrqPending) { + if(!(_timerIrqPending || _watchdogIrqPending)) { _cpu->ClearIrqSource(IRQSource::External); + } else { + _cpu->SetIrqSource(IRQSource::External); } } @@ -493,6 +490,10 @@ void Fds::WriteRegister(uint16_t addr, uint8_t value) _soundRegEnabled = value & 0x02; _watchdogIrqEnabled = value & 0x80; + if(!_watchdogIrqEnabled) { + _watchdogIrqPending = false; + } + if(!_diskRegEnabled) { _irqEnabled = false; @@ -516,10 +517,11 @@ void Fds::WriteRegister(uint16_t addr, uint8_t value) _timerIrqPending = false; _watchdogIrqPending = false; - _cpu->ClearIrqSource(IRQSource::External); _cpu->ClearIrqSource(IRQSource::FdsDisk); } + UpdateIrqState(); + /**TODO Determine/implement audio reset behaviour, should probably go in FdsAudio: - Proper method of resetting modulation state ($4085 write below doesn't always work) - Reset wave accumulator to 0 From 78d9a524d61bdc394c9a54e74c03d3ddada314a3 Mon Sep 17 00:00:00 2001 From: TakuikaNinja Date: Thu, 5 Nov 2026 12:46:43 +1300 Subject: [PATCH 5/5] FDS: Save watchdog state in new savestates Also only call UpdateIRQState when flags have changed --- Core/NES/Mappers/FDS/Fds.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Core/NES/Mappers/FDS/Fds.cpp b/Core/NES/Mappers/FDS/Fds.cpp index 347c3a6ce2..e578f0d775 100644 --- a/Core/NES/Mappers/FDS/Fds.cpp +++ b/Core/NES/Mappers/FDS/Fds.cpp @@ -145,6 +145,7 @@ void Fds::ClockIrq() if(!_irqRepeatEnabled) { _irqEnabled = false; } + UpdateIrqState(); } else { _irqCounter--; } @@ -156,6 +157,7 @@ void Fds::ClockIrq() if(_watchdogTimer == 0) { _watchdogTimer = 4095; _watchdogIrqPending = _watchdogIrqEnabled; + UpdateIrqState(); } else { _watchdogTimer--; } @@ -185,7 +187,7 @@ void Fds::UpdateIrqState() uint8_t Fds::ReadRam(uint16_t addr) { - _dramAccessed |= (addr >= 0x6000 && addr < 0xE000); + _dramAccessed = (addr >= 0x6000 && addr < 0xE000); if(addr == 0xE18C && !_gameStarted && (_memoryManager->DebugRead(0x100) & 0xC0)) { //$E18B is the NMI entry point (using $E18C due to dummy reads) @@ -252,7 +254,7 @@ uint8_t Fds::ReadRam(uint16_t addr) void Fds::WriteRam(uint16_t addr, uint8_t value) { - _dramAccessed |= (addr >= 0x6000 && addr < 0xE000); + _dramAccessed = (addr >= 0x6000 && addr < 0xE000); BaseMapper::WriteRam(addr, value); } @@ -318,7 +320,7 @@ void Fds::ProcessCpuClock() BaseProcessCpuClock(); ClockIrq(); - UpdateIrqState(); + _audio->Clock(); if(_diskNumber == Fds::NoDiskInserted || !_motorOn) { @@ -686,12 +688,19 @@ void Fds::Serialize(Serializer& s) SV(_crcAccumulator); if(s.IsSaving()) { + SV(_timerIrqPending); + SV(_watchdogIrqPending); + SV(_watchdogIrqEnabled); + SV(_dramAccessed); + SV(_dramRow); + SV(_watchdogTimer); + for(int i = 0; i < (int)_fdsDiskSides.size(); i++) { vector ipsData = IpsPatcher::CreatePatch(_orgDiskSides[i], _fdsDiskSides[i]); SVVectorI(ipsData); } } else { - if(!s.ContainsKey("_timerIrqPending")) { + if(!s.ContainsKey("timerIrqPending")) { //Backwards compatibility with savetates that predate the watchdog's discovery _timerIrqPending = _cpu->HasIrqSource(IRQSource::External); //External source was previously reserved for the timer IRQ _watchdogIrqPending = false;