phase1.0c-v2: cycle-paced sio_tick + dispatch quantum behind active-guard

Adds the cycle-paced sio_tick body, the sio_tick_quantum() wrapper, and
the pace_state TCP telemetry handler. Crucial change vs. failed 1.0c:
the dispatch-loop quantum tick is gated by g_sio_timing_active (volatile
int), so on the psx_check_interrupts hot path the cost is one load + one
branch when no SIO time is pending — not a full function call into the
cycle-paced loop.

In 1.0c-v2 the TX path is still synchronous and nothing arms shift_active
or pending_ack, so g_sio_timing_active stays 0. Phase 1.0d will set it
when arming the shifter and clear it when the shifter+buffer+ack are all
empty.

Files:
- sio.h: SIO_MODEL_CYCLE_PACED moved to header (default 1); declares
  g_sio_timing_active (extern volatile int) and sio_tick_quantum()
- sio.c: defines g_sio_timing_active=0; adds cycle-paced helpers
  (sio_consume_ack_event, sio_fire_ack_irq, sio_handle_shift_complete);
  sio_tick gains a cycle-paced loop that runs only if shift_active or
  pending_ack is set (currently never), then falls through to the legacy
  IRQ countdown body which remains the live IRQ delivery path; adds
  sio_get_pace_state(uint64_t out[16]) accessor
- interrupts.c: in psx_check_interrupts !in_exception block, calls
  sio_tick_quantum only if g_sio_timing_active is set
- debug_server.c: new pace_state TCP handler exposing the snapshot

Validation against 64e0194 baseline:
- t=20s/30s TCP responsive; pad arm audit 2840/2840 no_ack=0
- 4x CROSS succeeds
- card txn distribution unchanged (10 txns reach bytes=15 ts=10)
- txn 0 still reads "MC" magic at bytes 10-11 (0x4D 0x43)
- pace_state: timing_active=0, all dynamic fields 0 throughout

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-04-29 17:04:14 -07:00
parent 64e019497e
commit 0d15e2bd4e
4 changed files with 258 additions and 12 deletions
+27 -6
View File
@@ -10,21 +10,42 @@ extern "C" {
/* SIO0 register base: 0x1F801040 */
#define SIO_BASE 0x1F801040
/* Phase 1.0c-v2: cycle-paced SIO model. Default 1 enables the dispatch-
* loop quantum tick (gated by g_sio_timing_active). Set to 0 to revert
* to legacy access-paced behavior across all callers. */
#ifndef SIO_MODEL_CYCLE_PACED
#define SIO_MODEL_CYCLE_PACED 1
#endif
void sio_init(void);
/* MMIO read/write (0x1F801040-0x1F80104E) */
uint32_t sio_read(uint32_t addr);
void sio_write(uint32_t addr, uint32_t value);
/* Advance SIO timing.
/* Advance SIO timing by `cycles` PSX cycles.
*
* Phase 1.0a signature change. The cycles arg is reserved for a future
* cycle-paced model (SIO_MODEL_CYCLE_PACED=1). With the default macro
* setting (SIO_MODEL_CYCLE_PACED=0) the cycles arg is ignored and the
* body behaves identically to the prior tick-once-per-call semantics —
* fires pending IRQ7 after the transfer delay expires. */
* In cycle-paced mode (SIO_MODEL_CYCLE_PACED=1, default in 1.0c+): walks
* shift-complete and ACK-fire events scheduled in cycles ahead. In 1.0c
* the shifter is never armed (TX path still synchronous), so the body
* is effectively inert. Phase 1.0d will route TX through the shifter.
*
* The legacy access-paced IRQ countdown body always runs and is the
* actual IRQ delivery mechanism in 1.0c. */
void sio_tick(int cycles);
/* Convenience: advance SIO by the dispatch-loop quantum. Called from
* psx_check_interrupts inside !in_exception, gated by g_sio_timing_active
* (no-op when no SIO cycle-paced work is pending). No-op under macro=0. */
void sio_tick_quantum(void);
/* Hot-path active guard. Set to 1 by future 1.0d code when sio_shift or
* sio_pending_ack arms; cleared back to 0 when both shifter and buffer
* and ACK are empty. The dispatch loop reads this BEFORE calling
* sio_tick_quantum, so when no SIO time is pending the per-call cost is
* one load + one branch. In 1.0c-v2 nothing sets this — it stays 0. */
extern volatile int g_sio_timing_active;
/* Update pad button state. Buttons use PS1 convention: 0=pressed, 1=released.
Bit layout: SELECT, L3, R3, START, UP, RIGHT, DOWN, LEFT,
L2, R2, L1, R1, TRIANGLE, CIRCLE, CROSS, SQUARE */
+45
View File
@@ -1262,6 +1262,50 @@ static void handle_mc_status(int id, const char *json)
extern void sio_get_card_arm_audit(uint32_t out[3][7]);
extern int sio_get_card_arm_countdown_after(void);
extern void sio_get_burst_stats(uint64_t out[10]);
extern void sio_get_pace_state(uint64_t out[16]);
extern volatile int g_sio_timing_active;
/* Phase 1.0c-v2 telemetry: cycle-paced SIO state snapshot. Read-only.
* In 1.0c-v2 the TX path is still synchronous, g_sio_timing_active
* stays 0, and all dynamic shifter/ack fields stay zero. */
static void handle_pace_state(int id, const char *json)
{
(void)json;
uint64_t s[16];
sio_get_pace_state(s);
const char *model = s[0] ? "cycle_paced" : "access_legacy";
const char *owner = s[7] == 0 ? "none" : s[7] == 1 ? "card"
: s[7] == 2 ? "pad" : "unknown";
send_fmt("{\"id\":%d,\"ok\":true,"
"\"sio_model\":\"%s\","
"\"sio_quantum_cycles\":%llu,"
"\"timing_active\":%d,"
"\"shift_active\":%llu,"
"\"shift_remaining\":%llu,"
"\"tx_buffered\":%llu,"
"\"ack_active\":%llu,"
"\"ack_remaining\":%llu,"
"\"bus_owner\":\"%s\","
"\"bus_byte_index\":%llu,"
"\"tx_writes_buffered\":%llu,"
"\"tx_writes_dropped_busy\":%llu,"
"\"tx_writes_dropped_cross_device\":%llu,"
"\"tx_buffer_promoted\":%llu,"
"\"tx_buffer_promoted_during_card\":%llu,"
"\"pad_byte_processed_in_card_data\":%llu,"
"\"cross_device_pad_during_card\":%llu}\n",
id, model,
(unsigned long long)s[1],
g_sio_timing_active,
(unsigned long long)s[2], (unsigned long long)s[3],
(unsigned long long)s[4], (unsigned long long)s[5],
(unsigned long long)s[6],
owner,
(unsigned long long)s[8], (unsigned long long)s[9],
(unsigned long long)s[10], (unsigned long long)s[11],
(unsigned long long)s[12], (unsigned long long)s[13],
(unsigned long long)s[14], (unsigned long long)s[15]);
}
static void handle_sio_burst_stats(int id, const char *json)
{
@@ -2833,6 +2877,7 @@ static const CmdEntry s_commands[] = {
{ "card_buffer_dump", handle_card_buffer_dump },
{ "sio_arm_audit", handle_sio_arm_audit },
{ "sio_burst_stats", handle_sio_burst_stats },
{ "pace_state", handle_pace_state },
{ "chain_trace", handle_chain_trace },
{ "sio_trace", handle_sio_trace },
{ "sio_pc_trace", handle_sio_pc_trace },
+10
View File
@@ -155,6 +155,16 @@ void psx_check_interrupts(CPUState* cpu) {
}
}
if (!in_exception) {
#if SIO_MODEL_CYCLE_PACED
/* Phase 1.0c-v2: advance cycle-paced SIO bus by one quantum, but
* only when something is actually pending. g_sio_timing_active
* stays 0 throughout 1.0c-v2 (TX path still synchronous, never
* arms shift/ack), so this gate never opens — per-call cost on
* this hot path is one volatile load + one branch. */
if (g_sio_timing_active) {
sio_tick_quantum();
}
#endif
dispatch_count++;
if (dispatch_count >= VBLANK_INTERVAL) {
/* Defer VBlank while a card SIO transaction is mid-flight.
+176 -6
View File
@@ -1188,14 +1188,23 @@ void sio_get_burst_stats(uint64_t out[10]) {
out[9] = 0;
}
/* Phase 1.0a: SIO_MODEL_CYCLE_PACED defaulting to 0 keeps the legacy
* access-paced behavior unchanged. The cycles arg on sio_tick is
* reserved for a future cycle-paced model and is ignored under
* macro=0. */
/* Phase 1.0c-v2: flip default to 1, but gate the dispatch-loop quantum
* tick by g_sio_timing_active so the per-call cost on the
* psx_check_interrupts hot path is one load + one branch when no SIO
* cycle-paced work is pending. TX path remains synchronous in 1.0c-v2
* — nothing sets g_sio_timing_active, so the gate stays 0 and
* sio_tick_quantum is never invoked. Phase 1.0d will reroute TX into
* the shifter and arm the guard.
*
* Macro=0 still available to revert to legacy entirely. */
#ifndef SIO_MODEL_CYCLE_PACED
#define SIO_MODEL_CYCLE_PACED 0
#define SIO_MODEL_CYCLE_PACED 1
#endif
/* Hot-path active guard. Defined unconditionally (so its address exists
* for any caller), but only meaningfully used when macro=1. */
volatile int g_sio_timing_active = 0;
#if SIO_MODEL_CYCLE_PACED
/* ---- Phase 1.0b: cycle-paced SIO state (inert) ---------------------------
*
@@ -1251,8 +1260,139 @@ static uint64_t s_pace_shift_completes;
static uint64_t s_pace_ack_fires;
#endif /* SIO_MODEL_CYCLE_PACED */
#if SIO_MODEL_CYCLE_PACED
/* ---- Phase 1.0c-v2: cycle-paced sio_tick helpers (inert in 1.0c-v2) ---
*
* In 1.0c-v2, the TX path is still synchronous and nothing arms
* sio_shift_active or sio_pending_ack. Therefore these helpers exist
* but are never reached at runtime: sio_tick_quantum is gated by
* g_sio_timing_active (which stays 0), and even if called the cycle-
* paced loop in sio_tick would find no events to fire. */
static int sio_consume_ack_event(void) {
int acked = (sio_stat & SIO_STAT_ACK) ? 1 : 0;
sio_stat &= ~SIO_STAT_ACK;
return acked;
}
static void sio_fire_ack_irq(void) {
sio_stat |= SIO_STAT_ACK;
if (!(sio_ctrl & SIO_CTRL_ACK_IRQ_EN)) return;
sio_stat |= SIO_STAT_IRQ;
uint32_t i_stat_before = i_stat;
i_stat |= (1 << IRQ_SIO0);
extern uint32_t g_debug_current_func_addr;
extern uint8_t psx_read_byte(uint32_t addr);
SioIrqEntry *e = &sio_irq_buf[sio_irq_idx];
e->seq = sio_irq_seq;
e->byte_seq = sio_irq_pending_byte_seq;
e->i_stat_before = i_stat_before;
e->i_stat_after = i_stat;
e->mc_state = (uint32_t)sio_irq_pending_mc_state;
e->active_device = (uint32_t)active_device;
e->ctrl = (uint32_t)sio_ctrl;
e->func_addr = g_debug_current_func_addr;
e->counter_7514 = (uint32_t)psx_read_byte(0x7514);
e->source = sio_irq_pending_source;
e->slot = sio_irq_pending_slot;
e->delay_applied = sio_irq_pending_delay;
sio_irq_idx = (sio_irq_idx + 1) % SIO_IRQ_RING_CAP;
sio_irq_seq++;
s_pace_ack_fires++;
}
static void sio_handle_shift_complete(void) {
uint8_t b = sio_shift_byte;
if (b == 0x01 && mc_state >= MC_READ_DATA && mc_state <= MC_READ_END) {
s_pace_pad_byte_processed_in_card_data++;
}
sio_process_byte(b);
int acked = sio_consume_ack_event();
sio_shift_active = 0;
sio_stat |= SIO_STAT_RX_RDY;
s_pace_shift_completes++;
sio_irq_pending_source = (active_device == DEV_MEMCARD)
? SIO_IRQ_SRC_CARD_ACK : SIO_IRQ_SRC_PAD_ACK;
sio_irq_pending_slot = (uint8_t)selected_slot;
sio_irq_pending_delay = (uint8_t)SIO_ACK_CYCLES_DEFAULT;
sio_irq_pending_mc_state = (uint8_t)mc_state;
sio_irq_pending_byte_seq = sio_trace_seq;
if (acked) {
sio_pending_ack = 1;
sio_ack_remaining = SIO_ACK_CYCLES_DEFAULT;
}
if (sio_tx_buffered) {
sio_shift_byte = sio_tx_buffer;
sio_shift_active = 1;
sio_shift_remaining = SIO_BAUD_CYCLES_DEFAULT;
sio_tx_buffered = 0;
sio_stat |= SIO_STAT_TX_EMPTY;
sio_bus_byte_index++;
s_pace_tx_buffer_promoted++;
if (sio_bus_owner == SIO_OWNER_CARD)
s_pace_tx_buffer_promoted_during_card++;
} else {
sio_stat |= SIO_STAT_TX_RDY | SIO_STAT_TX_EMPTY;
}
}
#endif /* SIO_MODEL_CYCLE_PACED */
void sio_tick(int cycles) {
(void)cycles; /* reserved for cycle-paced model (Phase 1.0c+) */
#if SIO_MODEL_CYCLE_PACED
/* Cycle-paced advance. In 1.0c-v2 sio_shift_active and
* sio_pending_ack are never set (TX path still synchronous), so this
* loop finds no events and returns immediately. Plus, the dispatch
* caller is gated by g_sio_timing_active so this function isn't even
* called when nothing is pending. */
if (cycles > 0) {
int remaining = cycles;
int transitions = 0;
const int MAX_TRANSITIONS = 8;
while (remaining > 0 && transitions < MAX_TRANSITIONS) {
int dt = remaining;
int next_event = -1;
if (sio_shift_active && sio_shift_remaining > 0
&& sio_shift_remaining < dt) {
dt = sio_shift_remaining;
next_event = 0;
}
if (sio_pending_ack && sio_ack_remaining > 0
&& sio_ack_remaining < dt) {
dt = sio_ack_remaining;
next_event = 1;
}
if (sio_shift_active && sio_shift_remaining <= 0) {
dt = 0; next_event = 0;
} else if (sio_pending_ack && sio_ack_remaining <= 0) {
dt = 0; next_event = 1;
}
if (sio_shift_active) sio_shift_remaining -= dt;
if (sio_pending_ack) sio_ack_remaining -= dt;
remaining -= dt;
if (next_event == 0) {
sio_handle_shift_complete();
transitions++;
} else if (next_event == 1) {
sio_pending_ack = 0;
sio_fire_ack_irq();
transitions++;
} else {
break;
}
}
}
#else
(void)cycles;
#endif
/* Legacy access-paced IRQ countdown. Always live. In 1.0c-v2 this
* remains the actual IRQ delivery path because the TX path arms it.
* Phase 1.0d will stop arming this and rely on the cycle-paced
* shifter+ack path instead. */
if (sio_irq_pending && sio_irq_countdown > 0) {
sio_irq_countdown--;
if (sio_irq_countdown == 0) {
@@ -1287,3 +1427,33 @@ void sio_tick(int cycles) {
}
}
}
void sio_tick_quantum(void) {
#if SIO_MODEL_CYCLE_PACED
sio_tick(sio_tick_quantum_cycles);
#endif
}
/* Telemetry accessor for pace_state TCP probe. Read-only. */
void sio_get_pace_state(uint64_t out[16]) {
#if SIO_MODEL_CYCLE_PACED
out[ 0] = 1; /* sio_model = cycle_paced */
out[ 1] = (uint64_t)sio_tick_quantum_cycles;
out[ 2] = (uint64_t)sio_shift_active;
out[ 3] = (uint64_t)sio_shift_remaining;
out[ 4] = (uint64_t)sio_tx_buffered;
out[ 5] = (uint64_t)sio_pending_ack;
out[ 6] = (uint64_t)sio_ack_remaining;
out[ 7] = (uint64_t)sio_bus_owner;
out[ 8] = (uint64_t)sio_bus_byte_index;
out[ 9] = s_pace_tx_writes_buffered;
out[10] = s_pace_tx_writes_dropped_busy;
out[11] = s_pace_tx_writes_dropped_cross_device;
out[12] = s_pace_tx_buffer_promoted;
out[13] = s_pace_tx_buffer_promoted_during_card;
out[14] = s_pace_pad_byte_processed_in_card_data;
out[15] = s_pace_cross_device_pad_during_card;
#else
for (int i = 0; i < 16; i++) out[i] = 0;
#endif
}