diff --git a/runtime/include/device_trace.h b/runtime/include/device_trace.h new file mode 100644 index 00000000..0274d5e2 --- /dev/null +++ b/runtime/include/device_trace.h @@ -0,0 +1,73 @@ +/* device_trace.h — general two-process DEVICE-EVENT cycle ring. + * + * Purpose: name the device/IRQ event whose guest-cycle timing diverges between + * psx-runtime (code under test) and psx-beetle (independent oracle). Where + * parity_trace answers "which control-flow / gate-input diverged", this answers + * the level below it: "which hardware event (CD sector IRQ / DMA completion / + * RootCounter / VBlank / SPU) fired at a different guest cycle" — the producer + * of an over-fast or over-slow wait. + * + * Model (CLAUDE.md ring-buffer doctrine): ALWAYS-ON from boot, records one row + * per hardware IRQ-raise edge (the single choke point each backend funnels its + * device interrupts through — native psx_irq_raise(); Beetle mednafen + * IRQ_Assert rising edge), stamped with the deterministic guest-cycle ruler. + * A probe QUERIES the ring for a cycle window; it never arms-then-times. + * + * Generality: nothing here is title-specific. The SAME TU compiles into both + * binaries and emits IDENTICAL rows, so tools/devtrace_diff.py pulls both + * timelines and aligns them by guest cycle to find the first divergent event. + * This is the reusable device-timing audit substrate for the whole ecosystem + * (see recomp-template); the MMX6 cutscene asset-load over-fast wedge is its + * first user. + */ +#ifndef DEVICE_TRACE_H +#define DEVICE_TRACE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* IRQ source = the PSX I_STAT bit index. Identical on both backends (mednafen + * irq.h uses the same numbering). */ +typedef enum { + DEV_IRQ_VBLANK = 0, + DEV_IRQ_GPU = 1, + DEV_IRQ_CDROM = 2, + DEV_IRQ_DMA = 3, + DEV_IRQ_TIMER0 = 4, + DEV_IRQ_TIMER1 = 5, + DEV_IRQ_TIMER2 = 6, + DEV_IRQ_SIO0 = 7, + DEV_IRQ_SIO1 = 8, + DEV_IRQ_SPU = 9, + DEV_IRQ_PIO = 10, +} dev_irq_source_t; + +typedef struct { + uint64_t seq; /* monotonic event index since boot */ + uint64_t cycle; /* absolute guest CPU cycles at the raise (the ruler) */ + uint32_t frame; /* host video frame at the raise */ + uint32_t source; /* dev_irq_source_t — the I_STAT bit raised */ + uint32_t detail; /* source-specific: DMA channel / timer index / 0 */ +} DevEvent; + +void device_trace_arm(int on); /* on=1 begins recording (idempotent) */ +void device_trace_reset(void); /* clear ring (keeps armed state) */ +int device_trace_is_armed(void); /* hot-path gate: cheap one-branch check */ + +/* Record one device-IRQ raise edge. No-op unless armed. Stamps cycle+frame via + * the process's guest-cycle/frame accessors (shared with parity_trace). */ +void device_trace_note(uint32_t source, uint32_t detail); + +/* Readback: copies up to max_rows NEWEST events, oldest-first, into out[]. */ +uint32_t device_trace_get(DevEvent* out, uint32_t max_rows); +uint64_t device_trace_total(void); +const char* device_source_str(uint32_t source); + +#ifdef __cplusplus +} +#endif + +#endif /* DEVICE_TRACE_H */ diff --git a/runtime/include/interrupts.h b/runtime/include/interrupts.h index fbfffa32..fcd686f9 100644 --- a/runtime/include/interrupts.h +++ b/runtime/include/interrupts.h @@ -24,6 +24,13 @@ struct CPUState; void interrupts_init(void); +/* Central IRQ-raise choke point: sets the I_STAT bit AND records the raise edge + * into the always-on device-event ring (device_trace) with the guest cycle. + * Every hardware source (VBLANK/GPU/CDROM/DMA/timers/SIO/SPU) funnels its raise + * through here so the device-timing audit sees every event from one place. + * `detail` is source-specific (DMA channel, timer index) or 0. */ +void psx_irq_raise(uint32_t bit, uint32_t detail); + /* Called from the dispatch loop after each function returns. * Fires vblank on schedule, checks (i_stat & i_mask), and if * pending + COP0 allows, dispatches the exception handler. */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 29f142b7..5788909f 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -133,6 +133,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/dirty_ram_interp.c ${PSXRECOMP_ROOT}/runtime/src/fntrace.c ${PSXRECOMP_ROOT}/runtime/src/parity_trace.c + ${PSXRECOMP_ROOT}/runtime/src/device_trace.c ${PSXRECOMP_ROOT}/runtime/src/boot_state.c ${PSXRECOMP_ROOT}/runtime/src/traps.c ${PSXRECOMP_ROOT}/runtime/src/crash_trace.c diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index a8836d64..9a002087 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -36,6 +36,8 @@ extern int iso_track_is_audio(void* handle, int track); /* I_STAT owned by memory.c — set bit 2 for CDROM IRQ */ extern uint32_t i_stat; +/* Central IRQ-raise choke point (interrupts.c) — also records the device ring. */ +extern void psx_irq_raise(uint32_t bit, uint32_t detail); extern uint32_t g_debug_current_func_addr; extern uint32_t g_debug_last_store_pc; extern uint64_t s_frame_count; @@ -536,7 +538,7 @@ static void present_cdrom_irq(void) { * own trailing INTC ack. */ if (cdrom_irq_present_delay > 0) return; if (irq_flag && (irq_enable & (1 << (irq_flag - 1))) && !cdrom_intc_request_latched) { - i_stat |= (1u << 2); /* IRQ_CDROM */ + psx_irq_raise(2, irq_flag); /* IRQ_CDROM; detail = CD response/IRQ type */ cdrom_intc_request_latched = 1; cdrom_intc_latched_generation = cdrom_irq_generation; event_ring_record(EV_ISTAT_RAISE, 2 /* IRQ_CDROM bit */); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index e4557e9c..9ce6e74d 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -2606,6 +2606,7 @@ static void handle_dirty_insn_dump_file(int id, const char *json) * across processes (psx-runtime port 4370, psx-beetle port 4380). */ #include "fntrace.h" #include "parity_trace.h" +#include "device_trace.h" /* ---- parity_dump / parity_ctl: general two-process control-flow parity ring. * Mirrors the IDENTICAL command on psx-beetle so tools/parity_diff.py can pull @@ -2691,6 +2692,61 @@ static void handle_parity_ctl(int id, const char *json) debug_server_send_line(buf); } +/* ---- devtrace_dump / devtrace_ctl: general two-process device-event ring. + * Identical command + JSON on psx-beetle so tools/devtrace_diff.py pulls both + * device-IRQ timelines and aligns them by guest cycle. Optional filters: + * count max newest events to return (default 65536, cap = ring) + * cyc_lo / cyc_hi half-open guest-cycle window (decimal) — slice the load + * src only this I_STAT source bit (0..10); omit/negative = all */ +static void handle_devtrace_dump(int id, const char *json) +{ + int count = json_get_int(json, "count", 65536); + if (count < 1) count = 1; + if (count > (1 << 20)) count = (1 << 20); + char buf[32]; + uint64_t cyc_lo = 0, cyc_hi = ~0ull; + if (json_get_str(json, "cyc_lo", buf, sizeof buf)) cyc_lo = strtoull(buf, NULL, 0); + if (json_get_str(json, "cyc_hi", buf, sizeof buf)) cyc_hi = strtoull(buf, NULL, 0); + int src = json_get_int(json, "src", -1); + + DevEvent *e = (DevEvent *)malloc(sizeof(DevEvent) * (size_t)count); + if (!e) { send_err(id, "oom"); return; } + uint32_t got = device_trace_get(e, (uint32_t)count); + const size_t BUF_SZ = 12 * 1024 * 1024; + char *out = (char *)malloc(BUF_SZ); + if (!out) { free(e); send_err(id, "oom"); return; } + size_t pos = 0; + pos += snprintf(out + pos, BUF_SZ - pos, + "{\"id\":%d,\"ok\":true,\"total\":%llu,\"armed\":%d,\"count\":%u,\"events\":[", + id, (unsigned long long)device_trace_total(), device_trace_is_armed(), got); + uint32_t emitted = 0; + for (uint32_t i = 0; i < got; i++) { + if (pos > BUF_SZ - 256) break; + DevEvent *r = &e[i]; + if (r->cycle < cyc_lo || r->cycle >= cyc_hi) continue; + if (src >= 0 && (int)r->source != src) continue; + pos += snprintf(out + pos, BUF_SZ - pos, + "%s{\"seq\":%llu,\"cycle\":%llu,\"frame\":%u,\"srcn\":%u,\"src\":\"%s\",\"detail\":%u}", + emitted ? "," : "", (unsigned long long)r->seq, (unsigned long long)r->cycle, + r->frame, r->source, device_source_str(r->source), r->detail); + emitted++; + } + pos += snprintf(out + pos, BUF_SZ - pos, "],\"emitted\":%u}\n", emitted); + debug_server_send_line(out); free(out); free(e); +} + +static void handle_devtrace_ctl(int id, const char *json) +{ + if (json_get_int(json, "reset", 0)) device_trace_reset(); + long armv = json_get_int(json, "arm", -1); + if (armv == 0 || armv == 1) device_trace_arm((int)armv); + char buf[128]; + snprintf(buf, sizeof buf, + "{\"id\":%d,\"ok\":true,\"armed\":%d,\"total\":%llu}\n", + id, device_trace_is_armed(), (unsigned long long)device_trace_total()); + debug_server_send_line(buf); +} + static void handle_fntrace_arm(int id, const char *json) { char buf[32]; @@ -10338,6 +10394,8 @@ static const CmdEntry s_commands[] = { { "ping", handle_ping }, { "parity_dump", handle_parity_dump }, { "parity_ctl", handle_parity_ctl }, + { "devtrace_dump", handle_devtrace_dump }, + { "devtrace_ctl", handle_devtrace_ctl }, { "latency", handle_latency }, { "vk_perf", handle_vk_perf }, { "game_options", handle_game_options }, diff --git a/runtime/src/device_trace.c b/runtime/src/device_trace.c new file mode 100644 index 00000000..43468d75 --- /dev/null +++ b/runtime/src/device_trace.c @@ -0,0 +1,66 @@ +/* device_trace.c — general two-process device-event cycle ring. + * See device_trace.h. Self-contained (no runtime internals) so the SAME TU + * compiles into both psx-runtime and psx-beetle and they emit identical rows. */ +#include "device_trace.h" +#include + +#define DEVTRACE_RING_CAP (1u << 20) /* 1M events (~24 MB); covers the whole + * boot->wedge window so the divergent + * event is in-window, never evicted. */ + +static DevEvent s_ring[DEVTRACE_RING_CAP]; +static uint64_t s_seq = 0; +static uint32_t s_armed = 0; + +/* Guest-cycle + frame accessors — the SAME the parity ring uses, defined once + * per host process (native: main.cpp; Beetle: beetle_libretro.cpp). Reused so + * there is exactly one notion of "guest cycle" / "frame" per process. */ +extern uint32_t parity_host_frame(void); +extern uint64_t parity_host_cycle(void); + +void device_trace_arm(int on) { s_armed = on ? 1u : 0u; } +void device_trace_reset(void) { s_seq = 0; memset(s_ring, 0, sizeof(s_ring)); } +int device_trace_is_armed(void) { return (int)s_armed; } +uint64_t device_trace_total(void) { return s_seq; } + +const char* device_source_str(uint32_t source) +{ + switch (source) { + case DEV_IRQ_VBLANK: return "vblank"; + case DEV_IRQ_GPU: return "gpu"; + case DEV_IRQ_CDROM: return "cdrom"; + case DEV_IRQ_DMA: return "dma"; + case DEV_IRQ_TIMER0: return "timer0"; + case DEV_IRQ_TIMER1: return "timer1"; + case DEV_IRQ_TIMER2: return "timer2"; + case DEV_IRQ_SIO0: return "sio0"; + case DEV_IRQ_SIO1: return "sio1"; + case DEV_IRQ_SPU: return "spu"; + case DEV_IRQ_PIO: return "pio"; + default: return "?"; + } +} + +void device_trace_note(uint32_t source, uint32_t detail) +{ + if (!s_armed) return; + DevEvent* e = &s_ring[s_seq & (DEVTRACE_RING_CAP - 1u)]; + e->seq = s_seq; + e->cycle = parity_host_cycle(); + e->frame = parity_host_frame(); + e->source = source; + e->detail = detail; + s_seq++; +} + +uint32_t device_trace_get(DevEvent* out, uint32_t max_rows) +{ + if (!out || max_rows == 0) return 0; + uint64_t total = s_seq; + uint32_t avail = (total < DEVTRACE_RING_CAP) ? (uint32_t)total : DEVTRACE_RING_CAP; + uint32_t n = (avail < max_rows) ? avail : max_rows; + uint64_t start = total - n; + for (uint32_t i = 0; i < n; i++) + out[i] = s_ring[(start + i) & (DEVTRACE_RING_CAP - 1u)]; + return n; +} diff --git a/runtime/src/dma.c b/runtime/src/dma.c index e8f34291..ae74e3f1 100644 --- a/runtime/src/dma.c +++ b/runtime/src/dma.c @@ -32,6 +32,8 @@ extern void psx_write_word(uint32_t addr, uint32_t val); /* Interrupt status — defined in memory.c */ extern uint32_t i_stat; +/* Central IRQ-raise choke point (interrupts.c) — also records the device ring. */ +extern void psx_irq_raise(uint32_t bit, uint32_t detail); extern uint32_t g_debug_current_func_addr; extern uint32_t g_debug_last_store_pc; extern uint64_t s_frame_count; @@ -215,7 +217,7 @@ static void raise_dma_irq_on_master_edge(uint32_t before) { * from 0 to 1. Pending channel flags keep bit 31 high, but do not * continuously re-latch I_STAT after software acknowledges IRQ3. */ if (!dicr_master_flag(before) && dicr_master_flag(dicr)) { - i_stat |= (1u << 3); + psx_irq_raise(3, (dicr >> 24) & 0x7Fu); /* detail = DICR per-channel IRQ flags */ } } diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 1ee0cbd6..12f59ef8 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -1074,6 +1074,8 @@ static uint32_t lcf; /* bit 31 */ * BIOS-shell GPUSTAT poll loop, without going through the * vblank-callback path (which would call SDL_Delay/Sleep). */ extern uint32_t i_stat; +/* Central IRQ-raise choke point (interrupts.c) — also records the device ring. */ +extern void psx_irq_raise(uint32_t bit, uint32_t detail); /* Display area start (GP1(05h)) */ static uint32_t display_area_x; @@ -1229,7 +1231,7 @@ uint32_t gpu_read_gpustat(void) { if (gpustat_poll_count >= GPUSTAT_POLL_VBLANK_THRESHOLD) { gpustat_poll_count = 0; lcf ^= 1; - i_stat |= (1u << 0); /* IRQ_VBLANK */ + psx_irq_raise(0, 0); /* IRQ_VBLANK (GPUSTAT-poll fallback path) */ /* DEQUEUE: VBlank fired via the GPUSTAT-poll fallback path (distinct * from the cycle-paced VBlank in psx_check_interrupts). */ event_ring_record_aux(EV_DEQ, (uint8_t)SRC_VBLANK, 0xFFFFFFFFu); @@ -1343,7 +1345,7 @@ static uint16_t rgb888_to_rgb555(uint32_t color24) { void gpu_vblank_tick(void) { lcf ^= 1; gpustat_poll_count = 0; - i_stat |= (1u << 0); /* IRQ_VBLANK */ + psx_irq_raise(0, 0); /* IRQ_VBLANK (gpu_vblank_tick) */ if (vblank_callback) vblank_callback(); } diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index cceae05f..5c14252b 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -85,6 +85,20 @@ static void irq_record_outcome(uint8_t kind, uint8_t detail, uint32_t aux) { extern uint32_t i_stat; extern uint32_t i_mask; +/* Device-event cycle ring (device_trace.c): every hardware IRQ-raise edge is + * recorded here with its guest cycle. */ +#include "device_trace.h" + +/* Central IRQ-raise choke point. All device sources call this to set their + * I_STAT bit so the device-event ring sees every raise from one place with the + * exact guest cycle. Pure addition over `i_stat |= (1<= VBLANK_INTERVAL) { s_vblank_count = 0; - i_stat |= (1u << IRQ_VBLANK); + psx_irq_raise(IRQ_VBLANK, 0); gpu_vblank_tick(); timers_tick(33868); cdrom_tick(); diff --git a/runtime/src/sio.c b/runtime/src/sio.c index e0da3db2..295f4775 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -20,6 +20,8 @@ /* I_STAT is owned by memory.c */ extern uint32_t i_stat; +/* Central IRQ-raise choke point (interrupts.c) — also records the device ring. */ +extern void psx_irq_raise(uint32_t bit, uint32_t detail); /* IRQ bit for SIO0 */ #define IRQ_SIO0 7 @@ -1872,7 +1874,7 @@ static void sio_fire_ack_irq(void) { sio_stat |= SIO_STAT_IRQ; uint32_t i_stat_before = i_stat; - i_stat |= (1 << IRQ_SIO0); + psx_irq_raise(IRQ_SIO0, 0); /* SIO ACK IRQ */ event_ring_record_aux(EV_DEQ, (uint8_t)SRC_SIO, 0u); /* SIO ACK IRQ fired */ extern uint32_t g_debug_current_func_addr; @@ -2070,7 +2072,7 @@ void sio_tick(int cycles) { * on TX_RDY before writing the next byte. */ sio_stat |= SIO_STAT_TX_RDY | SIO_STAT_TX_EMPTY; uint32_t i_stat_before = i_stat; - i_stat |= (1 << IRQ_SIO0); + psx_irq_raise(IRQ_SIO0, 1); /* SIO shift IRQ */ event_ring_record_aux(EV_DEQ, (uint8_t)SRC_SIO, 1u); /* SIO shift IRQ fired */ /* SIO IRQ ring capture. */ diff --git a/runtime/src/timers.c b/runtime/src/timers.c index 20d7518f..8e6f770d 100644 --- a/runtime/src/timers.c +++ b/runtime/src/timers.c @@ -67,6 +67,8 @@ void timers_get_debug(int t, uint16_t *counter, uint16_t *target, /* I_STAT is owned by memory.c — we poke it directly via this extern. */ extern uint32_t i_stat; +/* Central IRQ-raise choke point (interrupts.c) — also records the device ring. */ +extern void psx_irq_raise(uint32_t bit, uint32_t detail); void timers_init(void) { memset(timers, 0, sizeof(timers)); @@ -119,14 +121,14 @@ static void timer_fire_irq(int t) { tm->irq_line ^= 1; if (tm->irq_line) { tm->mode &= ~MODE_IRQ_REQUEST; - i_stat |= (1 << timer_irq[t]); + psx_irq_raise(timer_irq[t], t); /* detail = timer index */ } else { tm->mode |= MODE_IRQ_REQUEST; } } else { /* Pulse mode */ tm->mode &= ~MODE_IRQ_REQUEST; - i_stat |= (1 << timer_irq[t]); + psx_irq_raise(timer_irq[t], t); /* detail = timer index */ tm->mode |= MODE_IRQ_REQUEST; } diff --git a/tools/devtrace_diff.py b/tools/devtrace_diff.py new file mode 100644 index 00000000..124e296a --- /dev/null +++ b/tools/devtrace_diff.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""devtrace_diff.py — two-process DEVICE-EVENT cycle-timeline diff. + +Pulls the always-on device-event ring (devtrace_dump) from psx-runtime (code +under test) and psx-beetle (independent oracle) and compares, per IRQ source +(VBLANK / GPU / CDROM / DMA / TIMER0-2 / SIO / SPU), the GUEST CYCLE at which the +Nth event of that source fired. VBlank is cycle-paced so it should track between +the two; a source that drifts (its Nth event at a very different cycle) is the +device whose completion timing diverges — the producer of an over-fast/over-slow +wait. This is the level below parity_diff (control flow): it names the hardware +event, not the branch. + +Both backends emit the IDENTICAL row schema (device_trace.h), so one tool reads +both ports by switching the port number. + +Usage: + python tools/devtrace_diff.py [--native PORT] [--beetle PORT] + [--cyc-lo C] [--cyc-hi C] # restrict to a guest-cycle window + [--drift N] # flag first ordinal whose |Δcycle| > N (default 2_000_000) + [--show K] # also print first K events per source per side +""" +import argparse, json, socket, sys + +SRC = {0:"vblank",1:"gpu",2:"cdrom",3:"dma",4:"timer0",5:"timer1",6:"timer2", + 7:"sio0",8:"sio1",9:"spu",10:"pio"} + +def query(port, cmd, **kw): + obj = {"cmd": cmd}; obj.update(kw) + s = socket.create_connection(("127.0.0.1", port), timeout=15) + try: + s.sendall((json.dumps(obj) + "\n").encode()) + buf = b"" + while b"\n" not in buf: + chunk = s.recv(1 << 20) + if not chunk: break + buf += chunk + return json.loads(buf.decode(errors="replace").split("\n", 1)[0]) + finally: + s.close() + +def pull(port, cyc_lo, cyc_hi): + kw = {"count": 1 << 20} + if cyc_lo is not None: kw["cyc_lo"] = str(cyc_lo) + if cyc_hi is not None: kw["cyc_hi"] = str(cyc_hi) + r = query(port, "devtrace_dump", **kw) + if not r.get("ok"): + print(f"[port {port}] devtrace_dump failed: {r}", file=sys.stderr); sys.exit(2) + return r + +def by_source(events): + d = {} + for e in events: + d.setdefault(e["srcn"], []).append(e) + return d + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--native", type=int, default=4490) + ap.add_argument("--beetle", type=int, default=4382) + ap.add_argument("--cyc-lo", type=int, default=None) + ap.add_argument("--cyc-hi", type=int, default=None) + ap.add_argument("--drift", type=int, default=2_000_000) + ap.add_argument("--show", type=int, default=0) + args = ap.parse_args() + + nat = pull(args.native, args.cyc_lo, args.cyc_hi) + bet = pull(args.beetle, args.cyc_lo, args.cyc_hi) + ne, be = nat["events"], bet["events"] + print(f"native(:{args.native}): {len(ne)} events (ring total={nat['total']}, armed={nat['armed']})") + print(f"beetle(:{args.beetle}): {len(be)} events (ring total={bet['total']}, armed={bet['armed']})") + + ns, bs = by_source(ne), by_source(be) + sources = sorted(set(ns) | set(bs)) + print(f"\n{'source':<8} {'native#':>8} {'beetle#':>8} {'first divergent ordinal (cycle delta)':<40}") + print("-" * 78) + for s in sources: + n, b = ns.get(s, []), bs.get(s, []) + name = SRC.get(s, f"src{s}") + # align by ordinal, find first where the cycle gap exceeds --drift + flag = "" + m = min(len(n), len(b)) + for i in range(m): + dc = n[i]["cycle"] - b[i]["cycle"] + if abs(dc) > args.drift: + flag = f"#{i}: native cyc={n[i]['cycle']} beetle cyc={b[i]['cycle']} (delta={dc:+d})" + break + if not flag and len(n) != len(b): + flag = f"count differs ({len(n)} vs {len(b)}) — no big per-ordinal drift in first {m}" + if not flag: + flag = "tracks (no drift > threshold)" + print(f"{name:<8} {len(n):>8} {len(b):>8} {flag}") + + if args.show: + for s in sources: + name = SRC.get(s, f"src{s}") + print(f"\n--- {name} first {args.show} (native | beetle) ---") + n, b = ns.get(s, []), bs.get(s, []) + for i in range(min(args.show, max(len(n), len(b)))): + nc = f"cyc={n[i]['cycle']} f={n[i]['frame']} d={n[i]['detail']}" if i < len(n) else "-" + bc = f"cyc={b[i]['cycle']} f={b[i]['frame']} d={b[i]['detail']}" if i < len(b) else "-" + print(f" #{i:<4} {nc:<34} | {bc}") + +if __name__ == "__main__": + main()