Files
psxrecomp/docs/CYCLE_TIMING_ARCH.md
Matthew Stanley 5d4d4bd30b phase1.0e-a: psx_cycle_count scaffold + cycle-timing arch doc
Scaffold for guest-cycle-driven peripheral timing per
docs/CYCLE_TIMING_ARCH.md. The counter is defined and exposed via
freeze_check, but psx_advance_cycles has no callers yet — counter
stays 0. Validation: zero behavior change.

Files:
- runtime/include/psx_cycles.h: extern uint64_t psx_cycle_count;
  void psx_advance_cycles(uint32_t cycles); uint64_t psx_get_cycle_count(void);
- runtime/src/psx_cycles.c: scaffold body; peripheral hooks empty
  (1.0e-b will add sio_advance, 1.0e-c will add gpu_advance, etc.)
- runtime/CMakeLists.txt: psx_cycles.c added to PSX_SHARED_SOURCES
- runtime/src/debug_server.c: freeze_check exposes psx_cycle_count
- docs/CYCLE_TIMING_ARCH.md: design doc — single cycle clock,
  generic per-block emit (no card-specific paths), MMIO-split codegen
  per explicit user direction, IRQ-pending-vs-CPU-entry separation,
  five-step migration plan (1.0e-a..e)

Validation against 0d15e2b baseline:
- t=20s/30s tx_writes match (1160/5480)
- 4x CROSS succeeds; card txn distribution unchanged (10 reach READ_DATA)
- "MC" bytes still flow at txn 0 bytes 10-11
- psx_cycle_count = 0 throughout (no callers in this slice)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:04:11 -07:00

12 KiB

Cycle-driven CPU/peripheral timing — architecture

Status: design proposal. No code committed against this doc yet.

Why

The runtime currently has no real notion of PSX wall-time. SIO byte readiness, ACK timing, IRQ delivery, and VBlank scheduling all infer "time" from proxies: MMIO access count, dispatch_count heuristics, host wall-clock, or function-boundary callbacks. Every memory-card debugging detour this branch has accumulated — burst drains, hard-cap defers, SELECT-lifetime owner guards, hybrid sync/async TX paths — has been a symptom of the same missing primitive: there is no psx_cycle_count.

Real PSX hardware advances peripherals on every CPU cycle. SIO TX_RDY clears for ~1088 cycles per byte regardless of whether the CPU is in an exception. ACK arrives ~170 cycles after byte completion. VBlank fires on the GPU's own cycle deadline. The CPU is one client of the bus, not the master of time.

Until the runtime models that, every fix to the symptom (pad leaks, abort_reselect, etc.) is structurally a guess.

Architectural target

1. Central PSX cycle clock

extern uint64_t psx_cycle_count;
  • Owned by a new psx_cycles.c / psx_cycles.h.
  • Monotonically increasing. Never decremented.
  • Advanced by psx_advance_cycles(int cycles) (signed int for symmetry with sio_tick(int)).
  • All peripherals derive their schedules from this clock.

2. Peripheral scheduler

void psx_advance_cycles(int cycles);

Single entry-point. Internally:

  1. psx_cycle_count += cycles
  2. Call sio_advance(cycles) — shifter/buffer/ACK timing
  3. Call gpu_advance(cycles) — VBlank deadline
  4. Call timers_advance(cycles) — root counters (later)
  5. Call cdrom_advance(cycles) — CD timing (later)

Each peripheral exposes its own *_advance(int cycles). The dispatcher holds no peripheral knowledge. Order matters only when one peripheral's advance can fire an IRQ that affects another's deadline computation — not the case for the initial slice.

No in_exception gating on hardware time. Real PSX SIO keeps shifting while the CPU is in the exception handler. Our peripheral advance must reflect that.

3. Interrupt correctness — separate "pending" from "entry"

Two distinct concepts:

  • IRQ source pending = i_stat & i_mask != 0. Set by peripherals any time their schedule fires. Independent of CPU mode.
  • CPU may enter exception = (SR & 0x401) == 0x401 AND not already in a kernel exception frame AND we're at a legal instruction boundary.

psx_check_interrupts(cpu) becomes:

void psx_check_interrupts(CPUState* cpu) {
    /* hardware time always advances */
    psx_advance_cycles(<block cycles since last check>);

    /* peripheral writes to i_stat already happened in *_advance() */

    /* CPU-side gate: are we at a legal entry point? */
    if (!cpu_can_take_interrupt(cpu)) return;
    if ((i_stat & i_mask) == 0) return;

    /* Enter exception */
    cpu_enter_exception(cpu);
}

cpu_can_take_interrupt(cpu) checks:

  • cpu->cop0[SR] & IEc (bit 0)
  • cpu->cop0[SR] & IM[2] (bit 10)
  • not currently inside a previously-entered exception (use in_exception ONLY as a re-entry guard against host-side recursion through psx_dispatch from inside the BIOS handler — not as a hardware-time gate)

The handler may now run for a long time without freezing peripheral schedules. SIO can fire its ACK IRQ while the CPU is mid-handler; the new IRQ pends in i_stat and is delivered on the next legal CPU entry point after the current exception returns.

4. SIO model — single bus, no device-specific paths

SIO0 has one shifter, one TX buffer, one ACK pipeline. Card and pad share it. Modeling:

struct sio_bus {
    int      shift_active;
    uint8_t  shift_byte;
    int      shift_remaining_cycles;     /* counts down each advance */
    int      tx_buffered;                /* per no$psx, single byte */
    uint8_t  tx_buffer;
    int      ack_remaining_cycles;       /* fires IRQ when reaches 0 */
    int      ack_pending;
};

sio_write(SIO_TX_DATA):

  • if !shift_active: load shifter, clear TX_RDY/TX_EMPTY
  • else if !tx_buffered: load buffer, clear TX_EMPTY (TX_RDY already 0)
  • else: byte dropped (per no$psx — only the FIRST write while busy buffers; subsequent writes ignored)

sio_advance(cycles):

  • if shift_active: shift_remaining -= cycles. On <= 0: run state machine on the shifted byte (sio_process_byte), schedule ACK, promote buffer if any, else restore TX_RDY.
  • if ack_pending: ack_remaining -= cycles. On <= 0: set i_stat |= 1<<7 (gated by SIO_CTRL.ACK_IRQ_EN).

sio_read(SIO_STAT):

  • Returns current shifter/buffer/ACK state. No tick on read.

Card and pad protocols both use this single mechanism. There is no "card busy → block pad" check anywhere. The natural timing of TX_RDY and TX_EMPTY enforces mutual exclusion via the existing BIOS pad-poll behavior — pad's TX writes during card-busy go to the buffer or are dropped; their mc_state mutation does not happen because the byte is held in the SIO buffer until the card's shift completes, at which point the buffered byte becomes the next byte processed against mc_state in its current state. If real hardware would have lost the pad poll's byte, our model loses it. The pad chain handler retries on next VBlank.

5. Recompiler integration — block-level cycle accounting

Each emitted basic block:

  • gets a precomputed block_cycles count (sum of per-instruction cycles for the block's MIPS instructions; integer multiplication and load-delay penalties accounted for)
  • emits psx_advance_cycles(<block_cycles>); before its psx_dispatch return

Block boundaries that contain MMIO reads — option A required per explicit user direction:

  • Split the block at every MMIO read. Cycles up to that point are advanced before the read so SIO_STAT (and analogous registers) show the elapsed guest time the BIOS expects.
  • The simpler "advance the whole block before the read" was rejected because it over-advances and may cause the BIOS to see a TX_RDY transition earlier than the real bytes have actually shifted.

For MMIO writes, advance the block's accumulated cycles before the write so peripherals see the same elapsed time the CPU sees.

Busy-wait loops (e.g., while (!(STAT & TX_RDY));) become tight loops of small basic blocks. Each loop iteration advances cycles. After ~1088 cycles total, the SIO shifter completes, TX_RDY restores, the loop exits. This is the architectural fix for the access-paced sio_tick problem.

6. VBlank — cycle-based deadline

static uint64_t next_vblank_deadline_cycles;

void gpu_advance(int cycles) {
    if (psx_cycle_count >= next_vblank_deadline_cycles) {
        next_vblank_deadline_cycles += NTSC_FRAME_CYCLES;  /* ~33,868,800 / 60 */
        i_stat |= (1 << IRQ_VBLANK);
        gpu_lcf ^= 1;
        gpu_vblank_callback();  /* SDL present + debug_server_poll */
    }
}

dispatch_count is removed from the VBlank path. SDL/TCP polling still happens on VBlank fire — the host-side cadence is determined by how fast the recompiled code runs, but that's correct: VBlank fires at the right PSX-visible cycle count regardless of host wall-clock.

7. Migration strategy

Vertical-slice approach. Each step independently testable, mergeable.

1.0e-a: cycle counter scaffold

  • Add psx_cycles.c/.h with psx_cycle_count and psx_advance_cycles(). Empty body except for the count update.
  • All peripherals' *_advance(cycles) declared but no-op stubs.
  • No recompiler integration yet.
  • Validation: zero behavior change; build clean; baseline preserved.

1.0e-b: SIO scheduler under cycle clock

  • Implement sio_advance(cycles) driven from psx_advance_cycles. SIO state vars (already on tree from 1.0c-v2) become live.
  • TX_DATA writes feed the shifter; sio_process_byte called from shift-complete; ACK fires from cycle countdown.
  • Critical: psx_advance_cycles is called from a SINGLE site initially — psx_check_interrupts — using a fixed estimate (e.g., BLOCK_CYCLES_AVG=8 per call). This isn't accurate per-block but proves the time-source plumbing works without recompiler changes.
  • Validation: pad polling at shell still works; card protocol behavior may change.

1.0e-c: VBlank cycle-based

  • gpu_advance fires VBlank from cycle deadline.
  • Remove dispatch_count from interrupts.c. Keep !card_active || progress_stale defer logic OUT — it becomes obsolete once timing is correct.
  • Validation: TCP still responsive; VBlank rate tracks PSX-visible cycle-time.

1.0e-d: per-block cycle accounting in recompiler

  • Recompiler emits psx_advance_cycles(<block_cycles>) per block.
  • Replace the fixed BLOCK_CYCLES_AVG from 1.0e-b with per-block.
  • Validation: accurate timing; SIO bytes shift in cycle-correct windows; card txns no longer receive pad TX during READ_DATA.

1.0e-e: card-read full-sector validation

  • Confirm card transactions reach 137 bytes.
  • [0x72F0] reaches 0x80.
  • mc_read_done > 0.
  • Beetle byte-trace cross-check.
  • Memory-card screen renders directory entries.

Open questions to resolve before 1.0e-a

  1. What's the average cycles-per-block for our recompiled BIOS? Need this for 1.0e-b. Pick the wrong number and pad polling rate is off, card-shift duration is wrong. Estimate: profile a few hundred blocks and pick a median.
  2. Where does cycle accounting LIVE in psx_check_interrupts? Before or after dispatch_count++? Should it replace it entirely in 1.0e-b or coexist for one revision?
  3. Does the dirty_ram_interp need cycle accounting? Probably yes — interpreted blocks should also advance the clock. Add a psx_advance_cycles(N) call at the end of each interpreted block.
  4. Should psx_advance_cycles be allowed to fire IRQs? Yes — that's the whole point. But the IRQ-pending bit is set in i_stat; CPU exception entry only happens at the next call to psx_check_interrupts, which checks SR/IM/in_exception. No recursive entry.
  5. VBlank during exception handler — do we still need a defer? No. Real hardware fires VBlank during handler too; the IRQ pends until the handler returns. With proper CPU-side gating, our model matches without any defer.

Hard rules during 1.0e implementation

  • No "drop the pad byte" hacks.
  • No card-specific paths.
  • No multi-slot TX FIFOs without no$psx evidence.
  • No dispatch_count-based VBlank.
  • No in_exception gating on hardware time advance.
  • No bypassing the chain dispatcher.
  • No edits to generated BIOS C.
  • No fake directory data.
  • All migrations behind SIO_MODEL_CYCLE_PACED (already on tree from 1.0a). Default flips to 1 only when the slice it covers is fully validated.

Vertical slice for first commit (1.0e-a)

Smallest possible. Just the scaffold, no behavior change:

  1. New file runtime/include/psx_cycles.h:

    #ifndef PSX_CYCLES_H
    #define PSX_CYCLES_H
    #include <stdint.h>
    #ifdef __cplusplus
    extern "C" {
    #endif
    extern uint64_t psx_cycle_count;
    void psx_advance_cycles(int cycles);
    uint64_t psx_get_cycle_count(void);
    #ifdef __cplusplus
    }
    #endif
    #endif
    
  2. New file runtime/src/psx_cycles.c:

    #include "psx_cycles.h"
    uint64_t psx_cycle_count = 0;
    
    void psx_advance_cycles(int cycles) {
        if (cycles <= 0) return;
        psx_cycle_count += (uint64_t)cycles;
        /* Phase 1.0e-a: peripheral hooks empty.
         * 1.0e-b will call sio_advance(cycles); etc. */
    }
    
    uint64_t psx_get_cycle_count(void) {
        return psx_cycle_count;
    }
    
  3. Add psx_cycles.c to runtime/CMakeLists.txt for both targets.

  4. Expose via TCP in debug_server.c as a passive read-only field (e.g., add "psx_cycle_count" to freeze_check).

  5. Validation: build clean; baseline behavior unchanged (psx_advance_cycles not called by anyone yet); freeze_check shows psx_cycle_count: 0.

That's 1.0e-a. After validation passes, 1.0e-b plugs the SIO scheduler in. Each subsequent step adds one capability and re-validates against baseline + the previous step.