From afeb20acc0c2479125141f1facd541b41b626dd8 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Mon, 6 Apr 2026 21:32:37 -0700 Subject: [PATCH 1/7] Fix timer accuracy: IRQ toggle mode, dynamic dotclock, gate modes - Implement IRQ toggle mode (bit 7): bit 10 now XORs on each IRQ instead of unconditionally setting, matching hardware behavior - Compute dotclock rate dynamically from GPU horizontal resolution and video standard instead of hardcoding rate=5 (only correct for 320px mode) - Implement gate modes 1 and 3 for Counter 0 (Hblank) and Counter 1 (Vblank): reset-at-blank and pause-until-first-blank - Properly decode Timer 2 sync mode bits (modes 0,3 stop; modes 1,2 free run) - Add 18-test hardware-verified timer test suite - Pull common.mk from fuckit branch for test infrastructure All behaviors verified on physical SCPH-5501 hardware via serial. Signed-off-by: Nicolas 'Pixel' Noble --- src/core/psxcounters.cc | 104 +++++++-- src/core/psxcounters.h | 19 +- src/core/sstate.cc | 11 +- src/core/sstate.h | 3 +- src/mips/tests/common.mk | 43 ++++ src/mips/tests/timers/Makefile | 8 + src/mips/tests/timers/timers.c | 389 +++++++++++++++++++++++++++++++++ 7 files changed, 551 insertions(+), 26 deletions(-) create mode 100644 src/mips/tests/common.mk create mode 100644 src/mips/tests/timers/Makefile create mode 100644 src/mips/tests/timers/timers.c diff --git a/src/core/psxcounters.cc b/src/core/psxcounters.cc index 1c4ba9bae..bd3e526be 100644 --- a/src/core/psxcounters.cc +++ b/src/core/psxcounters.cc @@ -43,7 +43,11 @@ inline void PCSX::Counters::writeCounterInternal(uint32_t index, uint32_t value) m_rcnts[index].cycleStart = PCSX::g_emulator->m_cpu->m_regs.cycle; m_rcnts[index].cycleStart -= value * m_rcnts[index].rate; - // TODO: <=. + // Hardware tested (SCPH-5501): counter reaches target value briefly before + // resetting. Fast reads can observe the target value; slow reads (printf) cannot. + // The comparison boundary needs more precise measurement to determine if + // reset happens on the same cycle as reaching target or the next cycle. + // TODO: determine exact reset timing with cycle-accurate measurement. if (value < m_rcnts[index].target) { m_rcnts[index].cycle = m_rcnts[index].target * m_rcnts[index].rate; m_rcnts[index].counterState = CountToTarget; @@ -132,7 +136,15 @@ void PCSX::Counters::reset(uint32_t index) { m_rcnts[index].mode |= RcOverflow; } - m_rcnts[index].mode |= RcIrqRequest; + // IRQ request flag (bit 10) behavior depends on bit 7 (toggle mode): + // Pulse mode (bit7=0): bit 10 resets to 1 after IRQ (short pulse low) + // Toggle mode (bit7=1): bit 10 toggles (XOR) on each IRQ + // Hardware verified: toggle mode produces different bit10 state than pulse mode. + if (m_rcnts[index].mode & RcIrqToggle) { + m_rcnts[index].mode ^= RcIrqRequest; + } else { + m_rcnts[index].mode |= RcIrqRequest; + } set(); } @@ -179,6 +191,24 @@ void PCSX::Counters::update() { m_hSyncCount++; m_spuSyncCountdown--; + // Counter 0 gate: triggered by Hblank + if (isGateEnabled(0, m_rcnts[0].mode)) { + switch (gateSyncMode(m_rcnts[0].mode)) { + case 1: // Reset at Hblank + case 2: // Reset at Hblank + pause outside + writeCounterInternal(0, 0); + break; + case 3: // Pause until first Hblank, then free run + if (!m_rcnts[0].gateStarted) { + m_rcnts[0].gateStarted = 1; + recalculateRate(0); + writeCounterInternal(0, 0); + set(); + } + break; + } + } + // Update spu. if (m_spuSyncCountdown <= 0) { // Scanlines until next sync @@ -197,6 +227,24 @@ void PCSX::Counters::update() { if (m_hSyncCount == VBlankStart[PCSX::g_emulator->settings.get()]) { setIrq(0x01); PCSX::g_emulator->vsync(); + + // Counter 1 gate: triggered by VBlank start + if (isGateEnabled(1, m_rcnts[1].mode)) { + switch (gateSyncMode(m_rcnts[1].mode)) { + case 1: // Reset at VBlank + case 2: // Reset at VBlank + pause outside + writeCounterInternal(1, 0); + break; + case 3: // Pause until first VBlank, then free run + if (!m_rcnts[1].gateStarted) { + m_rcnts[1].gateStarted = 1; + recalculateRate(1); + writeCounterInternal(1, 0); + set(); + } + break; + } + } } if (m_hSyncCount >= m_HSyncTotal[PCSX::g_emulator->settings.get()]) { @@ -213,17 +261,22 @@ void PCSX::Counters::writeCounter(uint32_t index, uint32_t value) { set(); } -void PCSX::Counters::writeMode(uint32_t index, uint32_t value) { - verboseLog(1, "[RCNT %i] writeMode: %x\n", index, value); - - update(); - m_rcnts[index].mode = value; - m_rcnts[index].irqState = false; - +void PCSX::Counters::recalculateRate(uint32_t index) { + uint16_t value = m_rcnts[index].mode; switch (index) { case 0: if (value & Rc0PixelClock) { - m_rcnts[index].rate = 5; + // Dotclock rate depends on GPU horizontal resolution and video standard. + static constexpr uint32_t dotclockDividers[] = {10, 8, 5, 4, 7, 7}; + auto hres = PCSX::g_emulator->m_gpu->m_display.info.hres; + auto videoMode = PCSX::g_emulator->m_gpu->m_display.info.mode; + uint32_t divider = dotclockDividers[static_cast(hres)]; + uint32_t videoCyclesPerScanline = (videoMode == GPU::CtrlDisplayMode::VM_PAL) ? 3406 : 3413; + uint32_t dotsPerScanline = videoCyclesPerScanline / divider; + uint32_t cpuCyclesPerScanline = (PCSX::g_emulator->m_psxClockSpeed / + (FrameRate[PCSX::g_emulator->settings.get()] * + m_HSyncTotal[PCSX::g_emulator->settings.get()])); + m_rcnts[index].rate = std::max(cpuCyclesPerScanline / dotsPerScanline, 1); } else { m_rcnts[index].rate = 1; } @@ -243,13 +296,38 @@ void PCSX::Counters::writeMode(uint32_t index, uint32_t value) { } else { m_rcnts[index].rate = 1; } - - // TODO: wcount must work. + // Timer 2 sync modes: 0,3 = stop counter; 1,2 = free run + // Hardware verified (SCPH-5501): modes 0,3 produce delta=0, modes 1,2 run normally. if (value & Rc2Disable) { - m_rcnts[index].rate = 0xffffffff; + uint8_t syncMode = gateSyncMode(value); + if (syncMode == 0 || syncMode == 3) { + m_rcnts[index].rate = 0xffffffff; + } } break; } +} + +void PCSX::Counters::writeMode(uint32_t index, uint32_t value) { + verboseLog(1, "[RCNT %i] writeMode: %x\n", index, value); + + update(); + m_rcnts[index].mode = value; + m_rcnts[index].irqState = false; + + recalculateRate(index); + + // Initialize gate state + m_rcnts[index].gateStarted = 0; + + if (isGateEnabled(index, value)) { + uint8_t syncMode = gateSyncMode(value); + if (syncMode == 2 || syncMode == 3) { + // Mode 2: pause outside blank. Mode 3: pause until first blank. + // Both start paused. + m_rcnts[index].rate = 0xffffffff; + } + } writeCounterInternal(index, 0); set(); diff --git a/src/core/psxcounters.h b/src/core/psxcounters.h index b8c1b3b3f..732a4dfb6 100644 --- a/src/core/psxcounters.h +++ b/src/core/psxcounters.h @@ -36,25 +36,30 @@ class Counters { void set(); void reset(uint32_t index); void calculateHsync(); + void recalculateRate(uint32_t index); struct Rcnt { uint16_t mode, target; uint32_t rate, irq, counterState, irqState; uint64_t cycle, cycleStart; + uint32_t gateStarted; // Gate mode 3: first blank has occurred, counter is free running }; + // Gate mode helpers - derived from mode register, not stored + static bool isGateEnabled(uint32_t index, uint16_t mode) { return (index <= 1) && (mode & RcSyncEnable); } + static uint8_t gateSyncMode(uint16_t mode) { return (mode >> 1) & 3; } + enum { - Rc0Gate = 0x0001, // 0 not implemented - Rc1Gate = 0x0001, // 0 not implemented - Rc2Disable = 0x0001, // 0 partially implemented - RcUnknown1 = 0x0002, // 1 ? - RcUnknown2 = 0x0004, // 2 ? + RcSyncEnable = 0x0001, // 0 Sync/gate enable + Rc2Disable = 0x0001, // 0 Timer 2: modes 0,3 = stop counter + RcSyncMode0 = 0x0002, // 1 Sync mode bit 0 + RcSyncMode1 = 0x0004, // 2 Sync mode bit 1 RcCountToTarget = 0x0008, // 3 RcIrqOnTarget = 0x0010, // 4 RcIrqOnOverflow = 0x0020, // 5 RcIrqRegenerate = 0x0040, // 6 - RcUnknown7 = 0x0080, // 7 ? - Rc0PixelClock = 0x0100, // 8 fake implementation + RcIrqToggle = 0x0080, // 7 IRQ toggle mode (0=pulse, 1=toggle bit10) + Rc0PixelClock = 0x0100, // 8 dotclock (varies with GPU hres) Rc1HSyncClock = 0x0100, // 8 Rc2Unknown8 = 0x0100, // 8 ? Rc0Unknown9 = 0x0200, // 9 ? diff --git a/src/core/sstate.cc b/src/core/sstate.cc index 27107c2a8..af2bce2b8 100644 --- a/src/core/sstate.cc +++ b/src/core/sstate.cc @@ -272,6 +272,7 @@ void PCSX::Counters::serialize(SaveStateWrapper* w) { counters.get().value[i].get().value = m_rcnts[i].irqState; counters.get().value[i].get().value = m_rcnts[i].cycle; counters.get().value[i].get().value = m_rcnts[i].cycleStart; + counters.get().value[i].get().value = m_rcnts[i].gateStarted; } counters.get().value = m_hSyncCount; counters.get().value = m_spuSyncCountdown; @@ -433,20 +434,20 @@ void PCSX::Counters::deserialize(const SaveStateWrapper* w) { m_rcnts[i].irqState = counters.get().value[i].get().value; m_rcnts[i].cycle = counters.get().value[i].get().value; m_rcnts[i].cycleStart = counters.get().value[i].get().value; + m_rcnts[i].gateStarted = counters.get().value[i].get().value; } m_hSyncCount = counters.get().value; m_spuSyncCountdown = counters.get().value; m_psxNextCounter = counters.get().value; calculateHsync(); - // iCB: recalculate target count in case overclock is changed + // Recalculate rates from mode registers (handles overclock changes and dotclock) m_rcnts[3].target = (g_emulator->m_psxClockSpeed / (FrameRate[g_emulator->settings.get()] * m_HSyncTotal[g_emulator->settings.get()])); - if (m_rcnts[1].rate != 1) - m_rcnts[1].rate = - (g_emulator->m_psxClockSpeed / (FrameRate[g_emulator->settings.get()] * - m_HSyncTotal[g_emulator->settings.get()])); + for (unsigned i = 0; i < 3; i++) { + recalculateRate(i); + } m_audioFrames = g_emulator->m_spu->getCurrentFrames(); } diff --git a/src/core/sstate.h b/src/core/sstate.h index 317b51685..2822be04d 100644 --- a/src/core/sstate.h +++ b/src/core/sstate.h @@ -257,8 +257,9 @@ typedef Protobuf::Field RcntCo typedef Protobuf::Field RcntIRQState; typedef Protobuf::Field RcntCycle; typedef Protobuf::Field RcntCycleStart; +typedef Protobuf::Field RcntGateStarted; typedef Protobuf::Message + RcntCycle, RcntCycleStart, RcntGateStarted> Rcnt; typedef Protobuf::RepeatedField Rcnts; typedef Protobuf::Field HSyncCount; diff --git a/src/mips/tests/common.mk b/src/mips/tests/common.mk new file mode 100644 index 000000000..24c28cda9 --- /dev/null +++ b/src/mips/tests/common.mk @@ -0,0 +1,43 @@ +USE_FUNCTION_SECTIONS = false +TYPE = ps-exe + +SRCS = \ +../uC-sdk-glue/BoardConsole.c \ +../uC-sdk-glue/BoardInit.c \ +../uC-sdk-glue/init.c \ +\ +../../../../third_party/uC-sdk/libc/src/cxx-glue.c \ +../../../../third_party/uC-sdk/libc/src/errno.c \ +../../../../third_party/uC-sdk/libc/src/initfini.c \ +../../../../third_party/uC-sdk/libc/src/malloc.c \ +../../../../third_party/uC-sdk/libc/src/qsort.c \ +../../../../third_party/uC-sdk/libc/src/rand.c \ +../../../../third_party/uC-sdk/libc/src/reent.c \ +../../../../third_party/uC-sdk/libc/src/stdio.c \ +../../../../third_party/uC-sdk/libc/src/string.c \ +../../../../third_party/uC-sdk/libc/src/strto.c \ +../../../../third_party/uC-sdk/libc/src/unistd.c \ +../../../../third_party/uC-sdk/libc/src/xprintf.c \ +../../../../third_party/uC-sdk/libc/src/xscanf.c \ +../../../../third_party/uC-sdk/libc/src/yscanf.c \ +../../../../third_party/uC-sdk/os/src/devfs.c \ +../../../../third_party/uC-sdk/os/src/filesystem.c \ +../../../../third_party/uC-sdk/os/src/fio.c \ +../../../../third_party/uC-sdk/os/src/hash-djb2.c \ +../../../../third_party/uC-sdk/os/src/init.c \ +../../../../third_party/uC-sdk/os/src/osdebug.c \ +../../../../third_party/uC-sdk/os/src/romfs.c \ +../../../../third_party/uC-sdk/os/src/sbrk.c \ +../../common/syscalls/printf.s \ +../../common/crt0/uC-sdk-crt0.s \ + +CPPFLAGS = -DNOFLOATINGPOINT +CPPFLAGS += -I. +CPPFLAGS += -I../../../../third_party/uC-sdk/libc/include +CPPFLAGS += -I../../../../third_party/uC-sdk/os/include +CPPFLAGS += -I../../../../third_party/libcester/include +CPPFLAGS += -I../../openbios/uC-sdk-glue + +ifeq ($(PCSX_TESTS),true) +CPPFLAGS += -DPCSX_TESTS=1 +endif diff --git a/src/mips/tests/timers/Makefile b/src/mips/tests/timers/Makefile new file mode 100644 index 000000000..ef4f273f4 --- /dev/null +++ b/src/mips/tests/timers/Makefile @@ -0,0 +1,8 @@ +TARGET = timers + +include ../common.mk + +SRCS += \ +timers.c \ + +include ../../common.mk diff --git a/src/mips/tests/timers/timers.c b/src/mips/tests/timers/timers.c new file mode 100644 index 000000000..4d764652d --- /dev/null +++ b/src/mips/tests/timers/timers.c @@ -0,0 +1,389 @@ +/* + +MIT License + +Copyright (c) 2026 PCSX-Redux authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +#ifndef PCSX_TESTS +#define PCSX_TESTS 0 +#endif + +#if PCSX_TESTS +#define CESTER_MAYBE_TEST CESTER_SKIP_TEST +#else +#define CESTER_MAYBE_TEST CESTER_TEST +#endif + +#include "common/hardware/counters.h" +#include "common/hardware/hwregs.h" +#include "common/syscalls/syscalls.h" + +#undef unix +#define CESTER_NO_SIGNAL +#define CESTER_NO_TIME +#define EXIT_SUCCESS 0 +#define EXIT_FAILURE 1 +#include "exotic/cester.h" + +// clang-format off + +/* Timer mode bits */ +#define TM_SYNC_EN 0x0001 +#define TM_SYNC_MODE(n) (((n) & 3) << 1) +#define TM_RESET_TARGET 0x0008 +#define TM_IRQ_TARGET 0x0010 +#define TM_IRQ_OVERFLOW 0x0020 +#define TM_IRQ_REPEAT 0x0040 +#define TM_IRQ_TOGGLE 0x0080 +#define TM_CLK_EXTERNAL 0x0100 +#define TM_CLK_DIV8 0x0200 +#define TM_IRQ_REQUEST 0x0400 +#define TM_HIT_TARGET 0x0800 +#define TM_HIT_OVERFLOW 0x1000 + +#define BUSY_WAIT(n) do { volatile int _bw = (n); while (_bw-- > 0) __asm__ volatile("nop"); } while(0) + +/* ================================================================= + * Target reset: counter reaches target value, then resets. + * The target value IS briefly visible on reads. + * With target=N, reset-on-target: counter counts 0..N, then resets. + * The hit-target flag (bit 11) should be set after target is reached. + * ================================================================= */ +CESTER_TEST(timerTargetResetHitsTarget, timer_tests, + COUNTERS[2].target = 0x0010; + COUNTERS[2].mode = TM_RESET_TARGET; + BUSY_WAIT(500); + + uint16_t mode = COUNTERS[2].mode; + /* Hit-target flag should be set after counter reached target */ + cester_assert_cmp((int)(mode & TM_HIT_TARGET), !=, 0); +) + +/* Counter with reset-on-target should not overflow (bit 12 stays clear) + * if target is well below 0xFFFF. */ +CESTER_TEST(timerTargetResetNoOverflow, timer_tests, + COUNTERS[2].target = 0x0010; + COUNTERS[2].mode = TM_RESET_TARGET; + BUSY_WAIT(500); + + uint16_t mode = COUNTERS[2].mode; + /* Overflow flag should NOT be set - counter resets at target */ + cester_assert_equal((int)(mode & TM_HIT_OVERFLOW), 0); +) + +/* ================================================================= + * Sysclock/8 divider ratio. + * Run both clock modes with same delay, ratio should be ~8. + * ================================================================= */ +CESTER_TEST(timerSysclockDiv8Ratio, timer_tests, + /* Use a short delay to avoid 16-bit counter wrap on sysclock. + * At ~33MHz sysclock, 1000 loop iters ~ a few thousand counts. */ + + /* Sysclock/8 mode first (slower, won't wrap) */ + COUNTERS[2].target = 0xFFFF; + COUNTERS[2].mode = TM_CLK_DIV8; + BUSY_WAIT(1000); + uint16_t div8_val = COUNTERS[2].value; + + /* Sysclock mode */ + COUNTERS[2].target = 0xFFFF; + COUNTERS[2].mode = 0; + BUSY_WAIT(1000); + uint16_t sys_val = COUNTERS[2].value; + + /* Ratio should be close to 8 (allow 6-10 for measurement jitter) */ + int ratio = sys_val / (div8_val ? div8_val : 1); + cester_assert_cmp(ratio, >=, 6); + cester_assert_cmp(ratio, <=, 10); +) + +/* ================================================================= + * Mode write resets counter to 0. + * ================================================================= */ +CESTER_TEST(timerModeWriteResetsCounter, timer_tests, + /* Let counter run */ + COUNTERS[2].mode = 0; + COUNTERS[2].target = 0xFFFF; + BUSY_WAIT(5000); + + uint16_t before = COUNTERS[2].value; + cester_assert_cmp((int)before, >, 0); + + /* Write mode - should reset to 0 */ + COUNTERS[2].mode = 0; + int after = COUNTERS[2].value; + + cester_assert_equal(after, 0); +) + +/* ================================================================= + * Status bits: hit-target (bit 11) set on target, cleared on read. + * ================================================================= */ +CESTER_TEST(timerHitTargetFlagSetAndCleared, timer_tests, + COUNTERS[2].target = 0x0010; + COUNTERS[2].mode = TM_RESET_TARGET; + BUSY_WAIT(500); + + uint16_t mode1 = COUNTERS[2].mode; + uint16_t mode2 = COUNTERS[2].mode; + + int flag1 = (mode1 & TM_HIT_TARGET) ? 1 : 0; + int flag2 = (mode2 & TM_HIT_TARGET) ? 1 : 0; + /* First read: bit 11 should be set */ + cester_assert_equal(flag1, 1); + /* Second read: bit 11 should be cleared */ + cester_assert_equal(flag2, 0); +) + +/* ================================================================= + * Status bits: hit-overflow (bit 12) set on 0xFFFF, cleared on read. + * ================================================================= */ +CESTER_TEST(timerHitOverflowFlagSetAndCleared, timer_tests, + COUNTERS[2].target = 0xFFFF; + COUNTERS[2].mode = 0; /* Free run */ + /* At sysclock ~33MHz, 0xFFFF counts = ~2ms */ + BUSY_WAIT(100000); + + uint16_t mode1 = COUNTERS[2].mode; + uint16_t mode2 = COUNTERS[2].mode; + + /* First read: bit 12 should be set */ + cester_assert_cmp((int)(mode1 & TM_HIT_OVERFLOW), !=, 0); + /* Second read: bit 12 should be cleared */ + cester_assert_equal((int)(mode2 & TM_HIT_OVERFLOW), 0); +) + +/* ================================================================= + * IRQ request flag (bit 10): pulse mode vs toggle mode. + * Pulse mode (bit7=0): bit 10 stays 1 after IRQ. + * Toggle mode (bit7=1): bit 10 toggles, may read as 0. + * ================================================================= */ +CESTER_TEST(timerIrqPulseModeBit10, timer_tests, + COUNTERS[2].target = 0x0080; + COUNTERS[2].mode = TM_RESET_TARGET | TM_IRQ_TARGET | TM_IRQ_REPEAT; + BUSY_WAIT(1000); + + uint16_t mode = COUNTERS[2].mode; + /* Pulse mode: bit 10 should be 1 */ + cester_assert_cmp((int)(mode & TM_IRQ_REQUEST), !=, 0); +) + +CESTER_TEST(timerIrqToggleModeBit10, timer_tests, + /* In toggle mode, bit 10 alternates on each IRQ. + * Read mode twice with different target counts between reads + * to verify that bit 10 actually changes state. */ + COUNTERS[2].target = 0x0010; + COUNTERS[2].mode = TM_RESET_TARGET | TM_IRQ_TARGET | TM_IRQ_REPEAT | TM_IRQ_TOGGLE; + BUSY_WAIT(200); + + /* Sample bit 10 multiple times to detect toggling */ + int saw_zero = 0; + int saw_one = 0; + for (int i = 0; i < 50; i++) { + /* Re-read mode (which clears status bits but bit 10 reflects toggle state) */ + uint16_t mode = COUNTERS[2].mode; + if (mode & TM_IRQ_REQUEST) saw_one = 1; + else saw_zero = 1; + BUSY_WAIT(10); + } + + /* In toggle mode, we should see both states */ + cester_assert_equal(saw_zero, 1); + cester_assert_equal(saw_one, 1); +) + +/* ================================================================= + * Timer 2 sync modes (bit 0): + * Modes 0 and 3 = stop counter + * Modes 1 and 2 = free run + * ================================================================= */ +CESTER_TEST(timerRc2SyncMode0Stop, timer_tests, + COUNTERS[2].target = 0xFFFF; + COUNTERS[2].mode = TM_SYNC_EN | TM_SYNC_MODE(0); + BUSY_WAIT(5000); + uint16_t v1 = COUNTERS[2].value; + BUSY_WAIT(5000); + uint16_t v2 = COUNTERS[2].value; + + cester_assert_equal((int)(uint16_t)(v2 - v1), 0); +) + +CESTER_TEST(timerRc2SyncMode1Free, timer_tests, + COUNTERS[2].target = 0xFFFF; + COUNTERS[2].mode = TM_SYNC_EN | TM_SYNC_MODE(1); + BUSY_WAIT(5000); + uint16_t v1 = COUNTERS[2].value; + BUSY_WAIT(5000); + uint16_t v2 = COUNTERS[2].value; + + cester_assert_cmp((int)(uint16_t)(v2 - v1), >, 0); +) + +CESTER_TEST(timerRc2SyncMode2Free, timer_tests, + COUNTERS[2].target = 0xFFFF; + COUNTERS[2].mode = TM_SYNC_EN | TM_SYNC_MODE(2); + BUSY_WAIT(5000); + uint16_t v1 = COUNTERS[2].value; + BUSY_WAIT(5000); + uint16_t v2 = COUNTERS[2].value; + + cester_assert_cmp((int)(uint16_t)(v2 - v1), >, 0); +) + +CESTER_TEST(timerRc2SyncMode3Stop, timer_tests, + COUNTERS[2].target = 0xFFFF; + COUNTERS[2].mode = TM_SYNC_EN | TM_SYNC_MODE(3); + BUSY_WAIT(5000); + uint16_t v1 = COUNTERS[2].value; + BUSY_WAIT(5000); + uint16_t v2 = COUNTERS[2].value; + + cester_assert_equal((int)(uint16_t)(v2 - v1), 0); +) + +/* ================================================================= + * Counter 0 gate modes (Hblank-synced). + * Unimplemented in Redux - use CESTER_MAYBE_TEST. + * ================================================================= */ + +/* Gate mode 0: pause during Hblank. + * Counter should advance slower than free run. */ +CESTER_TEST(timerC0GateMode0PauseDuringHblank, timer_tests, + COUNTERS[0].target = 0xFFFF; + + /* Measure gated first, then free run, to avoid ordering bias */ + COUNTERS[0].mode = TM_SYNC_EN | TM_SYNC_MODE(0); + BUSY_WAIT(20000); + uint16_t gated = COUNTERS[0].value; + + COUNTERS[0].mode = 0; + BUSY_WAIT(20000); + uint16_t free_val = COUNTERS[0].value; + + /* Gated value should be less than free run (paused during Hblank) */ + cester_assert_cmp((int)gated, <, (int)free_val); +) + +/* Gate mode 1: reset at Hblank. + * Counter keeps resetting, should show small values. */ +CESTER_TEST(timerC0GateMode1ResetAtHblank, timer_tests, + COUNTERS[0].target = 0xFFFF; + COUNTERS[0].mode = TM_SYNC_EN | TM_SYNC_MODE(1); + BUSY_WAIT(50000); + uint16_t val = COUNTERS[0].value; + + /* Value should be small - resets every scanline (~2130 sysclocks for NTSC) */ + cester_assert_cmp((int)val, <, 0x2000); +) + +/* Gate mode 2: reset at Hblank + pause outside. + * Counter only runs during Hblank and resets each time. */ +CESTER_TEST(timerC0GateMode2ResetPauseOutside, timer_tests, + COUNTERS[0].target = 0xFFFF; + COUNTERS[0].mode = TM_SYNC_EN | TM_SYNC_MODE(2); + BUSY_WAIT(50000); + uint16_t val = COUNTERS[0].value; + + /* Should be small - only counts during Hblank period itself. + * Hblank is ~200 sysclocks per scanline, so over many scanlines + * this accumulates but stays much smaller than free run (~45000). */ + cester_assert_cmp((int)val, <, 0x2000); +) + +/* Gate mode 3: pause until first Hblank, then free run. + * After the initial pause, should count at full speed. */ +CESTER_TEST(timerC0GateMode3FreeAfterHblank, timer_tests, + COUNTERS[0].target = 0xFFFF; + + COUNTERS[0].mode = 0; + BUSY_WAIT(50000); + uint16_t free_val = COUNTERS[0].value; + + COUNTERS[0].mode = TM_SYNC_EN | TM_SYNC_MODE(3); + BUSY_WAIT(50000); + uint16_t gated = COUNTERS[0].value; + + /* After first Hblank, should be close to free run */ + int diff = (int)free_val - (int)gated; + if (diff < 0) diff = -diff; + cester_assert_cmp(diff, <, 0x2000); +) + +/* ================================================================= + * PE2 scenario: Timer 2, sysclock/8, count to target, IRQ repeat. + * This is the exact configuration that triggers the PE2 jitter hack + * in the emulator (JITTER_FLAGS = Rc2OneEighthClock | RcIrqRegenerate | RcCountToTarget). + * Verify counter value is proportional to elapsed time. + * ================================================================= */ +CESTER_TEST(timerPE2Scenario, timer_tests, + /* Set up Timer 2 exactly as PE2 does */ + COUNTERS[2].target = 0x1000; + COUNTERS[2].mode = TM_CLK_DIV8 | TM_IRQ_REPEAT | TM_RESET_TARGET | TM_IRQ_TARGET; + + /* Wait a known amount, then read */ + BUSY_WAIT(5000); + uint16_t count = COUNTERS[2].value; + + /* Counter should be running and have a reasonable value. + * At sysclock/8, 5000 loop iterations at ~4 cycles each = ~20000 cycles, + * divided by 8 = ~2500 counts. Allow wide tolerance since loop timing + * varies with compiler optimization and cache state. */ + cester_assert_cmp((int)count, >, 100); + cester_assert_cmp((int)count, <, 0x1000); /* Should not have wrapped past target */ +) + +/* ================================================================= + * Dotclock rate measurement. + * Use Timer1 (hsync) to count 10 scanlines, read Timer0 (dotclock). + * The ratio gives dots per scanline which depends on GPU hres. + * + * Hardware verified on SCPH-5501 NTSC in 512px mode: + * 680 dots/scanline (expected 682 = 3413/5) + * ================================================================= */ +CESTER_TEST(timerDotclockRate, timer_tests, + /* Timer1: hsync clock, free run */ + COUNTERS[1].target = 0xFFFF; + COUNTERS[1].mode = TM_CLK_EXTERNAL; + + /* Timer0: dotclock, free run */ + COUNTERS[0].target = 0xFFFF; + COUNTERS[0].mode = TM_CLK_EXTERNAL; + + /* Reset both */ + COUNTERS[1].mode = TM_CLK_EXTERNAL; + COUNTERS[0].mode = TM_CLK_EXTERNAL; + + /* Wait for 10 scanlines */ + while (COUNTERS[1].value < 10) {} + + int dots = COUNTERS[0].value; + int lines = COUNTERS[1].value; + int dots_per_line = dots / lines; + + /* Dots per scanline should be reasonable for any resolution: + * Minimum: 256px mode = 341 dots/line (3413/10) + * Maximum: 640px mode = 853 dots/line (3413/4) + * Allow some tolerance for fractional rounding. */ + cester_assert_cmp(dots_per_line, >=, 330); + cester_assert_cmp(dots_per_line, <=, 860); +) From 3d0297ed7b2fae8714f2784c2272c82d5d4aa68b Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Wed, 8 Apr 2026 19:55:27 -0700 Subject: [PATCH 2/7] Add precise dotclock measurement tests for all resolutions Hardware-verified dots/scanline values on SCPH-5501 for every GPU horizontal resolution in both NTSC and PAL modes. Confirms psx-spx documentation: 320px PAL is the only mode where the fractional dot count rounds up (425.75 -> 426) rather than truncating. All other modes truncate as expected. Tests use CESTER_MAYBE_TEST (skipped on emulator, run on hardware). Signed-off-by: Nicolas 'Pixel' Noble --- src/mips/tests/timers/timers.c | 129 +++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/src/mips/tests/timers/timers.c b/src/mips/tests/timers/timers.c index 4d764652d..34a26e544 100644 --- a/src/mips/tests/timers/timers.c +++ b/src/mips/tests/timers/timers.c @@ -35,6 +35,7 @@ SOFTWARE. #endif #include "common/hardware/counters.h" +#include "common/hardware/gpu.h" #include "common/hardware/hwregs.h" #include "common/syscalls/syscalls.h" @@ -387,3 +388,131 @@ CESTER_TEST(timerDotclockRate, timer_tests, cester_assert_cmp(dots_per_line, >=, 330); cester_assert_cmp(dots_per_line, <=, 860); ) + +/* ================================================================= + * Precise dotclock measurements per resolution. + * Measures dots per N scanlines at each GPU horizontal resolution + * in both NTSC and PAL modes. Uses 100 scanlines for precision. + * + * Hardware verified on SCPH-5501, 2026-04-08: + * 256px: NTSC 341, PAL 340 + * 320px: NTSC 426, PAL 426 (rounds UP from 425.75) + * 512px: NTSC 682, PAL 681 + * 640px: NTSC 853, PAL 851 + * 368px: NTSC 487, PAL 486 + * ================================================================= */ + +CESTER_BODY( +static int measureDotsPerLine(void) { + int scanlines = 100; + + /* Timer1: hsync clock, free run */ + COUNTERS[1].target = 0xFFFF; + COUNTERS[1].mode = TM_CLK_EXTERNAL; + + /* Timer0: dotclock, free run */ + COUNTERS[0].target = 0xFFFF; + COUNTERS[0].mode = TM_CLK_EXTERNAL; + + /* Reset both by re-writing mode */ + COUNTERS[1].mode = TM_CLK_EXTERNAL; + COUNTERS[0].mode = TM_CLK_EXTERNAL; + + /* Wait for exactly N scanlines */ + while (COUNTERS[1].value < scanlines) {} + + int dots = COUNTERS[0].value; + int lines = COUNTERS[1].value; + return dots / lines; +} +) + +/* 256px NTSC: 3413/10 = 341.3 -> 341 */ +CESTER_MAYBE_TEST(timerDotclock256NTSC, timer_tests, + struct DisplayModeConfig cfg = { HR_256, VR_240, VM_NTSC, CD_15BITS, VI_OFF, HRE_NORMAL }; + setDisplayMode(&cfg); + BUSY_WAIT(5000); + int dpl = measureDotsPerLine(); + cester_assert_equal(dpl, 341); +) + +/* 256px PAL: 3406/10 = 340.6 -> 340 */ +CESTER_MAYBE_TEST(timerDotclock256PAL, timer_tests, + struct DisplayModeConfig cfg = { HR_256, VR_240, VM_PAL, CD_15BITS, VI_OFF, HRE_NORMAL }; + setDisplayMode(&cfg); + BUSY_WAIT(5000); + int dpl = measureDotsPerLine(); + cester_assert_equal(dpl, 340); +) + +/* 320px NTSC: 3413/8 = 426.625 -> 426 */ +CESTER_MAYBE_TEST(timerDotclock320NTSC, timer_tests, + struct DisplayModeConfig cfg = { HR_320, VR_240, VM_NTSC, CD_15BITS, VI_OFF, HRE_NORMAL }; + setDisplayMode(&cfg); + BUSY_WAIT(5000); + int dpl = measureDotsPerLine(); + cester_assert_equal(dpl, 426); +) + +/* 320px PAL: 3406/8 = 425.75 -> 426 (only mode that rounds up) */ +CESTER_MAYBE_TEST(timerDotclock320PAL, timer_tests, + struct DisplayModeConfig cfg = { HR_320, VR_240, VM_PAL, CD_15BITS, VI_OFF, HRE_NORMAL }; + setDisplayMode(&cfg); + BUSY_WAIT(5000); + int dpl = measureDotsPerLine(); + cester_assert_equal(dpl, 426); +) + +/* 512px NTSC: 3413/5 = 682.6 -> 682 */ +CESTER_MAYBE_TEST(timerDotclock512NTSC, timer_tests, + struct DisplayModeConfig cfg = { HR_512, VR_240, VM_NTSC, CD_15BITS, VI_OFF, HRE_NORMAL }; + setDisplayMode(&cfg); + BUSY_WAIT(5000); + int dpl = measureDotsPerLine(); + cester_assert_equal(dpl, 682); +) + +/* 512px PAL: 3406/5 = 681.2 -> 681 */ +CESTER_MAYBE_TEST(timerDotclock512PAL, timer_tests, + struct DisplayModeConfig cfg = { HR_512, VR_240, VM_PAL, CD_15BITS, VI_OFF, HRE_NORMAL }; + setDisplayMode(&cfg); + BUSY_WAIT(5000); + int dpl = measureDotsPerLine(); + cester_assert_equal(dpl, 681); +) + +/* 640px NTSC: 3413/4 = 853.25 -> 853 */ +CESTER_MAYBE_TEST(timerDotclock640NTSC, timer_tests, + struct DisplayModeConfig cfg = { HR_640, VR_240, VM_NTSC, CD_15BITS, VI_OFF, HRE_NORMAL }; + setDisplayMode(&cfg); + BUSY_WAIT(5000); + int dpl = measureDotsPerLine(); + cester_assert_equal(dpl, 853); +) + +/* 640px PAL: 3406/4 = 851.5 -> 851 */ +CESTER_MAYBE_TEST(timerDotclock640PAL, timer_tests, + struct DisplayModeConfig cfg = { HR_640, VR_240, VM_PAL, CD_15BITS, VI_OFF, HRE_NORMAL }; + setDisplayMode(&cfg); + BUSY_WAIT(5000); + int dpl = measureDotsPerLine(); + cester_assert_equal(dpl, 851); +) + +/* 368px NTSC: 3413/7 = 487.57 -> 487 */ +CESTER_MAYBE_TEST(timerDotclock368NTSC, timer_tests, + struct DisplayModeConfig cfg = { HR_256, VR_240, VM_NTSC, CD_15BITS, VI_OFF, HRE_368 }; + setDisplayMode(&cfg); + BUSY_WAIT(5000); + int dpl = measureDotsPerLine(); + cester_assert_equal(dpl, 487); +) + +/* 368px PAL: 3406/7 = 486.57 -> 486 */ +CESTER_MAYBE_TEST(timerDotclock368PAL, timer_tests, + struct DisplayModeConfig cfg = { HR_256, VR_240, VM_PAL, CD_15BITS, VI_OFF, HRE_368 }; + setDisplayMode(&cfg); + BUSY_WAIT(5000); + int dpl = measureDotsPerLine(); + cester_assert_equal(dpl, 486); +) From 515affa2ee16dcb05996a87b287f89a275dcb922 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Wed, 8 Apr 2026 21:35:19 -0700 Subject: [PATCH 3/7] Harden timer test suite for reliable hardware execution Fix BUSY_WAIT to use compiler barrier instead of volatile local, move timer mode constants to counters.h API, replace broken cester equality assertions with typed variants, add icache warmup passes for cycle-sensitive tests, disable interrupts during test execution, and fix dotclock measurement overflow at high resolutions. Signed-off-by: Nicolas 'Pixel' Noble --- src/mips/common/hardware/counters.h | 16 +++ src/mips/tests/timers/timers.c | 180 +++++++++++++++++----------- 2 files changed, 127 insertions(+), 69 deletions(-) diff --git a/src/mips/common/hardware/counters.h b/src/mips/common/hardware/counters.h index c3d610487..2efd392f7 100644 --- a/src/mips/common/hardware/counters.h +++ b/src/mips/common/hardware/counters.h @@ -38,3 +38,19 @@ struct Counter { }; #define COUNTERS ((volatile struct Counter *)0xbf801100) + +enum { + TM_SYNC_EN = 0x0001, + TM_RESET_TARGET = 0x0008, + TM_IRQ_TARGET = 0x0010, + TM_IRQ_OVERFLOW = 0x0020, + TM_IRQ_REPEAT = 0x0040, + TM_IRQ_TOGGLE = 0x0080, + TM_CLK_EXTERNAL = 0x0100, + TM_CLK_DIV8 = 0x0200, + TM_IRQ_REQUEST = 0x0400, + TM_HIT_TARGET = 0x0800, + TM_HIT_OVERFLOW = 0x1000, +}; + +#define TM_SYNC_MODE(n) (((n) & 3) << 1) diff --git a/src/mips/tests/timers/timers.c b/src/mips/tests/timers/timers.c index 34a26e544..010f0a182 100644 --- a/src/mips/tests/timers/timers.c +++ b/src/mips/tests/timers/timers.c @@ -48,21 +48,28 @@ SOFTWARE. // clang-format off -/* Timer mode bits */ -#define TM_SYNC_EN 0x0001 -#define TM_SYNC_MODE(n) (((n) & 3) << 1) -#define TM_RESET_TARGET 0x0008 -#define TM_IRQ_TARGET 0x0010 -#define TM_IRQ_OVERFLOW 0x0020 -#define TM_IRQ_REPEAT 0x0040 -#define TM_IRQ_TOGGLE 0x0080 -#define TM_CLK_EXTERNAL 0x0100 -#define TM_CLK_DIV8 0x0200 -#define TM_IRQ_REQUEST 0x0400 -#define TM_HIT_TARGET 0x0800 -#define TM_HIT_OVERFLOW 0x1000 - -#define BUSY_WAIT(n) do { volatile int _bw = (n); while (_bw-- > 0) __asm__ volatile("nop"); } while(0) +#define BUSY_WAIT(n) do { for (int _bw = (n); _bw > 0; _bw--) __asm__ volatile(""); } while(0) + +CESTER_BODY( +static int s_interruptsWereEnabled; +) + +CESTER_BEFORE_ALL(timer_tests, + s_interruptsWereEnabled = enterCriticalSection(); + IMASK = 0; + IREG = 0; +) + +CESTER_AFTER_ALL(timer_tests, + if (s_interruptsWereEnabled) leaveCriticalSection(); +) + +CESTER_BODY( +static void waitVSync(void) { + while (!(GPU_STATUS & 0x80000000)) __asm__ volatile(""); + while (GPU_STATUS & 0x80000000) __asm__ volatile(""); +} +) /* ================================================================= * Target reset: counter reaches target value, then resets. @@ -89,7 +96,7 @@ CESTER_TEST(timerTargetResetNoOverflow, timer_tests, uint16_t mode = COUNTERS[2].mode; /* Overflow flag should NOT be set - counter resets at target */ - cester_assert_equal((int)(mode & TM_HIT_OVERFLOW), 0); + cester_assert_int_eq(0, (int)(mode & TM_HIT_OVERFLOW)); ) /* ================================================================= @@ -122,10 +129,18 @@ CESTER_TEST(timerSysclockDiv8Ratio, timer_tests, * Mode write resets counter to 0. * ================================================================= */ CESTER_TEST(timerModeWriteResetsCounter, timer_tests, - /* Let counter run */ + /* Warmup pass to prime icache */ + COUNTERS[2].mode = 0; + COUNTERS[2].target = 0xFFFF; + BUSY_WAIT(50000); + (void)COUNTERS[2].value; + COUNTERS[2].mode = 0; + (void)COUNTERS[2].value; + + /* Real measurement */ COUNTERS[2].mode = 0; COUNTERS[2].target = 0xFFFF; - BUSY_WAIT(5000); + BUSY_WAIT(50000); uint16_t before = COUNTERS[2].value; cester_assert_cmp((int)before, >, 0); @@ -134,16 +149,25 @@ CESTER_TEST(timerModeWriteResetsCounter, timer_tests, COUNTERS[2].mode = 0; int after = COUNTERS[2].value; - cester_assert_equal(after, 0); + /* Counter restarts immediately after reset, so a few ticks + * may elapse before the read. Allow small tolerance. */ + cester_assert_cmp(after, <, 10); ) /* ================================================================= * Status bits: hit-target (bit 11) set on target, cleared on read. * ================================================================= */ CESTER_TEST(timerHitTargetFlagSetAndCleared, timer_tests, + /* Warmup pass to prime icache */ COUNTERS[2].target = 0x0010; COUNTERS[2].mode = TM_RESET_TARGET; BUSY_WAIT(500); + (void)COUNTERS[2].mode; + (void)COUNTERS[2].mode; + + /* Real measurement */ + COUNTERS[2].mode = TM_RESET_TARGET; + BUSY_WAIT(500); uint16_t mode1 = COUNTERS[2].mode; uint16_t mode2 = COUNTERS[2].mode; @@ -151,9 +175,9 @@ CESTER_TEST(timerHitTargetFlagSetAndCleared, timer_tests, int flag1 = (mode1 & TM_HIT_TARGET) ? 1 : 0; int flag2 = (mode2 & TM_HIT_TARGET) ? 1 : 0; /* First read: bit 11 should be set */ - cester_assert_equal(flag1, 1); + cester_assert_int_eq(1, flag1); /* Second read: bit 11 should be cleared */ - cester_assert_equal(flag2, 0); + cester_assert_int_eq(0, flag2); ) /* ================================================================= @@ -171,7 +195,7 @@ CESTER_TEST(timerHitOverflowFlagSetAndCleared, timer_tests, /* First read: bit 12 should be set */ cester_assert_cmp((int)(mode1 & TM_HIT_OVERFLOW), !=, 0); /* Second read: bit 12 should be cleared */ - cester_assert_equal((int)(mode2 & TM_HIT_OVERFLOW), 0); + cester_assert_int_eq(0, (int)(mode2 & TM_HIT_OVERFLOW)); ) /* ================================================================= @@ -209,8 +233,8 @@ CESTER_TEST(timerIrqToggleModeBit10, timer_tests, } /* In toggle mode, we should see both states */ - cester_assert_equal(saw_zero, 1); - cester_assert_equal(saw_one, 1); + cester_assert_int_eq(1, saw_zero); + cester_assert_int_eq(1, saw_one); ) /* ================================================================= @@ -221,20 +245,20 @@ CESTER_TEST(timerIrqToggleModeBit10, timer_tests, CESTER_TEST(timerRc2SyncMode0Stop, timer_tests, COUNTERS[2].target = 0xFFFF; COUNTERS[2].mode = TM_SYNC_EN | TM_SYNC_MODE(0); - BUSY_WAIT(5000); + BUSY_WAIT(50000); uint16_t v1 = COUNTERS[2].value; - BUSY_WAIT(5000); + BUSY_WAIT(50000); uint16_t v2 = COUNTERS[2].value; - cester_assert_equal((int)(uint16_t)(v2 - v1), 0); + cester_assert_int_eq(0, (int)(uint16_t)(v2 - v1)); ) CESTER_TEST(timerRc2SyncMode1Free, timer_tests, COUNTERS[2].target = 0xFFFF; COUNTERS[2].mode = TM_SYNC_EN | TM_SYNC_MODE(1); - BUSY_WAIT(5000); + BUSY_WAIT(50000); uint16_t v1 = COUNTERS[2].value; - BUSY_WAIT(5000); + BUSY_WAIT(50000); uint16_t v2 = COUNTERS[2].value; cester_assert_cmp((int)(uint16_t)(v2 - v1), >, 0); @@ -243,9 +267,9 @@ CESTER_TEST(timerRc2SyncMode1Free, timer_tests, CESTER_TEST(timerRc2SyncMode2Free, timer_tests, COUNTERS[2].target = 0xFFFF; COUNTERS[2].mode = TM_SYNC_EN | TM_SYNC_MODE(2); - BUSY_WAIT(5000); + BUSY_WAIT(50000); uint16_t v1 = COUNTERS[2].value; - BUSY_WAIT(5000); + BUSY_WAIT(50000); uint16_t v2 = COUNTERS[2].value; cester_assert_cmp((int)(uint16_t)(v2 - v1), >, 0); @@ -254,12 +278,12 @@ CESTER_TEST(timerRc2SyncMode2Free, timer_tests, CESTER_TEST(timerRc2SyncMode3Stop, timer_tests, COUNTERS[2].target = 0xFFFF; COUNTERS[2].mode = TM_SYNC_EN | TM_SYNC_MODE(3); - BUSY_WAIT(5000); + BUSY_WAIT(50000); uint16_t v1 = COUNTERS[2].value; - BUSY_WAIT(5000); + BUSY_WAIT(50000); uint16_t v2 = COUNTERS[2].value; - cester_assert_equal((int)(uint16_t)(v2 - v1), 0); + cester_assert_int_eq(0, (int)(uint16_t)(v2 - v1)); ) /* ================================================================= @@ -342,7 +366,7 @@ CESTER_TEST(timerPE2Scenario, timer_tests, COUNTERS[2].mode = TM_CLK_DIV8 | TM_IRQ_REPEAT | TM_RESET_TARGET | TM_IRQ_TARGET; /* Wait a known amount, then read */ - BUSY_WAIT(5000); + BUSY_WAIT(50000); uint16_t count = COUNTERS[2].value; /* Counter should be running and have a reasonable value. @@ -403,9 +427,7 @@ CESTER_TEST(timerDotclockRate, timer_tests, * ================================================================= */ CESTER_BODY( -static int measureDotsPerLine(void) { - int scanlines = 100; - +static int measureDotsPerLine(int scanlines) { /* Timer1: hsync clock, free run */ COUNTERS[1].target = 0xFFFF; COUNTERS[1].mode = TM_CLK_EXTERNAL; @@ -423,7 +445,7 @@ static int measureDotsPerLine(void) { int dots = COUNTERS[0].value; int lines = COUNTERS[1].value; - return dots / lines; + return (dots + lines / 2) / lines; } ) @@ -431,88 +453,108 @@ static int measureDotsPerLine(void) { CESTER_MAYBE_TEST(timerDotclock256NTSC, timer_tests, struct DisplayModeConfig cfg = { HR_256, VR_240, VM_NTSC, CD_15BITS, VI_OFF, HRE_NORMAL }; setDisplayMode(&cfg); - BUSY_WAIT(5000); - int dpl = measureDotsPerLine(); - cester_assert_equal(dpl, 341); + waitVSync(); + waitVSync(); + measureDotsPerLine(50); + int dpl = measureDotsPerLine(50); + cester_assert_cmp(dpl, >=, 341 - 1); cester_assert_cmp(dpl, <=, 341 + 1); ) /* 256px PAL: 3406/10 = 340.6 -> 340 */ CESTER_MAYBE_TEST(timerDotclock256PAL, timer_tests, struct DisplayModeConfig cfg = { HR_256, VR_240, VM_PAL, CD_15BITS, VI_OFF, HRE_NORMAL }; setDisplayMode(&cfg); - BUSY_WAIT(5000); - int dpl = measureDotsPerLine(); - cester_assert_equal(dpl, 340); + waitVSync(); + waitVSync(); + measureDotsPerLine(50); + int dpl = measureDotsPerLine(50); + cester_assert_cmp(dpl, >=, 340 - 1); cester_assert_cmp(dpl, <=, 340 + 1); ) /* 320px NTSC: 3413/8 = 426.625 -> 426 */ CESTER_MAYBE_TEST(timerDotclock320NTSC, timer_tests, struct DisplayModeConfig cfg = { HR_320, VR_240, VM_NTSC, CD_15BITS, VI_OFF, HRE_NORMAL }; setDisplayMode(&cfg); - BUSY_WAIT(5000); - int dpl = measureDotsPerLine(); - cester_assert_equal(dpl, 426); + waitVSync(); + waitVSync(); + measureDotsPerLine(50); + int dpl = measureDotsPerLine(50); + cester_assert_cmp(dpl, >=, 426 - 1); cester_assert_cmp(dpl, <=, 426 + 1); ) /* 320px PAL: 3406/8 = 425.75 -> 426 (only mode that rounds up) */ CESTER_MAYBE_TEST(timerDotclock320PAL, timer_tests, struct DisplayModeConfig cfg = { HR_320, VR_240, VM_PAL, CD_15BITS, VI_OFF, HRE_NORMAL }; setDisplayMode(&cfg); - BUSY_WAIT(5000); - int dpl = measureDotsPerLine(); - cester_assert_equal(dpl, 426); + waitVSync(); + waitVSync(); + measureDotsPerLine(50); + int dpl = measureDotsPerLine(50); + cester_assert_cmp(dpl, >=, 426 - 1); cester_assert_cmp(dpl, <=, 426 + 1); ) /* 512px NTSC: 3413/5 = 682.6 -> 682 */ CESTER_MAYBE_TEST(timerDotclock512NTSC, timer_tests, struct DisplayModeConfig cfg = { HR_512, VR_240, VM_NTSC, CD_15BITS, VI_OFF, HRE_NORMAL }; setDisplayMode(&cfg); - BUSY_WAIT(5000); - int dpl = measureDotsPerLine(); - cester_assert_equal(dpl, 682); + waitVSync(); + waitVSync(); + measureDotsPerLine(50); + int dpl = measureDotsPerLine(50); + cester_assert_cmp(dpl, >=, 682 - 1); cester_assert_cmp(dpl, <=, 682 + 1); ) /* 512px PAL: 3406/5 = 681.2 -> 681 */ CESTER_MAYBE_TEST(timerDotclock512PAL, timer_tests, struct DisplayModeConfig cfg = { HR_512, VR_240, VM_PAL, CD_15BITS, VI_OFF, HRE_NORMAL }; setDisplayMode(&cfg); - BUSY_WAIT(5000); - int dpl = measureDotsPerLine(); - cester_assert_equal(dpl, 681); + waitVSync(); + waitVSync(); + measureDotsPerLine(50); + int dpl = measureDotsPerLine(50); + cester_assert_cmp(dpl, >=, 681 - 1); cester_assert_cmp(dpl, <=, 681 + 1); ) /* 640px NTSC: 3413/4 = 853.25 -> 853 */ CESTER_MAYBE_TEST(timerDotclock640NTSC, timer_tests, struct DisplayModeConfig cfg = { HR_640, VR_240, VM_NTSC, CD_15BITS, VI_OFF, HRE_NORMAL }; setDisplayMode(&cfg); - BUSY_WAIT(5000); - int dpl = measureDotsPerLine(); - cester_assert_equal(dpl, 853); + waitVSync(); + waitVSync(); + measureDotsPerLine(50); + int dpl = measureDotsPerLine(50); + cester_assert_cmp(dpl, >=, 853 - 1); cester_assert_cmp(dpl, <=, 853 + 1); ) /* 640px PAL: 3406/4 = 851.5 -> 851 */ CESTER_MAYBE_TEST(timerDotclock640PAL, timer_tests, struct DisplayModeConfig cfg = { HR_640, VR_240, VM_PAL, CD_15BITS, VI_OFF, HRE_NORMAL }; setDisplayMode(&cfg); - BUSY_WAIT(5000); - int dpl = measureDotsPerLine(); - cester_assert_equal(dpl, 851); + waitVSync(); + waitVSync(); + measureDotsPerLine(50); + int dpl = measureDotsPerLine(50); + cester_assert_cmp(dpl, >=, 851 - 1); cester_assert_cmp(dpl, <=, 851 + 1); ) /* 368px NTSC: 3413/7 = 487.57 -> 487 */ CESTER_MAYBE_TEST(timerDotclock368NTSC, timer_tests, struct DisplayModeConfig cfg = { HR_256, VR_240, VM_NTSC, CD_15BITS, VI_OFF, HRE_368 }; setDisplayMode(&cfg); - BUSY_WAIT(5000); - int dpl = measureDotsPerLine(); - cester_assert_equal(dpl, 487); + waitVSync(); + waitVSync(); + measureDotsPerLine(50); + int dpl = measureDotsPerLine(50); + cester_assert_cmp(dpl, >=, 487 - 1); cester_assert_cmp(dpl, <=, 487 + 1); ) /* 368px PAL: 3406/7 = 486.57 -> 486 */ CESTER_MAYBE_TEST(timerDotclock368PAL, timer_tests, struct DisplayModeConfig cfg = { HR_256, VR_240, VM_PAL, CD_15BITS, VI_OFF, HRE_368 }; setDisplayMode(&cfg); - BUSY_WAIT(5000); - int dpl = measureDotsPerLine(); - cester_assert_equal(dpl, 486); + waitVSync(); + waitVSync(); + measureDotsPerLine(50); + int dpl = measureDotsPerLine(50); + cester_assert_cmp(dpl, >=, 486 - 1); cester_assert_cmp(dpl, <=, 486 + 1); ) From 4c860ebf21616b90088a79f72b586e53f38761b6 Mon Sep 17 00:00:00 2001 From: "Nicolas \"Pixel\" Noble" Date: Sun, 12 Apr 2026 22:27:36 -0700 Subject: [PATCH 4/7] Adding test. --- src/mips/tests/Makefile | 2 ++ tests/pcsxrunner/timers.cc | 35 +++++++++++++++++++ .../tests/pcsxrunner/pcsxrunner.vcxproj | 1 + .../pcsxrunner/pcsxrunner.vcxproj.filters | 3 ++ 4 files changed, 41 insertions(+) create mode 100644 tests/pcsxrunner/timers.cc diff --git a/src/mips/tests/Makefile b/src/mips/tests/Makefile index eb86927a2..83fd8656a 100644 --- a/src/mips/tests/Makefile +++ b/src/mips/tests/Makefile @@ -7,6 +7,7 @@ all: $(MAKE) -C memcpy all $(MAKE) -C memset all $(MAKE) -C pcdrv all + $(MAKE) -C timers all clean: $(MAKE) -C basic clean @@ -17,3 +18,4 @@ clean: $(MAKE) -C memcpy clean $(MAKE) -C memset clean $(MAKE) -C pcdrv clean + $(MAKE) -C timers clean diff --git a/tests/pcsxrunner/timers.cc b/tests/pcsxrunner/timers.cc new file mode 100644 index 000000000..280224b33 --- /dev/null +++ b/tests/pcsxrunner/timers.cc @@ -0,0 +1,35 @@ +/*************************************************************************** + * Copyright (C) 2026 PCSX-Redux authors * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. * + ***************************************************************************/ + +#include "gtest/gtest.h" +#include "main/main.h" + +TEST(Timers, Interpreter) { + MainInvoker invoker("-no-ui", "-run", "-bios", "src/mips/openbios/openbios.bin", "-testmode", "-interpreter", + "-luacov", "-loadexe", "src/mips/tests/timers/timers.ps-exe"); + int ret = invoker.invoke(); + EXPECT_EQ(ret, 0); +} + +TEST(Timers, Dynarec) { + MainInvoker invoker("-no-ui", "-run", "-bios", "src/mips/openbios/openbios.bin", "-testmode", "-dynarec", + "-luacov", "-loadexe", "src/mips/tests/timers/timers.ps-exe"); + int ret = invoker.invoke(); + EXPECT_EQ(ret, 0); +} diff --git a/vsprojects/tests/pcsxrunner/pcsxrunner.vcxproj b/vsprojects/tests/pcsxrunner/pcsxrunner.vcxproj index c140389a0..dd4fdef08 100644 --- a/vsprojects/tests/pcsxrunner/pcsxrunner.vcxproj +++ b/vsprojects/tests/pcsxrunner/pcsxrunner.vcxproj @@ -260,6 +260,7 @@ + diff --git a/vsprojects/tests/pcsxrunner/pcsxrunner.vcxproj.filters b/vsprojects/tests/pcsxrunner/pcsxrunner.vcxproj.filters index 1b6304fc8..10d27629b 100644 --- a/vsprojects/tests/pcsxrunner/pcsxrunner.vcxproj.filters +++ b/vsprojects/tests/pcsxrunner/pcsxrunner.vcxproj.filters @@ -45,6 +45,9 @@ Source Files + + Source Files + From 76df40ae0884ededcd4b6f323e07c9749e750db9 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Mon, 13 Apr 2026 20:00:47 -0700 Subject: [PATCH 5/7] Skip unimplemented gate mode tests on emulator Gate modes 0 (pause during blank) and 2 (pause outside blank) are not implemented in the emulator. Mark those tests as CESTER_MAYBE_TEST so they're skipped when PCSX_TESTS=1 (CI) but still run on hardware. Signed-off-by: Nicolas 'Pixel' Noble --- src/mips/tests/timers/timers.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/mips/tests/timers/timers.c b/src/mips/tests/timers/timers.c index 010f0a182..755451319 100644 --- a/src/mips/tests/timers/timers.c +++ b/src/mips/tests/timers/timers.c @@ -288,12 +288,13 @@ CESTER_TEST(timerRc2SyncMode3Stop, timer_tests, /* ================================================================= * Counter 0 gate modes (Hblank-synced). - * Unimplemented in Redux - use CESTER_MAYBE_TEST. + * Modes 1 and 3 are implemented. Modes 0 and 2 (pause logic) are + * not yet implemented - use CESTER_MAYBE_TEST to skip on emulator. * ================================================================= */ /* Gate mode 0: pause during Hblank. * Counter should advance slower than free run. */ -CESTER_TEST(timerC0GateMode0PauseDuringHblank, timer_tests, +CESTER_MAYBE_TEST(timerC0GateMode0PauseDuringHblank, timer_tests, COUNTERS[0].target = 0xFFFF; /* Measure gated first, then free run, to avoid ordering bias */ @@ -323,7 +324,7 @@ CESTER_TEST(timerC0GateMode1ResetAtHblank, timer_tests, /* Gate mode 2: reset at Hblank + pause outside. * Counter only runs during Hblank and resets each time. */ -CESTER_TEST(timerC0GateMode2ResetPauseOutside, timer_tests, +CESTER_MAYBE_TEST(timerC0GateMode2ResetPauseOutside, timer_tests, COUNTERS[0].target = 0xFFFF; COUNTERS[0].mode = TM_SYNC_EN | TM_SYNC_MODE(2); BUSY_WAIT(50000); From 1a963643a9729ab7084dd088fe9213fc22f5c061 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Mon, 13 Apr 2026 20:30:41 -0700 Subject: [PATCH 6/7] Fix counter scheduling when target equals max value When target is 0xFFFF, the CountToTarget and CountToOverflow events coincide at the same counter value. Skip the redundant target phase and schedule CountToOverflow directly, so the overflow flag is visible on the first mode register read without requiring two update() calls. Restore small target values for tests that only check flag presence, keeping the larger target only for the back-to-back read test. Signed-off-by: Nicolas 'Pixel' Noble --- src/core/psxcounters.cc | 8 +++++++- src/mips/tests/timers/timers.c | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/core/psxcounters.cc b/src/core/psxcounters.cc index bd3e526be..f53ca2b05 100644 --- a/src/core/psxcounters.cc +++ b/src/core/psxcounters.cc @@ -48,7 +48,13 @@ inline void PCSX::Counters::writeCounterInternal(uint32_t index, uint32_t value) // The comparison boundary needs more precise measurement to determine if // reset happens on the same cycle as reaching target or the next cycle. // TODO: determine exact reset timing with cycle-accurate measurement. - if (value < m_rcnts[index].target) { + // Schedule next event: target hit first (for flag/IRQ), then overflow. + // If RcCountToTarget is not set, the counter doesn't reset at target, + // but bit 11 (RcCountEqTarget) is still set when the counter passes + // the target value. However, if target == 0xFFFF, the target and + // overflow events coincide - skip straight to overflow to avoid + // redundant processing. + if (value < m_rcnts[index].target && m_rcnts[index].target != 0xffff) { m_rcnts[index].cycle = m_rcnts[index].target * m_rcnts[index].rate; m_rcnts[index].counterState = CountToTarget; } else { diff --git a/src/mips/tests/timers/timers.c b/src/mips/tests/timers/timers.c index 755451319..af94dd569 100644 --- a/src/mips/tests/timers/timers.c +++ b/src/mips/tests/timers/timers.c @@ -159,15 +159,15 @@ CESTER_TEST(timerModeWriteResetsCounter, timer_tests, * ================================================================= */ CESTER_TEST(timerHitTargetFlagSetAndCleared, timer_tests, /* Warmup pass to prime icache */ - COUNTERS[2].target = 0x0010; + COUNTERS[2].target = 0x1000; COUNTERS[2].mode = TM_RESET_TARGET; - BUSY_WAIT(500); + BUSY_WAIT(50000); (void)COUNTERS[2].mode; (void)COUNTERS[2].mode; /* Real measurement */ COUNTERS[2].mode = TM_RESET_TARGET; - BUSY_WAIT(500); + BUSY_WAIT(50000); uint16_t mode1 = COUNTERS[2].mode; uint16_t mode2 = COUNTERS[2].mode; From 7c04db58e86f5a1f6b865f7f86893d5b7f461f8c Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Tue, 14 Apr 2026 08:29:02 -0700 Subject: [PATCH 7/7] Set IRQ request flag on mode write Hardware sets bit 10 (IRQ request) to 1 when the mode register is written. The emulator was storing the raw written value, leaving bit 10 at 0. This caused the toggle mode test to fail because the XOR toggle started from 0 instead of 1. Signed-off-by: Nicolas 'Pixel' Noble --- src/core/psxcounters.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/psxcounters.cc b/src/core/psxcounters.cc index f53ca2b05..70ab9d65c 100644 --- a/src/core/psxcounters.cc +++ b/src/core/psxcounters.cc @@ -318,7 +318,7 @@ void PCSX::Counters::writeMode(uint32_t index, uint32_t value) { verboseLog(1, "[RCNT %i] writeMode: %x\n", index, value); update(); - m_rcnts[index].mode = value; + m_rcnts[index].mode = value | RcIrqRequest; // Hardware sets bit 10 on mode write m_rcnts[index].irqState = false; recalculateRate(index);