diff --git a/README.md b/README.md index 159c0a3..e86702c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ of its own snapshot, 2x nearest-neighbour, no filtering. **The player, running.** 119 frames out of a **256 KB ring buffer on an emulated stock 2 MB X68000**, paced to a 12 fps frame clock, streamed from a host file at 488 KB/s by `src/player/stream.s` with no Lua in the decode path. Source on the -left, the machine's screen on the right. +left, the machine's screen on the right. (This recording was paced by the host; +the 68000 now keeps that clock itself, off the CRTC's V-DISP, and the same 120 +frames decode pixel-exact under it — `src/player/clock.i`, FINDINGS 54.) diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index 40ff30a..59c8bf1 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -4535,8 +4535,10 @@ because it is a pure copy, and it still runs alone. **The stages are exactly additive on the exact core.** `P1 + P2 - SCENE = 330` clocks, and `tables + P1 + P2 - 2x330 = 253,614 = BOOT`, to the clock — 330 is the front-end's own per-pass overhead. On MAME the same identity closes to 1.8%, -which is one tick of its 1/55.46 s clock over the 0.99 s run. Two instruments, -two granularities, one arithmetic. +which is one tick of its host clock over the 0.99 s run. Two instruments, +two granularities, one arithmetic. (That tick was written here as 1/55.46 s and +is **1/56.69 s** — MAME's raster, not the hardware's, 54.5. 17.64 ms over 990 ms +is 1.78%, so the sentence was right and the label was wrong.) ### 53.3 The scratch tables are scene-independent, so they are not in the scene path @@ -4618,3 +4620,197 @@ black with `I = 0`. That half of P2 is encoder-side, it changes the container, and it moves every constant fitted to the gate container, so it is a re-encode plus a re-measurement rather than an edit. `load.i` is ready for it: it reads whatever the palette section holds and reports the darkest index either way. + +--- + +## 54. The frame clock moves onto the 68000, and the 12 fps frame turns out never to have existed (session 22) + +ROADMAP P3, and the item was phrased "needs MFP timer or VBL" — which quietly +assumes one of those can do it. Neither can, and finding out why produced a +better clock than either and a correction to an instrument the whole tree reads. + +`src/player/clock.i` is the clock; `src/player/clockgate.s` and +`tools/bench/clock.lua` measure it; `tools/analysis/23_frame_clock.py` +enumerates the space it was chosen from and prices its cadence. Two new stages +in `tools/bench/check.sh` gate it. + +**Layer: MAME 0.277's emulated X68000, not real hardware.** The MFP, the CRTC +and the interrupt sequence are all the emulator's. Where the emulator and the +registers disagree — and they do, 54.5 — the code is built on the registers. + +### 54.1 No MFP timer can tick at 12 Hz, and none can tick as slowly as a frame + +The MC68901's timer clock on this board is 16 MHz / 4 = 4 MHz +(`sharp/x68k.cpp:1027-1028`), its prescaler ladder is `{4, 10, 16, 50, 64, 100, +200}` (`machine/mc68901.cpp:173`) and its data register is 8 bits. So: + +* the **slowest** tick a single timer can produce is 4e6/(200·256) = + **78.125 Hz**, which is 6.5× faster than a 12 fps frame — a software divider + is required whatever the source; +* **4e6/12 = 333,333.33 is not an integer**, so no prescale/data pair divides to + 12 Hz at all. `23_frame_clock.py` walks all 7 × 256 of them and finds zero. + +A timer clock is therefore not "the simple option". It is a divider *plus* an +interrupt rate 3.6× higher than the raster's, at an arbitrary phase against the +scan. + +### 54.2 The raster cannot do it by whole division either — and the fix is exact + +V-DISP is on MFP GPIP4 (`x68k.cpp:1139`), and the same pin is Timer A's event +input (`mc68901.cpp:167`, `GPIO_TIMER = {GPIP_4, GPIP_3}`); its interrupt is +channel 6, `IR_GPIP_4 = $40` (`mc68901.cpp:76`). The raster is 31,500/568 = +**55.4577 Hz** exactly. Every whole divide misses: + +| Timer A event count | fps | error | +|---:|---:|---:| +| 4 | 13.8644 | +15.54% | +| 5 | 11.0915 | −7.57% | + +12 fps needs **4.6215 refreshes per frame**. So the divider keeps a remainder: + +``` +each V-DISP: acc += fps*VTOTAL ; 12*568 = 6816 + if acc >= 31500: acc -= 31500 ; PACE += 1 +``` + +Long-run rate is `fps·VTOTAL/VTOTAL` = **12.000000 fps exactly**, with a +remainder that never accumulates. Both constants are **read out of the CRTC at +init** — `R04+1` for VTOTAL, `R20` bit 4 checked for the 31.5 kHz mode — so the +clock is derived from the registers that generate the raster it counts, and the +two cannot drift apart. The accumulator peaks at 38,316, so it is 16-bit +arithmetic on a 68000; `clk_init` refuses rather than overflow (the ceiling is +fps < 59.9 at this VTOTAL). + +**Measured over 3,000 refreshes: 3,000 interrupts, 649 ticks, where 649.1429 +were exactly due — an error of −0.14 ticks, i.e. the remainder still held.** The +gate is stated in ticks and not in ppm on purpose: a remainder-keeping divider +is off by at most one tick over *any* window, so quoting ppm would let a longer +window advertise a tighter clock for nothing. + +### 54.3 It costs 181.35 clocks per V-DISP — 838 per frame, 0.10% of the budget + +The host cannot time this: MAME's Lua sees the machine once per screen frame, +17.64 ms, and the interrupt costs microseconds. So the **68000 times it +itself**. `clockgate.s` runs a one-instruction loop for a window of 3,000 +refreshes with the clock off and again with it armed: + +``` +clock off: iters0·L = clocks in the window -> L +clock on: iters1·L + ints·H = clocks in the window -> H +``` + +`L` is **calibrated, not looked up** — the point is to price the clock on the +machine rather than against `buscost.py`, which is the table being checked. + +| | | +|---|---:| +| loop iteration, calibrated over 13,926,121 of them | **38.000002 clocks** | +| per V-DISP interrupt, over 3,000 | **181.35 clocks** | +| per 12 fps frame (4.6215 interrupts) | **838 clocks = 0.1006%** | + +`L` landing on a whole number to seven digits is the check that licenses the +subtraction, and it is also an independent confirmation of `buscost.py`'s model: +`addq.l #1,(xxx).L` is 3 instruction words + 4 data accesses = 7 bus cycles = 28 +clocks, plus 10 for the `bra.s`. + +**The 181.35 decomposes exactly.** By the same model the handler body is 130 +clocks on a V-DISP that emits no tick and 164 on one that does; over the measured +649/3,000 mix that is 137.355, leaving **43.99 clocks for the interrupt exception +sequence** — the textbook 44, measured here rather than recalled. + +For comparison, the cheapest exact MFP-timer clock would interrupt 16.7 times a +frame instead of 4.6: **3.6× the cost, for a tick with no fixed relationship to +the scan.** + +### 54.4 THE ONE THAT MOVES SOMETHING: there is no 83.33 ms frame, and there never was + +12 fps on a 55.4577 Hz raster is 4.6215 refreshes, so a frame is shown for **4 +refreshes (72.13 ms) or 5 (90.16 ms)** — 37.9% of them short. The 833,333-clock +budget every figure in this project is priced against is the **mean** slot, and +the short slot is **13.4% under it**. + +With the per-frame decode costs (`tmp/c68k_frames.csv`, 120 frames of the gate +container) run through the actual divider and the actual pace gate: + +| tick source | short slot | frames over it | no idle left | +|---|---:|---:|---:| +| nominal 1/fps model (no raster has it) | 833,333 | 1/120 | **1/120** | +| the host tick, as `stream.lua` really emits it | 705,590 | 10/120 | **4/120** | +| the 68000's clock, hardware raster | 721,270 | 10/120 | **4/120** | +| the 68000's clock, MAME's raster | 705,590 | 10/120 | **4/120** | + +**The cadence was already there and nothing had named it.** `stream.lua`'s tick +is `floor((t - t_rel) * fps)` — which *looks* uniform and is not, because Lua +only sees the machine at frame boundaries, so its ticks land on refreshes and its +gaps are the same two whole numbers. Every host-paced result in FINDINGS 49 and +51 already carried a 4/5 cadence. **P3 did not introduce it. It moved who +produces it onto the machine and made it visible.** + +**A short slot is not a dropped frame.** The pace gate says only "not before +tick i", so a frame that overruns spends the next frame's idle and the clock +recovers itself; the cost is one frame presented a refresh late. What the table +counts is frames with no idle left, and the difference between the nominal row +and the raster rows — **1 against 4** — is the entire price of the cadence on +this container. + +**The expensive frame is frame 0, at 923,146 clocks = 111% of the nominal +budget**: the first frame of a scene has nothing to SKIP against, so it is the +whole picture in one slot. Most of what follows it in those counts is that +transient draining. It also means the cost lands **at a scene change**, next to +FINDINGS 53.2's 18.96 ms of loader and the seek — not spread over the window. + +`src/player/stream.s` now counts this itself (`LATEFR`/`LATEMAX`/`LATE1ST`), and +the rig's count matches the offline model **exactly**: 4/120, first at frame 1, +on both tick sources. The counter sits ahead of the wait loop and the +free-running path executes none of it, so FINDINGS 49's figures are untouched. + +### 54.5 MAME's raster runs 2.22% fast, and the whole tree has been sampling it + +`x68k_crtc.cpp refresh_mode()` computes the frame period as +`(scr.max_x * scr.max_y)` dots over the dot clock, with +`scr.max_x = m_htotal - 8` — one character cell short, and an **inclusive +rectangle bound used as a count**. In the 256-wide mode that is 360 where the +registers say 368, so MAME's refresh is fast by **368/360 = 1.02222**: + +* registers: 31,500/568 = **55.4577 Hz** +* MAME, measured by `clock.lua` over 3,000 frames: **56.6901 Hz** + +The two agree to six digits with `clock_69m()/6 / (360·568)`, so this is the +mechanism and not a coincidence. Consequences, and the third one is why it is +worth this much space: + +1. **Every "1/55.46 s granularity" note in this tree was wrong** — it is + 1/56.69 s, 17.64 ms. Corrected in `decode.lua`, `load.lua`, `span.lua`, + `blit.lua`, `loadgate.s` and `check.sh`, with the derivation put once in + `crtc_mode.lua`. No conclusion changes: 53.2's "one tick over the 0.99 s run" + is 1.78% at the corrected figure and was quoted as 1.8%. +2. **68000 cycle figures are untouched.** The CPU clock is 40 MHz/4 and has + nothing to do with the screen. Nothing in FINDINGS 24–53 moves. +3. **A raster-paced player runs 2.22% fast under MAME**, so the rig measures + 12.267 fps where the hardware would give 12.000. `clock.lua` reports both and + de-skews, and `clock_run.sh` prices the interrupt against the **hardware** + refresh count — charging the player the emulator's extra interrupts would + overstate the cost by that same 2.2%. + +**Do not "fix" 55.4577 to match the measurement.** It is the hardware's, derived +from the dot clocks, and it is what the divider is built on. + +### 54.6 What had to be turned off, and why it is in the file + +The rigs launch the 68000 at `SR=$2700` into a machine the IPL ROM has already +booted, so the MFP arrives with whatever IOCS enabled on it and vectors pointing +into IOCS. Lowering the mask without disarming it would vector into code we did +not put there. `clk_init` writes `IERA = IERB = 0` first — which on the MC68901 +clears the matching pending bits with them (`mc68901.cpp REGISTER_IERA/B`, +`m_ipr &= m_ier`) — then arms GPIP4 alone, takes the falling edge (AER bit 4 +clear: the **start of vertical blanking**, which is when a player would present), +and drops to `SR=$2500`. Levels 1–5 stay masked, so the DMAC (IRQ3) and the SCC +(IRQ5) cannot get in. `VR` is written with **S clear**, so an acknowledge clears +the pending bit by itself and the handler needs no end-of-interrupt write. + +The handler saves only the **low word** of `d0`, because every operation in it is +a word operation — which is legal precisely because `clk_init` proved the +accumulator fits 16 bits. `decode.s` and `frame.i` were checked for stack tricks +before the mask was lowered: the only `a7` use in either is one `move.l a1,-(sp)` +pair, so an interrupt cannot corrupt decoder state. The 120-frame self-paced +decode being pixel-exact is the test of that, and it is gated. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0514271..4be7c60 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,6 +2,7 @@ Written end of session 19 (2026-08-24), against a tree that is ALL GREEN. Amended end of session 21: P1 done, P2 half done (FINDINGS 53). +Amended end of session 22: P3 done (FINDINGS 54). **THE COMPLETION TARGET IS M3, THE VERTICAL SLICE** (USER DECISION): one scene tree — a decision point, two outcomes, a death clip — with audio, streaming from @@ -124,10 +125,37 @@ re-measurement rather than an edit. Until then the letterbox gets the palette's closest thing to black (index 255 on the gate container); `load.i` reports whichever index that is and needs no change when it becomes 0. -**P3. A real frame clock.** `stream.s` has `PACE`/`PACEON` (`$18034`/`$18038`) -but the 12 fps tick comes from the Lua producer. Needs MFP timer or VBL. Keep -`PACEON=0` free-run working — the wrap gate uses it and every FINDINGS 49 figure -depends on it. +~~**P3. A real frame clock.**~~ **DONE, session 22 — FINDINGS 54.** +`src/player/clock.i` derives the tick from the CRTC's own V-DISP through the +MFP, with a remainder-keeping divider whose two constants are read out of the +CRTC at init. **Exactly 12.000000 fps, by construction** — measured at 649 ticks +over 3,000 refreshes where 649.1429 were due, so the remainder still held and +nothing accumulated. **181.35 clocks per V-DISP, 838 per frame, 0.1006% of the +budget**, timed by the 68000 itself because the host's 17.64 ms granularity +cannot see it. `PACEON=0` free-run is untouched and so is the wait loop; the +free-running path executes none of the new code. + +The item said "MFP timer or VBL" and **neither can do it alone**: 4e6/12 is not +an integer and no prescale/data pair reaches 12 Hz, while the slowest MFP tick +of any kind is 78.125 Hz; and the raster's 55.4577 Hz has no whole divide near +12 either (4 gives 13.86, 5 gives 11.09). `tools/analysis/23_frame_clock.py` +walks the whole space rather than asserting it. + +**What it exposed is bigger than the item.** 12 fps on a 55.4577 Hz raster is +4.6215 refreshes, so a frame gets **4 refreshes (72.13 ms) or 5 (90.16 ms)** and +**there is no 83.33 ms frame** — that figure is the mean slot, and 37.9% of slots +are 13.4% under it. The cadence was ALREADY in every host-paced result in +FINDINGS 49/51, because `stream.lua`'s tick is sampled at frame boundaries and +its gaps were always 4 or 5; nothing had named it. On the gate container it +costs 4 frames of 120 their idle against 1 for the nominal model. **It is not a +dropped frame** — the pace gate lets an overrun eat the next frame's idle and +the clock recovers — but it means every budget in this project is priced against +a slot 37.9% of frames do not get. 54.4. + +**Also struck: MAME's raster runs 2.22% fast** (`refresh_mode()` builds the frame +period from `htotal - 8`), so the tree's "1/55.46 s granularity" was 1/56.69 s +throughout. No 68000 cycle figure moves — the CPU clock is unrelated to the +screen — but anything paced by the raster does. 54.5. **P4. Real transport.** Drive the MB89352 instead of a host file. The `W` handshake — clocks stolen per delivered byte, bracketed 5..12 by MC68450 Fig diff --git a/docs/STATUS.md b/docs/STATUS.md index 5a40c6f..afe3066 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,3 +1,117 @@ +# Status & next-session handoff — end of session 22 (2026-08-24) + +## Session 22: the frame clock moves onto the 68000, and the 12 fps frame turns out not to exist + +**Green light first and last: `./tools/bench/check.sh` was ALL GREEN before any +of this and ALL GREEN after**, 120/120 on both cores, no `TRUNCATED`, plus two +new frame-clock stages. + +**ROADMAP P3 is DONE. FINDINGS 54.** P3 was one of the two items session 21's +handoff called buildable here, and it is the one that touches every other number +in the project — because the tick is what the word "frame" in "% of a frame" +means. + +**1. Neither of the two sources P3 named can do it, and the enumeration is the +finding.** The MFP's timer clock is 16 MHz/4 = 4 MHz, its prescalers are +`{4,10,16,50,64,100,200}` and its data register is 8 bits, so the **slowest tick +any single timer can make is 78.125 Hz** — 6.5x faster than a frame — and +**4e6/12 = 333,333.33 is not an integer**, so no setting reaches 12 Hz at all. +The raster has no whole divide near 12 either: 4 refreshes is 13.86 fps and 5 is +11.09. `tools/analysis/23_frame_clock.py` walks all 7x256 timer settings rather +than asserting it. 54.1, 54.2. + +**2. The clock is the raster with a remainder, and it is exact by construction.** +`src/player/clock.i` takes the V-DISP falling edge on MFP GPIP4 — the start of +vertical blanking, which is when a player would present — and adds `fps*VTOTAL` +per edge to a 16-bit accumulator, emitting a tick at 31,500 and keeping the +remainder. Long-run rate is `fps*VTOTAL/VTOTAL` = **12.000000 fps exactly**. +Both constants are **read out of the CRTC at init**, so the clock is derived from +the registers that generate the raster it counts. Measured: **3,000 interrupts, +649 ticks where 649.1429 were due**. The gate is stated in ticks, not ppm — a +remainder is off by at most one tick over any window, so ppm would let a longer +window advertise a tighter clock for free. 54.2. + +**3. It costs 181.35 clocks per V-DISP; 838 per frame; 0.1006% of the budget.** +The host cannot time this — its granularity is 17.64 ms and the interrupt is +microseconds — so **the 68000 times it itself**: a one-instruction loop over a +3,000-refresh window, run with the clock off and on, with the loop's own cost +calibrated rather than looked up. The calibration landed on **38.000002 clocks** +per iteration, which is both the check that licenses the subtraction and an +independent confirmation of `buscost.py`'s model. The 181.35 then decomposes +exactly, leaving **43.99 clocks for the interrupt exception** — the textbook 44, +measured rather than recalled. A timer-based clock would have cost 3.6x this at +an arbitrary phase against the scan. 54.3. + +**4. THE ONE THAT MOVES SOMETHING: there is no 83.33 ms frame, and there never +was.** 12 fps on a 55.4577 Hz raster is 4.6215 refreshes, so a frame is shown for +**4 refreshes (72.13 ms) or 5 (90.16 ms)**, 37.9% of them short. The +833,333-clock budget every figure in this project is priced against is the +**mean** slot; the short one is **13.4% under it**, and 10 of the gate +container's 120 frames do not fit it. + +**And the cadence was already in the tree, unnamed.** `stream.lua`'s tick is +`floor((t - t_rel) * fps)`, which looks uniform and is not: Lua only sees the +machine at frame boundaries, so its ticks land on refreshes and its gaps were +always 4 or 5. **Every host-paced result in FINDINGS 49 and 51 already carried +this cadence.** P3 did not introduce it; it moved who produces it onto the +machine and made it visible. + +**It is not a dropped frame.** The pace gate says only "not before tick i", so an +overrun eats the next frame's idle and the clock recovers itself; the cost is one +frame presented a refresh late. On the gate container that is **4 frames of 120 +with no idle left, against 1 for the nominal model** — and the expensive one is +**frame 0 at 111% of budget**, because the first frame of a scene has nothing to +SKIP against. So the cost lands **at a scene change**, next to 53.2's 18.96 ms of +loader and the seek. `stream.s` counts this itself now, and the rig's count +matches an offline model of the divider **exactly**: 4/120, first at frame 1, on +both tick sources. 54.4. + +**5. An instrument correction the whole tree was reading.** `x68k_crtc.cpp +refresh_mode()` builds the frame period from `scr.max_x * scr.max_y` with +`scr.max_x = m_htotal - 8` — one character cell short, an inclusive bound used as +a count. **MAME's raster is fast by 368/360 = 2.2222%**: 56.6901 Hz measured +against the registers' 55.4577, agreeing to six digits with the arithmetic. So +every "1/55.46 s granularity" note in this tree was wrong and is **1/56.69 s**; +corrected in six files with the derivation put once in `crtc_mode.lua`. **No +conclusion changes and no 68000 cycle figure moves** — the CPU clock is unrelated +to the screen — but anything *paced* by the raster runs 2.22% fast under MAME, so +`clock.lua` reports both rates and de-skews, and the interrupt is priced against +the hardware refresh count. 54.5. + +**New in the tree:** `src/player/clock.i` (the clock) and +`src/player/clockgate.s` (its measurement front-end); `tools/bench/clock.lua`, +`clock_cost.py`, `clock_run.sh` (the rig); `tools/analysis/23_frame_clock.py` +(the enumeration and the cadence pricing). `stream.s` gains `CLKON` and a +late-frame counter ahead of the wait loop; `stream.lua` gains `DLX_PACE=2` and +takes its deadlines from the ticks the machine actually emitted rather than from +a host model; `pace_run.sh` gains `DLX_PACE` selection, with the default tag left +alone so `pace_sweep.sh` still finds its logs. `check.sh` gains two stages: the +clock's own measurement, and 120 frames decoded pixel-exact with nothing outside +the machine deciding when a frame may start. + +**`decode.s` and `frame.i` are unchanged.** `decode.bin` is still 1,296 B at the +same MD5. The pace gate's wait loop is byte-for-byte the one FINDINGS 51 +measured, and the free-running path executes none of the new code, so every +FINDINGS 49 figure stands. + +**Still open in P2:** unchanged — the encoder does not reserve a black entry +(23.4), so the letterbox still gets the palette's closest thing to black. + +**Next:** P5 (per-record index, prefill policy, the accumulated-slack rule in the +player rather than the rig) is buildable here and is now the last M2 item that +is. G1 (import the scene graph) still needs fetching, and is still the one that +would let this tree ask what the worst gap between consecutive decision points +is. P4 still decides the project and still cannot be measured here. + +**A question 54.4 raises and does not answer:** every rate-control and budget +figure in this project is fitted to an 833,333-clock frame, and 37.9% of frames +get 721,270. Whether the encoder should be fitted to the SHORT slot instead of +the mean is a re-encode plus a re-measurement — the same class of change as the +reserved black entry — and it should be decided with P5's numbers in hand, not +before. + +--- + # Status & next-session handoff — end of session 21 (2026-08-24) ## Session 21: the loader moves onto the 68000, and a scene change gets a price @@ -68,7 +182,8 @@ what this project is short of. **Derived, not measured.** 53.6. gains a `--loadraw` mode, which also makes its flag-watch address a variable rather than a constant. `check.sh` gains a stage that gates byte-exactness on both cores, and deliberately does **not** gate the cycle counts — MAME's clock -is 1/55.46 s and a wall timing would make the green light host-sensitive, the +is 1/56.69 s (1/55.46 when that was written; 54.5) and a wall timing would make +the green light host-sensitive, the same reason `blit.s` and `span.sh` are not in it. **`decode.s` and `stream.s` are unchanged.** Nothing in the per-frame path was diff --git a/src/player/clock.i b/src/player/clock.i new file mode 100644 index 0000000..0845660 --- /dev/null +++ b/src/player/clock.i @@ -0,0 +1,202 @@ +; --------------------------------------------------------------------------- +; clock.i -- the FRAME CLOCK, on the 68000 itself. ROADMAP P3. +; +; src/player/stream.s has a pace gate: frame i may not START before tick i, and +; PACE is the tick counter. Until now PACE was written by tools/bench/ +; stream.lua, i.e. by the host, off the host's idea of what 12 fps means. That +; was honest for what FINDINGS 49/51 were measuring -- arrival times against a +; deadline -- and it is not a player. A player has no host. These are the +; bytes that replace it. +; +; WHAT THE MACHINE ACTUALLY OFFERS, because "use the MFP timer or vblank" hides +; a real constraint. The MC68901's timer clock on this board is 16 MHz / 4 = +; 4 MHz (MAME 0.277 src/mame/sharp/x68k.cpp:1027-1028), its prescaler ladder is +; {4, 10, 16, 50, 64, 100, 200} (src/devices/machine/mc68901.cpp:173) and its +; data register is 8 bits. The SLOWEST tick a single MFP timer can produce is +; therefore 4e6 / (200*256) = 78.125 Hz, and 4e6/12 = 333,333.33 is not even an +; integer -- so no prescaler/data pair ticks at 12 Hz, and no timer at any +; setting ticks as slowly as a 12 fps frame. A frame clock needs a divider in +; software whichever source it is built on. tools/analysis/23_frame_clock.py +; enumerates the whole space rather than asserting this. +; +; So the source is the RASTER, and that is a better answer than a timer anyway. +; GPIP4 on the MFP is V-DISP (x68k.cpp:1139, `m_crtc->vdisp_cb().set(i4_w)`), +; high while the display is active; the same signal is the MFP's Timer A event +; input (mc68901.cpp:167, GPIO_TIMER = {GPIP_4, GPIP_3}). Its interrupt is +; channel 6, IR_GPIP_4 = $40 in IERB/IPRB/IMRB (mc68901.cpp:76). We take the +; FALLING edge (AER bit 4 = 0), which is the start of vertical blanking -- the +; instant a player would present a finished frame, so the clock and the flip +; are the same event rather than two events with a phase between them. +; +; THE DIVIDER IS EXACT, AND IT IS EXACT BY CONSTRUCTION. The raster is +; +; 31,500 lines/s / (R04 + 1) lines/frame +; +; and 31,500 / 568 = 55.4577 Hz is not a multiple of 12, so a whole-number +; divide cannot do it: 4 refreshes is 13.87 fps and 5 is 11.09 fps. Instead +; each V-DISP adds `fps * (R04+1)` to an accumulator and a frame tick is emitted +; whenever it reaches 31,500, keeping the remainder: +; +; acc += fps*VTOTAL ; if acc >= HFREQ: acc -= HFREQ ; PACE += 1 +; +; Over VTOTAL/gcd raster frames that emits exactly fps*VTOTAL/gcd ticks, so the +; long-run rate is fps*VTOTAL/VTOTAL = fps EXACTLY, with a bounded remainder and +; ZERO accumulated drift -- not 12.0001, not 11.9998. It holds for any fps and +; any vertical geometry, which is why the two constants are READ OUT OF THE +; CRTC at init rather than assembled in: the clock is derived from the same +; registers that generate the raster it is counting, so the two cannot disagree. +; +; WHAT IT COSTS IN CADENCE, WHICH IS THE PART THAT IS NOT FREE. 12 fps on a +; 55.4577 Hz raster is 4.6215 refreshes per frame, so a frame is shown for +; either 4 or 5 refreshes -- 72.13 ms or 90.16 ms. Nothing can change that; +; it is the display's quantisation, not the clock's error, and a timer-derived +; clock would have exactly the same cadence with an arbitrary phase against the +; raster on top. It does mean the slot a frame gets is NOT always the 83.33 ms +; every budget in this project is priced against, and the short slot is 13.4% +; under it. tools/analysis/23_frame_clock.py prices that; do not read this file +; as a claim that the clock made the budget bigger. +; +; INTERRUPTS, AND WHAT HAD TO BE TURNED OFF. The rigs launch the 68000 at +; SR=$2700 with everything masked, into a machine the IPL ROM has already booted +; -- so the MFP arrives with whatever IOCS enabled on it (keyboard receive, +; Timer C, its own V-DISP handler) and vectors pointing into IOCS. Lowering the +; mask without disarming the MFP would vector into code we did not put there. +; clk_init therefore writes IERA = IERB = 0 first, which on the MC68901 also +; clears the matching pending bits (mc68901.cpp REGISTER_IERA/B: `m_ipr &= +; m_ier`), and only then arms GPIP4 alone. Levels 1-5 stay masked at SR=$2500, +; so the DMAC (IRQ3) and the SCC (IRQ5) cannot get in either; level 7 is the +; front-panel NMI and is not ours to mask. +; +; The vector is the MFP's own: VR is written with the S bit CLEAR, so the +; in-service register is not used and an acknowledge clears the pending bit by +; itself (mc68901.cpp get_vector). No end-of-interrupt write in the handler. +; --------------------------------------------------------------------------- + +; --- MFP registers. The device sits on D0-D7 of a 16-bit bus (x68k.cpp:793, +; `.umask16(0x00ff)`), so register n is one BYTE at $E88001 + 2n. +MFP = $E88001 +MFP_GPIP = MFP+0*2 +MFP_AER = MFP+1*2 +MFP_DDR = MFP+2*2 +MFP_IERA = MFP+3*2 +MFP_IERB = MFP+4*2 +MFP_IPRA = MFP+5*2 +MFP_IPRB = MFP+6*2 +MFP_ISRA = MFP+7*2 +MFP_ISRB = MFP+8*2 +MFP_IMRA = MFP+9*2 +MFP_IMRB = MFP+10*2 +MFP_VR = MFP+11*2 + +MFP_GPIP4 = 4 ; bit number of V-DISP in GPIP/AER/DDR +MFP_IVDISP = $40 ; IR_GPIP_4, channel 6, in IERB/IPRB/IMRB +MFP_VBASE = $40 ; vector base; S clear -> no in-service register +CLK_VEC = (MFP_VBASE+6)*4 ; $118: MFP channel 6 vector, as an ADDRESS + +CRTC = $E80000 +CRTC_R04 = CRTC+4*2 ; V total, in scanlines, minus one +CRTC_R20 = CRTC+20*2 ; mode; bit 4 = 31.5 kHz +HFREQ = 31500 ; lines/s in the 31.5 kHz modes. Exact: the + ; 768-wide IPL mode is 34.776 MHz / 1104 dots + ; and the 256-wide mode 11.592 MHz / 368, both + ; 31500.0 (tools/bench/crtc_mode.lua). + +; --- state. PACE is stream.s's, deliberately: the whole point is that the +; 68000 now writes the word the host used to write, and the pace gate that +; reads it does not change by a single byte. +CLK_PACE = $18034 ; == stream.s PACE +CLK_ACC = $18060 ; word: Bresenham remainder, < HFREQ +CLK_INCR = $18062 ; word: fps * (R04+1), computed by clk_init +CLK_VDISP = $18064 ; long: V-DISP edges taken. An INSTRUMENT -- + ; it is what lets a rig check that the tick + ; count and the raster count are the same clock. +CLK_FPS = $18068 ; long: requested fps, an argument to clk_init +CLK_ERR = $1806C ; long: 0 ok / 1 not a 31.5 kHz mode + ; / 2 fps*VTOTAL would overflow 16 bits + +; --------------------------------------------------------------------------- +; clk_init -- arm the frame clock. Reads CLK_FPS, leaves CLK_ERR. +; Clobbers d0-d2. Leaves the CPU at SR=$2500 on success. +; --------------------------------------------------------------------------- +clk_init: + clr.l CLK_ERR.l + clr.l CLK_VDISP.l + clr.w CLK_ACC.l + clr.l CLK_PACE.l + +; The mode has to be the one HFREQ describes. A 15 kHz mode would halve the +; line rate and the divider would run at double speed while looking correct, +; which is the failure this test exists to prevent. + move.w CRTC_R20.l,d0 + btst #4,d0 + bne.s .modeok + move.l #1,CLK_ERR.l + rts +.modeok: +; VTOTAL and the increment. Both out of the CRTC, so a change of mode changes +; the clock with it. acc is 16-bit and reaches at most HFREQ-1+incr, so incr +; must leave room: 65536 - 31500 = 34036. At VTOTAL=568 that is fps < 59.9, +; which is every rate this machine can display anyway -- but it is checked +; rather than argued. + move.w CRTC_R04.l,d0 + addq.w #1,d0 ; VTOTAL scanlines + move.w d0,d1 + move.w CLK_FPS+2.l,d2 ; low word of the long + mulu d2,d1 ; fps * VTOTAL (see FINDINGS 53.4 on + ; C68K's flat MULU charge; this is boot + ; code and is not cost-measured there) + cmp.l #65536-HFREQ,d1 + bcs.s .fitok + move.l #2,CLK_ERR.l + rts +.fitok: + move.w d1,CLK_INCR.l + +; The vector, before the source is armed. + move.l #clk_isr,CLK_VEC.w + +; Disarm everything the IPL left running, then arm GPIP4 alone. Order matters: +; IER first (which clears IPR with it), then the edge, then the mask. + move.b #0,MFP_IERA.l + move.b #0,MFP_IERB.l + move.b #0,MFP_IMRA.l + move.b #MFP_VBASE,MFP_VR.l ; S clear: acknowledge clears pending + bclr #MFP_GPIP4,MFP_DDR.l ; V-DISP is an input + bclr #MFP_GPIP4,MFP_AER.l ; interrupt on the FALLING edge, i.e. + ; at the start of vertical blanking + move.b #MFP_IVDISP,MFP_IERB.l + move.b #MFP_IVDISP,MFP_IMRB.l + move.w #$2500,sr ; let level 6 in; 1-5 stay masked + rts + +; --------------------------------------------------------------------------- +; clk_stop -- disarm, and put the mask back where the rigs expect it. +; --------------------------------------------------------------------------- +clk_stop: + move.w #$2700,sr + move.b #0,MFP_IERB.l + move.b #0,MFP_IMRB.l + rts + +; --------------------------------------------------------------------------- +; clk_isr -- one V-DISP. Every instruction here is charged to every frame the +; decoder draws, so it is deliberately the shortest thing that is still exact: +; four word operations and one long increment. +; +; Only the LOW WORD of d0 is touched, so only the low word is saved. The +; accumulator, the increment and the threshold all fit in 16 bits by the check +; in clk_init, which is what makes that legal. +; --------------------------------------------------------------------------- +clk_isr: + move.w d0,-(sp) + addq.l #1,CLK_VDISP.l + move.w CLK_ACC.l,d0 + add.w CLK_INCR.l,d0 + cmp.w #HFREQ,d0 + bcs.s .nf + sub.w #HFREQ,d0 + addq.l #1,CLK_PACE.l +.nf: + move.w d0,CLK_ACC.l + move.w (sp)+,d0 + rte diff --git a/src/player/clockgate.s b/src/player/clockgate.s new file mode 100644 index 0000000..6d3fcfc --- /dev/null +++ b/src/player/clockgate.s @@ -0,0 +1,59 @@ +; Front-end for the frame clock (ROADMAP P3), for the rig. +; +; It exists to answer two questions that the streaming rig cannot answer on its +; own, because there the clock is buried under a decoder: +; +; 1. does the tick actually come from the raster, and at exactly the rate +; asked for -- measured over thousands of refreshes, not four; +; 2. WHAT IT COSTS, in clocks, per interrupt. This project's currency is +; 68000 clocks and the decoder already occupies 86.7% of the bus, so a +; frame clock is not free until someone has priced it. +; +; THE INSTRUMENT, and why it is a busy loop. MAME's Lua only sees the machine +; at frame boundaries, so it can time to 1/55.46 s and no finer -- 18 ms, where +; the whole per-frame cost of this clock is microseconds. Differencing two +; wall timings would measure nothing. So the 68000 counts instead: a loop with +; ONE instruction in its body runs for a fixed number of refreshes, and the +; iteration count is read out at both ends. +; +; clock off: iters0 * L = clocks in the window -> L +; clock on: iters1 * L + ints * H = clocks in the window -> H +; +; The window is an exact number of raster frames, so its length in clocks is +; exact and does not depend on the host at all. L is calibrated out by the +; first run rather than assumed from a cycle table, which matters: the point of +; the exercise is to price this code on the machine that will run it, and a +; table is the thing being checked. With ~2.7e7 iterations behind it, L carries +; enough digits that the interrupt cost -- 0.08% of the window -- survives the +; subtraction. +; +; The body is `addq.l #1,CGCNT.l` and nothing else: no compare, no counter in a +; register that an interrupt could be accused of disturbing, and a value that +; the host can read at any moment without stopping the CPU. +; +; The gate does NOT decode anything. What the clock does to a real frame is +; tools/bench/stream.lua's question, with DLX_PACE=2. + +CGFLAG = $18070 ; 0 idle / 1 running / $EE clk_init refused +CGON = $18074 ; 1 = arm the frame clock, 0 = leave it off +CGCNT = $18078 ; <- loop iterations, read by the host at both + ; ends of the window + + org $10000 +start: + clr.l CGCNT.l + move.l CGON.l,d0 + beq.s noclk + bsr clk_init + tst.l CLK_ERR.l + bne.s bad +noclk: + move.l #1,CGFLAG.l ; the host starts its window here +loop: + addq.l #1,CGCNT.l + bra.s loop +bad: + move.l #$EE,CGFLAG.l +hold: bra.s hold + + include "src/player/clock.i" diff --git a/src/player/loadgate.s b/src/player/loadgate.s index f81e153..956c8c1 100644 --- a/src/player/loadgate.s +++ b/src/player/loadgate.s @@ -4,7 +4,8 @@ ; does nothing the shipping player would not do, so that the bytes being ; measured are the bytes that will ship. The player's own boot path will call ; do_load once with the mode bits set to 3; this repeats it LITER times so a -; host clock with 1/55.46 s granularity can time a job that takes milliseconds, +; host clock with 1/56.69 s granularity (tools/bench/crtc_mode.lua) can time a +; job that takes milliseconds, ; and splits it by LMODE so the codebook expansion and the palette pack can be ; priced apart. A player calls do_load with mode 7 once at boot -- the three ; scratch tables describe the machine, not the scene -- and with mode 3 at every diff --git a/src/player/stream.s b/src/player/stream.s index c2f8b88..d7337c1 100644 --- a/src/player/stream.s +++ b/src/player/stream.s @@ -81,6 +81,18 @@ PACE = $18034 ; producer -> decoder: frame ticks elapsed since ; release. Frame i may not START before tick i. PACEON = $18038 ; 1 = obey PACE. 0 leaves the loop free-running, ; byte for byte the loop FINDINGS 49 measured. +LATEFR = $18080 ; frames that reached the pace gate with their + ; tick ALREADY ARRIVED, i.e. did not idle for a + ; single poll -- the previous frame used its + ; whole slot. See the gate below. +LATEMAX = $18084 ; the worst of those, in WHOLE ticks overrun +LATE1ST = $18088 ; index of the FIRST such frame, so that a + ; count can be told apart from a start-up + ; transient without re-running anything +CLKON = $1803C ; 1 = the 68000 paces ITSELF: src/player/clock.i + ; drives PACE off the CRTC's V-DISP instead of + ; the host writing it. Needs PACEON=1; the gate + ; below cannot tell the two apart and must not. DESC = $18100 ; DESCN x u32, record base addresses DESCN = 64 ; power of two; the index is masked, not compared @@ -92,12 +104,27 @@ SPINMAX = 2000000 ; polls with no progress before giving up org $10000 start: +; ---- the frame clock, if this run is asking the 68000 to keep its own time. +; It goes here rather than inside the frame loop because clk_init CLEARS PACE: +; tick 0 has to be the instant the decoder was released, exactly as it is when +; the host writes PACE, or the first frame's deadline moves. + tst.l CLKON.l + beq.s noclk + bsr clk_init + tst.l CLK_ERR.l + beq.s noclk + move.l #$E2,FLAG.l ; the clock refused; CLK_ERR says why + bra hold +noclk: move.l #1,FLAG.l ; timer starts here outer: move.l NFR.l,SCR_N.l clr.l FR_TAIL.l clr.l STALLS.l clr.l SPINS.l + clr.l LATEFR.l + clr.l LATEMAX.l + move.l #-1,LATE1ST.l frameloop: ; ---- PACE GATE (FINDINGS 49.7.2, and it is the whole point of this session). ; Free-running, this loop asks for record i the instant it finishes record i-1, @@ -117,8 +144,44 @@ frameloop: ; ; It also makes STALLS mean something. Free-running, a stall is EARLINESS ; (49.6); paced, a frame that has to wait for its record is a real underrun. +; +; AND IT COUNTS THE FRAMES THAT WERE ALREADY LATE, which is a question only a +; REAL frame clock raises. 12 fps on a 55.4577 Hz raster is 4.6215 refreshes +; per frame, so the divider hands out slots of 4 refreshes (72.13 ms) and 5 +; (90.16 ms), 37.9% of them short -- and the SHORT one is 13.4% under the +; 83.33 ms every budget in this project is priced against (FINDINGS 54). A +; frame that does not fit its slot does not fail here: PACE has already moved +; on, so the next frame starts the instant this one finishes and the clock +; catches up by itself. What it costs is one late PRESENT, and nothing in this +; tree counted those because until now the tick was a host model with no +; cadence in it at all. +; +; The test is "did this frame have to WAIT", not "is it a whole tick behind". +; Frame i waits while PACE < i; so PACE >= i on arrival means the decoder came +; to the gate with slot i already open and idled for zero polls, which is the +; same statement as "frame i-1 ran to the end of its slot". A whole tick of +; overrun -- PACE - FR_TAIL >= 1 -- is the much rarer case where it ran past +; the end of the NEXT one, and is reported separately as the worst seen. +; +; Frame 0 is excluded: it starts at tick 0 by definition and has no predecessor +; to have overrun. The wait loop below is untouched -- all of this is ahead of +; it, and the free-running path executes none of it. tst.l PACEON.l beq.s nopace + move.l PACE.l,d0 + cmp.l FR_TAIL.l,d0 ; PACE < FR_TAIL: the slot has not come + bcs.s pacewait ; round yet, so this frame is EARLY + tst.l FR_TAIL.l + beq.s pacewait ; frame 0 starts AT tick 0 by definition + tst.l LATEFR.l + bne.s .nf1 + move.l FR_TAIL.l,LATE1ST.l +.nf1: + addq.l #1,LATEFR.l + sub.l FR_TAIL.l,d0 ; whole ticks overrun; 0 = inside the + cmp.l LATEMAX.l,d0 ; slot but with nothing left of it + bls.s pacewait + move.l d0,LATEMAX.l pacewait: move.l PACE.l,d0 cmp.l FR_TAIL.l,d0 ; d0 - FR_TAIL; carry = tick not reached @@ -176,9 +239,17 @@ nostall: bne frameloop subq.l #1,ITER.l bne outer +; ---- leave the MFP as it was found. A rig that exits with a live interrupt +; source and a lowered mask hands the next thing that runs an interrupt it +; has no vector for, and the failure would land somewhere else entirely. + tst.l CLKON.l + beq.s noclk2 + bsr clk_stop +noclk2: move.l #$FF,FLAG.l ; timer stops here hold: bra.s hold desync: move.l #$EE,FLAG.l bra.s hold include "src/player/frame.i" + include "src/player/clock.i" diff --git a/tools/analysis/23_frame_clock.py b/tools/analysis/23_frame_clock.py new file mode 100644 index 0000000..9e1cb53 --- /dev/null +++ b/tools/analysis/23_frame_clock.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""What a real frame clock can be built from, and what its cadence costs. + + python3 tools/analysis/23_frame_clock.py [--fps 12] [--vtotal 568] + [--csv tmp/c68k_frames.csv] + +ROADMAP P3 says "needs MFP timer or VBL", which hides the fact that ONE OF +THOSE CANNOT DO IT and the other cannot do it either without a divider. This +file enumerates the space rather than asserting a conclusion, the way FINDINGS +47 had to be re-done once a hardware "no" turned out to be a claim about a whole +configuration space nobody had walked. + +EVERY CONSTANT HERE IS SOURCED, and from a file on this machine: + + MFP timer clock 16 MHz / 4 MAME 0.277 sharp/x68k.cpp:1027-1028 + prescaler ladder 4,10,16,50,64,100,200 machine/mc68901.cpp:173 + timer data reg 8 bits, 0 means 256 machine/mc68901.cpp (TCDR/TADR) + V-DISP -> GPIP4 x68k.cpp:1139, and it is also Timer A's event input, + mc68901.cpp:167 GPIO_TIMER = {GPIP_4, GPIP_3} + line rate 31,500 Hz exactly in both 31.5 kHz modes, derived in + tools/bench/crtc_mode.lua from the dot clocks + interrupt cost MEASURED, not tabled: tools/bench/clock_run.sh + +THE PART THAT IS NOT A CLOCK PROBLEM AT ALL. 12 fps on a 55.4577 Hz raster is +4.6215 refreshes per frame, so every frame is shown for 4 refreshes or 5 -- +72.13 ms or 90.16 ms -- and 37.9% of them get the short one. That is the +display's quantisation and no choice of clock changes it. What it changes is +the BUDGET: 833,333 clocks is the mean slot, not the slot, and the short slot is +721,270. With the per-frame decode costs in hand this file says exactly how +many frames do not fit theirs, which is a thing this project has never had to +ask because until now the tick came from a host that could not miss. +""" +import argparse +import csv +import os +import sys + +MFP_HZ = 16_000_000 // 4 # x68k.cpp:1027-1028 +PRESCALER = [4, 10, 16, 50, 64, 100, 200] # mc68901.cpp:173 +HFREQ = 31500 # lines/s, crtc_mode.lua +CPUHZ = 10_000_000 # 40 MHz / 4, x68k.cpp:1133 + + +def timer_space(fps): + """Every (prescale, data) the MFP can be set to, against a target fps.""" + slowest = MFP_HZ / (PRESCALER[-1] * 256) + print(f"\n=== 1. THE MFP TIMER, WHICH CANNOT DO IT ALONE") + print(f" timer clock {MFP_HZ:,} Hz, prescalers {PRESCALER}, data 1..256") + print(f" slowest tick any single timer can produce: " + f"{MFP_HZ}/({PRESCALER[-1]}*256) = {slowest:.3f} Hz") + print(f" a {fps} fps frame needs {fps} Hz, which is {slowest/fps:.1f}x " + f"slower than that -- so a software divider is REQUIRED whatever the " + f"source, and 'use an MFP timer' is not by itself an answer.") + exact = [(p, d) for p in PRESCALER for d in range(1, 257) + if (MFP_HZ * d * p) and (MFP_HZ % (p * d) == 0) + and (MFP_HZ // (p * d)) % fps == 0] + print(f" settings whose tick rate is a whole multiple of {fps} Hz, so that " + f"a plain counter would be exact: {len(exact)}") + if exact: + best = min(exact, key=lambda pd: MFP_HZ / (pd[0] * pd[1])) + p, d = best + tick = MFP_HZ / (p * d) + print(f" slowest of them: prescale /{p} data {d} = {tick:.4f} Hz, " + f"{tick/fps:.0f} ticks per frame") + print(f" -> {tick/fps:.0f} interrupts per frame, against 4.6215 for " + f"the raster: {tick/fps/(HFREQ/(fps*568)):.1f}x the cost, and its " + f"phase against the raster is arbitrary, so a frame would be " + f"presented mid-scan.") + else: + print(f" NONE. {MFP_HZ}/{fps} = {MFP_HZ/fps:,.2f} is not an " + f"integer, so no prescale/data pair divides to {fps} Hz at all.") + + +def raster_space(fps, vtotal): + hz = HFREQ / vtotal + print(f"\n=== 2. THE RASTER, WHICH IS THE RIGHT SOURCE AND IS ALSO NOT " + f"A WHOLE DIVIDE") + print(f" V-DISP is {HFREQ}/{vtotal} = {hz:.4f} Hz, and it is BOTH the " + f"GPIP4 interrupt and Timer A's event-count input") + print(f" whole divides -- all the MFP can do in hardware, no software:") + for n in (3, 4, 5, 6): + f = hz / n + print(f" Timer A event count = {n}: {f:7.4f} fps " + f"({100*(f/fps-1):+6.2f}% from {fps})") + print(f" {fps} fps needs {hz/fps:.4f} refreshes per frame, which is not a " + f"whole number, so no event-count setting is exact either.") + print(f"\n THE DIVIDER THAT IS EXACT: add fps*VTOTAL = {fps*vtotal} per " + f"V-DISP, emit a tick at {HFREQ}, keep the remainder.") + print(f" long-run rate = {fps}*{vtotal}/{vtotal} = {fps} fps EXACTLY, " + f"with a remainder that never accumulates") + print(f" the accumulator stays under {HFREQ + fps*vtotal:,}, so it is " + f"16-bit arithmetic on a 68000 (the ceiling is fps < " + f"{(65536-HFREQ)/vtotal:.1f} at this VTOTAL, and clk_init checks it)") + + +def divider_gaps(fps, vtotal, n): + """The tick sequence src/player/clock.i emits, in refreshes per tick.""" + acc, gaps, since = 0, [], 0 + while len(gaps) < n: + acc += fps * vtotal + since += 1 + if acc >= HFREQ: + acc -= HFREQ + gaps.append(since) + since = 0 + return gaps + + +def host_gaps(fps, refresh_hz, n): + """The tick sequence tools/bench/stream.lua's HOST clock emits. + + It looks uniform in the source -- `floor((t - t_rel) * fps)` -- and is not. + Lua only sees the machine at frame boundaries, so tick k lands on the first + refresh at or after k/fps, and the gaps between ticks come out as the same + two whole numbers of refreshes the divider produces. The host-paced runs of + FINDINGS 49 and 51 therefore already had this cadence in them; what ROADMAP + P3 changes is who produces it, not whether it exists. + """ + at = [-(-int(k * refresh_hz * 1000000 // fps) // 1000000) for k in range(n + 1)] + return [at[k + 1] - at[k] for k in range(n)] + + +def cadence(fps, vtotal, costs, label, refresh_hz, gaps, uniform=False): + """Charge each frame the slot it really gets, and run the pace gate.""" + rpf = 1.0 if uniform else HFREQ / (fps * vtotal) + lo, hi = (1, 1) if uniform else (int(rpf), int(rpf) + 1) + slot_lo = lo / refresh_hz * CPUHZ + slot_hi = hi / refresh_hz * CPUHZ + nominal = CPUHZ / fps + + n_lo = sum(1 for g in gaps[:len(costs)] if g == lo) + print(f"\n --- {label}: refresh {refresh_hz:.4f} Hz") + print(f" slots are {lo} refreshes = {slot_lo:,.0f} clk " + f"({1000*lo/refresh_hz:.2f} ms) or {hi} = {slot_hi:,.0f} clk " + f"({1000*hi/refresh_hz:.2f} ms)") + print(f" the nominal {fps} fps budget every figure in this project is " + f"priced against is {nominal:,.0f} clk; the SHORT slot is " + f"{100*(slot_lo/nominal-1):+.1f}% of it") + over_lo = sum(1 for c in costs if c > slot_lo) + over_hi = sum(1 for c in costs if c > slot_hi) + over_nom = sum(1 for c in costs if c > nominal) + print(f" frames that do not fit: {over_lo}/{len(costs)} the short " + f"slot, {over_hi}/{len(costs)} the long one, {over_nom}/{len(costs)} " + f"the nominal budget") + + # The schedule, with the catch-up the pace gate actually performs: frame i + # starts at max(finish of i-1, tick i). A frame that overruns does not fail + # -- it eats the next frame's idle, and the clock catches up by itself. + t, tick, late, worst, first = 0.0, 0.0, 0, 0.0, None + for i, c in enumerate(costs): + if t <= tick: + t = tick # idled: on time + else: + if i: # frame 0 has no predecessor + late += 1 + if first is None: + first = i + worst = max(worst, t - tick) + t += c + tick += gaps[i] / refresh_hz * CPUHZ + print(f" through the pace gate: {late}/{len(costs)} frames found " + f"their slot already open (first at frame {first}), worst start " + f"{worst:,.0f} clk = {1000*worst/CPUHZ:.1f} ms behind its tick") + if not uniform: + print(f" {n_lo}/{len(costs)} slots were the short one " + f"({100*n_lo/len(costs):.1f}%; the exact share is " + f"{100*(hi-rpf):.1f}%)") + return late + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--fps", type=int, default=12) + ap.add_argument("--vtotal", type=int, default=568, + help="CRTC R04+1; 568 is the 31.5 kHz 256-line mode") + ap.add_argument("--csv", default="tmp/c68k_frames.csv", + help="per-frame decode cost, from tools/bench/c68k/run.sh") + a = ap.parse_args() + + timer_space(a.fps) + raster_space(a.fps, a.vtotal) + + print(f"\n=== 3. WHAT THE CADENCE COSTS") + if not os.path.exists(a.csv): + print(f" {a.csv} not found -- run tools/bench/c68k/run.sh first. The " + f"cadence question CANNOT be answered from percentiles: it needs " + f"the per-frame series, because what matters is whether an " + f"expensive frame lands in a short slot and how long the catch-up " + f"takes afterwards.") + return 1 + costs = [float(r["cycles"]) for r in csv.DictReader(open(a.csv))] + print(f" {len(costs)} frames from {a.csv}: mean {sum(costs)/len(costs):,.0f} " + f"clk, max {max(costs):,.0f} (frame {costs.index(max(costs))})") + + hw = HFREQ / a.vtotal + n = len(costs) + # MAME's screen is fast by htotal/(htotal-8): refresh_mode() builds the + # frame period from scr.max_x*scr.max_y with scr.max_x = m_htotal - 8. Both + # rasters are run, because the rig measures against the fast one and the + # player will run on the other -- reporting only one would leave the rig's + # count and this file's differing with nobody able to say which was wrong. + htotal = 368 + mame = hw * htotal / (htotal - 8) + + # The budget model every figure in FINDINGS assumes: a slot of exactly + # 1/fps. No machine has this; it is the yardstick, run through the same + # schedule so that what the cadence ADDS can be read off. + cadence(a.fps, a.vtotal, costs, "the NOMINAL model (a slot of exactly " + "1/fps, which no raster produces)", float(a.fps), + [1] * (n + 1), uniform=True) + cadence(a.fps, a.vtotal, costs, "the HOST tick, as tools/bench/stream.lua " + "actually emits it", mame, host_gaps(a.fps, mame, n + 1)) + cadence(a.fps, a.vtotal, costs, "the 68000's own clock on the HARDWARE " + "raster", hw, divider_gaps(a.fps, a.vtotal, n + 1)) + cadence(a.fps, a.vtotal, costs, f"the 68000's own clock on MAME's raster " + f"(fast by {htotal}/{htotal-8})", mame, + divider_gaps(a.fps, a.vtotal, n + 1)) + + print(f""" +Reading it. + +THE CADENCE WAS ALREADY THERE. The nominal row is the model every budget in +this project is priced against -- a slot of exactly 1/fps -- and no raster +produces it. The host row is what tools/bench/stream.lua has been emitting all +along: `floor((t - t_rel) * fps)` looks uniform, but Lua only sees the machine at +frame boundaries, so its ticks land on refreshes and its gaps are the same two +whole numbers. The host-paced results of FINDINGS 49 and 51 therefore already +carried a 4/5 cadence that nothing named. ROADMAP P3 did not introduce it; it +moved who produces it onto the machine, where it belongs, and made it visible. + +THE SHORT SLOT IS REAL AND IT IS NOT A FAILURE. {sum(1 for c in costs if c > 721270)}/{len(costs)} frames do not fit +721,270 clocks. The pace gate only says "not before tick i", so a frame that +overruns spends the next frame's idle and the clock recovers by itself; the cost +is one frame presented a refresh late, not a dropped frame. What the counts +above measure is frames with no idle left, and the difference between the +nominal row and the raster rows -- 1 against 4 -- is the whole price of the +cadence on this container. + +THE EXPENSIVE FRAME IS FRAME 0, at {max(costs)/(CPUHZ/a.fps)*100:.0f}% of the nominal budget: the first +frame of a scene has nothing to SKIP against, so it is the whole picture in one +slot. Most of what follows it in these counts is that transient draining, which +is why the first index is printed next to the total. It also means the cost is +paid AT A SCENE CHANGE, alongside the 18.96 ms of loader (FINDINGS 53.2) and the +seek -- not spread over the window. + +SCOPE. Decode costs are C68K's, on zero-wait-state memory, so they are a lower +bound; real DRAM moves every row here in the same direction. The MAME rows are +the emulator's fast raster and exist to be compared with tools/bench/pace_run.sh, +not to describe hardware.""") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/bench/blit.lua b/tools/bench/blit.lua index 4222552..8002a8b 100644 --- a/tools/bench/blit.lua +++ b/tools/bench/blit.lua @@ -11,7 +11,8 @@ -- a LOWER BOUND, not a prediction. Interrupts are masked (SR=$2700) so the -- IPL's timer and VBL handlers cannot steal cycles into the measurement. -- --- Timing resolution is one video frame (1/55.46 s = 18.03 ms), because Lua +-- Timing resolution is one video frame (1/56.69 s = 17.64 ms -- MAME's, not +-- the hardware's 55.46; see crtc_mode.lua), because Lua -- gets no cycle counter -- luaengine.cpp exposes machine.time and nothing -- from device_execute_interface. Each variant therefore loops enough times -- to run ~4 emulated seconds, putting the granularity error near 0.4%. diff --git a/tools/bench/check.sh b/tools/bench/check.sh index 37828c6..fe8cd6d 100755 --- a/tools/bench/check.sh +++ b/tools/bench/check.sh @@ -286,7 +286,7 @@ echo "--- session 21: the 68000 builds its own codebooks and palette (FINDINGS 5 # # NOT gated on the cycle counts, and the reason is NOT the one blit.s has. These # are emulated time and reproduce exactly run to run; what they are not is -# sharp, because MAME samples them on a 1/55.46 s clock and the job takes +# sharp, because MAME samples them on a 1/56.69 s clock and the job takes # milliseconds. Nothing in the tree's cost models depends on them either. A # change in them is a re-derivation in FINDINGS 53, not a red light here. bash tools/bench/load_run.sh "$DLX" > tmp/load_gate.log 2>&1 || { @@ -294,4 +294,48 @@ bash tools/bench/load_run.sh "$DLX" > tmp/load_gate.log 2>&1 || { exit 1; } grep -aE "^ *OK|both CPU cores|SCENE CHANGE" tmp/load_gate.log | sed 's/^ *//;s/^/ /' +echo "--- session 22: the 68000 keeps its own frame clock (FINDINGS 54) ---" +# ROADMAP P3. Until now the 12 fps tick came from tools/bench/stream.lua -- a +# host writing a word into emulated RAM. A player has no host. src/player/ +# clock.i derives the tick from the CRTC's own V-DISP output through the MFP, +# with a remainder-keeping divider whose two constants are READ OUT OF THE CRTC +# at init, so the clock and the raster it counts cannot disagree. +# +# WHAT IS GATED, and it is deliberately structural rather than numeric: +# * the interrupt count equals the raster frame count -- the tick IS the +# raster, not something that merely resembles it; +# * the divider does not accumulate drift, stated in TICKS (a remainder can +# hold back at most one) rather than in ppm, which would let a longer +# window advertise a tighter clock for free; +# * every frame tick waits 4 or 5 refreshes and nothing else, which is what a +# remainder-keeping divider can produce and a broken one cannot. +# The interrupt COST is printed and not gated, for the same reason FINDINGS 53's +# cycle counts are not: it is a measurement, and a change in it is a +# re-derivation in FINDINGS 54 rather than a red light here. +bash tools/bench/clock_run.sh 3000 12 > tmp/clock_gate.log 2>&1 || { + echo "FAIL: the frame clock did not pass."; tail -12 tmp/clock_gate.log + exit 1; } +grep -aE "INTERRUPT:|PER FRAME:|DRIFT:|CADENCE:" tmp/clock_gate.log +grep -q "V-DISP interrupts 3000" tmp/clock_gate.log || { + echo "FAIL: the tick is not the raster -- the interrupt count and the frame" + echo " count disagree. Everything else in FINDINGS 54 rests on that." + exit 1; } + +echo "--- session 22: 120 frames decoded on the machine's own clock (FINDINGS 54) ---" +# The strongest form of the claim: the same pixel-exact 120-frame decode out of +# the same 256 KB ring, with NOTHING outside the machine deciding when a frame +# may start. The pace gate in src/player/stream.s is byte-for-byte the one +# FINDINGS 51 measured -- it cannot tell a host-written tick from a machine- +# written one, which is why this is a test of the clock and not of a new rig. +DLX_PACE=2 bash tools/bench/pace_run.sh 256 0 > tmp/selfpace_check.log 2>&1 || { + echo "FAIL: the self-paced pass did not complete."; tail -8 tmp/selfpace_check.log + exit 1; } +grep -aE "decoder SELF-PACED|FRAME CLOCK|UNDERRUNS|NO IDLE" tmp/selfpace_check.log +grep -q "UNDERRUNS: 0/120" tmp/selfpace_check.log || { + echo "FAIL: the self-paced decoder underran."; exit 1; } +grep -q "^OK" tmp/selfpace_check.log || { + echo "FAIL: the self-paced pass was not pixel-exact. The clock changed WHEN" + echo " frames start; if it changed WHAT they draw, the interrupt is" + echo " corrupting decoder state."; tail -4 tmp/selfpace_check.log; exit 1; } + echo "ALL GREEN" diff --git a/tools/bench/clock.lua b/tools/bench/clock.lua new file mode 100644 index 0000000..806d168 --- /dev/null +++ b/tools/bench/clock.lua @@ -0,0 +1,240 @@ +-- Drive src/player/clockgate.s: measure the 68000's own FRAME CLOCK. +-- ROADMAP P3. +-- +-- Two things are being measured and they need different instruments. +-- +-- THE RATE AND THE CADENCE are counted, not timed. The clock's tick is a +-- V-DISP interrupt, and MAME's Lua sees the machine once per screen frame -- +-- which is once per V-DISP. So the host's sampling granularity is exactly the +-- clock's own granularity, and the cadence comes out as integers: how many +-- refreshes each frame tick waited. There is no timing error to argue about +-- in a count of 4s and 5s. +-- +-- THE COST IS TIMED BY THE 68000, because the host cannot. 1/55.46 s of host +-- granularity is 18 ms and the interrupt costs microseconds. So the 68000 runs +-- a one-instruction loop for a window of thousands of refreshes and the host +-- reads the iteration count at both ends; the interrupt cost falls out of the +-- difference between a run with the clock armed and one without. See the head +-- of src/player/clockgate.s for the arithmetic. This script emits the raw +-- counts; tools/bench/clock_cost.py does the subtraction, so that the two runs +-- it needs can be separate MAME invocations. +-- +-- MEASUREMENT SCOPE. This is MAME 0.277's emulated X68000, not real hardware. +-- What is being priced is the interrupt sequence of MAME's cycle-accurate +-- M68000 core (src/devices/cpu/m68000, the `M68000` device x68k.cpp:1133 asks +-- for) against zero-wait-state RAM. Real DRAM adds wait states to the six bus +-- cycles of the exception and the four of the handler alike, so this is a LOWER +-- BOUND in the same way every other 68000 figure in this project is. +-- +-- Env: +-- DLX_CLK_ON 1 = arm the frame clock, 0 = leave it off (the calibration +-- run). REQUIRED -- the two runs are not interchangeable and a +-- default would let one be reported as the other. +-- DLX_CLK_FPS frame rate to ask clk_init for (default 12) +-- DLX_CLK_WIN measurement window, in raster frames (default 3000 = 54.1 s) +-- DLX_CLK_OUT where to write the raw counts (default tmp/clock_run.txt) + +M = manager.machine +SP = M.devices[":maincpu"].spaces["program"] + +local function findfile(n) + for _,p in ipairs{"../tools/bench/"..n, "tools/bench/"..n, n} do + local f = io.open(p,"rb"); if f then f:close(); return p end + end + error(n.." not found") +end +local MODE = loadfile(findfile("crtc_mode.lua"))() + +local CGFLAG, CGON, CGCNT = 0x18070, 0x18074, 0x18078 +local CLK_PACE = 0x18034 +local CLK_ACC, CLK_INCR = 0x18060, 0x18062 +local CLK_VDISP, CLK_FPS = 0x18064, 0x18068 +local CLK_ERR = 0x1806C +local CPUHZ = 10000000 + +local ONS = os.getenv("DLX_CLK_ON") +local FPS = tonumber(os.getenv("DLX_CLK_FPS") or "") or 12 +local WIN = tonumber(os.getenv("DLX_CLK_WIN") or "") or 3000 +local OUT = os.getenv("DLX_CLK_OUT") or "clock_run.txt" + +local function P(s) print("[CLK] "..s) end + +if ONS ~= "0" and ONS ~= "1" then + P("DLX_CLK_ON must be 0 (calibration, clock off) or 1 (clock armed). The " + .."cost figure is the DIFFERENCE between the two runs, so neither is " + .."meaningful alone and neither gets to be the default.") + M:exit() + return +end +local ON = (ONS == "1") + +local code do local f=io.open("clockgate.bin","rb"); code=f:read("a"); f:close() end + +local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end + +-- Settling frames between the gate reporting `running` and the window opening. +-- The CPU may still be inside clk_init when the host first sees CGFLAG=1, and +-- the first V-DISP edge after arming lands wherever the raster happens to be. +-- Two frames puts the window entirely inside the steady state. +local SETTLE = 2 + +local st, n = "boot", 0 +local f_ready, f0, f1 = nil, nil, nil +local c0, c1, v0, v1, p0, p1, t0, t1 +-- Cadence: refreshes between consecutive frame ticks. Recorded as a histogram +-- and as the raw first few, because the interesting claim is not the mean (the +-- divider makes that exact by construction) but that the SPREAD is only ever +-- the two values either side of fps*VTOTAL/HFREQ. +local last_pace, last_pace_f, cad, seen_tick = nil, nil, {}, false + +SUB = emu.add_machine_frame_notifier(function() + local ok, err = pcall(function() + n = n + 1 + if st == "boot" then + if T() < 3.0 then return end + MODE.apply(SP) + for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end + SP:write_u32(CGFLAG, 0) + SP:write_u32(CGON, ON and 1 or 0) + SP:write_u32(CLK_FPS, FPS) + local cpu = M.devices[":maincpu"] + cpu.state["SR"].value = 0x2700 -- clk_init lowers it to $2500 itself + cpu.state["SP"].value = 0x8000 + cpu.state["PC"].value = 0x10000 + P(string.format("clockgate.bin=%d B, clock %s, asking for %d fps, " + .."window %d raster frames", #code, + ON and "ARMED" or "OFF (calibration run)", FPS, WIN)) + st = "wait"; return + end + if st == "wait" then + local fl = SP:read_u32(CGFLAG) + if fl == 0xEE then + local e = SP:read_u32(CLK_ERR) + P("clk_init REFUSED: CLK_ERR="..e..(e == 1 and + " (CRTC is not in a 31.5 kHz mode, so HFREQ=31500 would be wrong)" or + e == 2 and " (fps*VTOTAL does not fit the 16-bit accumulator)" or "")) + M:exit(); return + end + if fl ~= 1 then + if T() > 60 then P("TIMEOUT: the gate never started"); M:exit() end + return + end + f_ready = n; st = "settle"; return + end + if st == "settle" then + if n < f_ready + SETTLE then return end + f0, t0 = n, T() + c0 = SP:read_u32(CGCNT) + v0 = SP:read_u32(CLK_VDISP) + p0 = SP:read_u32(CLK_PACE) + last_pace, last_pace_f = p0, n + if ON then + P(string.format("armed: incr=%d (fps*VTOTAL), acc=%d, first tick " + .."pending", SP:read_u16(CLK_INCR), + SP:read_u16(CLK_ACC))) + end + st = "run"; return + end + if st == "run" then + if ON then + local pc = SP:read_u32(CLK_PACE) + if pc ~= last_pace then + -- The FIRST change is dropped. Its interval runs from the window + -- opening rather than from a tick, so it measures where the window + -- happened to start and would show up as a spurious short bucket. + if seen_tick then + -- More than one tick in a single refresh would mean fps above the + -- raster rate; give it its own bucket rather than averaging it in. + local gap = n - last_pace_f + if pc - last_pace > 1 then gap = 0 end + cad[gap] = (cad[gap] or 0) + 1 + end + seen_tick = true + last_pace, last_pace_f = pc, n + end + end + if n < f0 + WIN then return end + f1, t1 = n, T() + c1 = SP:read_u32(CGCNT) + v1 = SP:read_u32(CLK_VDISP) + p1 = SP:read_u32(CLK_PACE) + st = "done" + + local frames = f1 - f0 + local secs = t1 - t0 + local clocks = secs * CPUHZ + local iters = c1 - c0 + local ints = v1 - v0 + local ticks = p1 - p0 + P(string.format("window: %d raster frames, %.6f s emulated -> %.0f " + .."68000 clocks", frames, secs, clocks)) + -- THE INSTRUMENT IS 2.22% FAST AND IT IS WORTH SAYING SO EVERY RUN. + -- The CRTC registers describe a 31,500 lines/s raster of VTOTAL lines. + -- MAME does not run it at that rate: x68k_crtc.cpp refresh_mode() + -- computes the frame period as (scr.max_x * scr.max_y) dots with + -- scr.max_x = m_htotal - 8, one character cell short and an INCLUSIVE + -- rectangle bound used as a count. So the emulated raster is fast by + -- htotal/(htotal-8) -- 368/360 in this mode -- and every rate derived + -- from it here is fast by the same factor. The divider under test is + -- built on the registers, so its HARDWARE rate is the asked-for one and + -- what this rig can check is that it tracks whatever raster it is given. + local vtotal = SP:read_u16(0xE80008) + 1 + local htotal = (SP:read_u16(0xE80000) + 1) * 8 + local hw_hz = 31500 / vtotal + local skew = htotal / (htotal - 8) + P(string.format(" raster period %.4f ms = %.4f Hz", 1000*secs/frames, + frames/secs)) + P(string.format(" the CRTC registers describe 31500/%d = %.4f Hz; " + .."MAME is fast by htotal/(htotal-8) = %d/%d = %.4f", + vtotal, hw_hz, htotal, htotal-8, skew)) + P(string.format(" loop iterations %d", iters)) + if ON then + P(string.format(" V-DISP interrupts %d, frame ticks %d", ints, + ticks)) + -- The self-check that makes the rest of it worth reading: the interrupt + -- count and the host's screen-frame count are supposed to be the SAME + -- clock seen from two sides. If they disagree by more than the one + -- edge the window boundaries can straddle, the tick is not the raster. + if math.abs(ints - frames) > 1 then + P(string.format("FAIL: %d V-DISP interrupts over %d raster frames. " + .."The tick is not coming from the raster.", ints, + frames)) + M:exit(); return + end + -- Two numbers, and confusing them is the whole trap. The measured rate + -- is against MAME's fast raster; dividing the skew out gives the rate + -- the same code produces on a machine whose raster matches its own + -- registers, which is the number the player is judged on. + local meas = ticks/secs + P(string.format(" measured rate %.6f fps against MAME's raster " + .."(%+.0f ppm vs the asked %d)", meas, + 1e6*(meas/FPS - 1), FPS)) + P(string.format(" de-skewed %.6f fps -> %+.1f ppm from %d, " + .."which is the tick quantisation of %d ticks and not " + .."drift", meas/skew, 1e6*(meas/skew/FPS - 1), FPS, + ticks)) + local ks = {} + for k in pairs(cad) do ks[#ks+1] = k end + table.sort(ks) + local s = "" + for _,k in ipairs(ks) do + s = s .. string.format("%d:%d ", k, cad[k]) + end + P(" cadence, refreshes per frame tick: "..s) + end + + local fh = io.open(OUT, "w") + fh:write(string.format("on %d\nfps %d\nframes %d\nsecs %.15g\n" + .."clocks %.15g\niters %d\nints %d\nticks %d\n" + .."vtotal %d\nhtotal %d\nhw_hz %.15g\nskew %.15g\n", + ON and 1 or 0, FPS, frames, secs, clocks, iters, + ints, ticks, vtotal, htotal, hw_hz, skew)) + for k, v in pairs(cad) do fh:write(string.format("cad %d %d\n", k, v)) end + fh:close() + P("counts -> "..OUT) + P("done") + M:exit(); return + end + end) + if not ok then print("[CLK] LUA ERROR: "..tostring(err)); M:exit() end +end) diff --git a/tools/bench/clock_cost.py b/tools/bench/clock_cost.py new file mode 100644 index 0000000..c2e6ab0 --- /dev/null +++ b/tools/bench/clock_cost.py @@ -0,0 +1,132 @@ +"""What the 68000's own frame clock costs, out of the two clock.lua runs. + +ROADMAP P3. Usage: clock_cost.py + +THE SUBTRACTION. Both runs execute the same one-instruction loop over a window +of the same number of raster frames, so the window is the same number of 68000 +clocks in both. With the clock off, every clock in the window went into loop +iterations: + + L = clocks / iters_off clocks per iteration + +With it armed, the interrupts took some of them: + + H = (clocks - iters_on * L) / ints clocks per V-DISP interrupt + +L is CALIBRATED rather than looked up. That is the point: this project's cost +model (tools/analysis/buscost.py) says a 68000 bus cycle is 4 clocks and an +instruction costs 4 * (instruction words + data accesses), and the whole reason +to measure is to avoid scoring the clock against the table the table is meant to +be checked by. L falling on a whole number of clocks is therefore a RESULT, not +an assumption, and it is reported as one. + +WHAT THE FIGURE IS PER FRAME. Not H -- the interrupt fires once per refresh and +a frame is several refreshes. On the hardware raster that is 31500/VTOTAL over +fps interrupts per frame, and the de-skewed rate is the one to use: MAME's +raster is fast by htotal/(htotal-8) (see tools/bench/clock.lua), and charging +the player the emulator's extra interrupts would overstate the cost by that +same 2.2%. +""" +import sys + + +def read(path): + d, cad = {}, {} + for line in open(path): + f = line.split() + if f[0] == "cad": + cad[int(f[1])] = int(f[2]) + else: + d[f[0]] = float(f[1]) + d["cad"] = cad + return d + + +def main(off_path, on_path): + off, on = read(off_path), read(on_path) + if off["on"] != 0 or on["on"] != 1: + sys.exit("FAIL: expected the calibration run first and the armed run " + "second; got on=%d then on=%d" % (off["on"], on["on"])) + for k in ("frames", "clocks", "fps", "vtotal"): + if off[k] != on[k]: + sys.exit("FAIL: the two runs do not share a window: %s is %g in " + "the calibration run and %g in the armed one" + % (k, off[k], on[k])) + + clocks = off["clocks"] + L = clocks / off["iters"] + ints = on["ints"] + H = (clocks - on["iters"] * L) / ints + + # The self-check that licenses the subtraction: the interrupt count must be + # the raster frame count. clock.lua already fails on this, restated here + # because this file is also read on its own. + if abs(ints - on["frames"]) > 1: + sys.exit("FAIL: %d interrupts over %g raster frames -- not the raster" + % (ints, on["frames"])) + + fps, skew = on["fps"], on["skew"] + hw_hz = on["hw_hz"] + per_frame_ints = hw_hz / fps + per_frame = H * per_frame_ints + FRAME_CLK = 10e6 / fps + + print(" calibration: %.6f clocks per loop iteration over %d iterations" + % (L, off["iters"])) + print(" (%s a whole number of clocks -- the loop is one " + "`addq.l #1,abs.l` at 7 bus cycles plus a `bra.s`)" + % ("lands on" if abs(L - round(L)) < 1e-3 else "does NOT land on")) + print(" INTERRUPT: %.2f clocks per V-DISP, measured over %d of them" + % (H, ints)) + print(" PER FRAME: %.2f interrupts x %.2f = %.0f clocks = %.4f%% of a " + "%g fps frame" % (per_frame_ints, H, per_frame, + 100 * per_frame / FRAME_CLK, fps)) + print(" (%.4f refreshes per frame on the HARDWARE raster of " + "31500/%d = %.4f Hz, not on MAME's, which is %.4fx fast)" + % (per_frame_ints, on["vtotal"], hw_hz, skew)) + + # THE DRIFT GATE, and it is stated in TICKS rather than in ppm on purpose. + # A remainder-keeping divider emits floor() or ceil() of the exact tick + # count over any window and never accumulates -- so the only honest + # tolerance is one tick, and any ppm figure is that one tick divided by + # however long the window happened to be. Quoting ppm would let a longer + # window advertise a tighter clock for no reason. + want = on["frames"] * fps * on["vtotal"] / 31500.0 + ticks = on["ticks"] + print(" DRIFT: %d ticks over %d refreshes; exact is %.4f, so the " + "error is %+.4f ticks" % (ticks, on["frames"], want, ticks - want)) + if abs(ticks - want) > 1.0: + sys.exit("FAIL: %d ticks where %.4f were due -- off by %.2f, which is " + "more than the one tick a remainder can hold back. The " + "divider is accumulating drift." % (ticks, want, ticks - want)) + + cad = on["cad"] + tot = sum(cad.values()) + if tot: + # Refreshes per frame is 31500 / (fps * VTOTAL) exactly -- the divider's + # own ratio, upside down. A remainder-keeping divider can only ever + # emit the two whole numbers either side of it, so anything else in the + # histogram is a bug in the divider and not a rounding taste. + rpf = 31500.0 / (fps * on["vtotal"]) + lo, hi = int(rpf), int(rpf) + 1 + print(" CADENCE: %s (%d intervals; %.4f refreshes per frame, so " + "only %d and %d are possible)" + % (", ".join("%dx%d (%.1f%%)" % (k, v, 100.0 * v / tot) + for k, v in sorted(cad.items())), tot, rpf, lo, hi)) + for k in cad: + if k not in (lo, hi): + sys.exit("FAIL: a frame tick waited %d refreshes, which a " + "remainder-keeping divider cannot produce" % k) + # The mix is forced too: lo*a + hi*b = refreshes, a + b = ticks. + b = tot * rpf - lo * tot + print(" expected %d:%d split %.1f%% / %.1f%%, got " + "%.1f%% / %.1f%%" + % (lo, hi, 100 * (tot - b) / tot, 100 * b / tot, + 100.0 * cad.get(lo, 0) / tot, 100.0 * cad.get(hi, 0) / tot)) + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit(__doc__) + sys.exit(main(sys.argv[1], sys.argv[2])) diff --git a/tools/bench/clock_run.sh b/tools/bench/clock_run.sh new file mode 100755 index 0000000..c15f8ab --- /dev/null +++ b/tools/bench/clock_run.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# One frame-clock run: the 68000 derives its own 12 fps tick from the raster +# (ROADMAP P3, FINDINGS 54). +# +# tools/bench/clock_run.sh [window-in-raster-frames] [fps] +# +# TWO MAME INVOCATIONS, and they are not interchangeable. The first leaves the +# clock off and calibrates the cost of the gate's own loop; the second arms it. +# The interrupt cost is the difference, so a run that reported only the second +# would be reporting a number it cannot compute. See src/player/clockgate.s. +# +# Only MAME can run this: the frame clock is an MFP interrupt driven by the +# CRTC's V-DISP output, and tools/bench/c68k has neither device. That is why +# this stage has no second-core half, unlike load_run.sh. +set -e +cd "$(dirname "$0")/../.." +WIN=${1:-3000} +FPS=${2:-12} + +tools/vasm/vasmm68k_mot -Fbin -o tmp/clockgate.bin src/player/clockgate.s > /dev/null + +for ON in 0 1; do + # stdbuf -oL: without it a long MAME run is unobservable until it exits, and + # a run that is merely finishing looks exactly like one that is wedged (34.1). + ( cd tmp && DLX_CLK_ON=$ON DLX_CLK_FPS=$FPS DLX_CLK_WIN=$WIN \ + DLX_CLK_OUT=clock_$ON.txt SDL_VIDEODRIVER=dummy stdbuf -oL \ + timeout -k 5 600 mame x68000 -bios ipl10 -ramsize 2M -video soft -window \ + -sound none -nothrottle -plugins -autoboot_script ../tools/bench/clock.lua \ + -seconds_to_run 240 > clock_$ON.log 2>&1 ) + # A run that never reached the counts must fail as that, not as bad arithmetic. + grep -q "^\[CLK\] done" tmp/clock_$ON.log || { + echo "FAIL: the clock rig did not finish run ON=$ON -- no completion marker." + tail -8 tmp/clock_$ON.log; exit 1; } +done +grep -a "^\[CLK\]" tmp/clock_1.log | sed -n '/window:/,/cadence/p' | sed 's/\[CLK\] / /' +python3 tools/bench/clock_cost.py tmp/clock_0.txt tmp/clock_1.txt diff --git a/tools/bench/crtc_mode.lua b/tools/bench/crtc_mode.lua index bff65c1..05e1bae 100644 --- a/tools/bench/crtc_mode.lua +++ b/tools/bench/crtc_mode.lua @@ -23,6 +23,26 @@ -- Total blanking time is identical to the 768 mode (112 dots @ 11.592MHz = -- 336 dots @ 34.776MHz = 9.66us), which is what a real monitor needs. -- +-- THE EMULATOR DOES NOT RUN THE RASTER THESE REGISTERS DESCRIBE, and every +-- rig in this tree samples the machine at ITS rate, not at the hardware's. +-- x68k_crtc.cpp refresh_mode() builds the frame period as +-- +-- (scr.max_x * scr.max_y) dots / dotclock, scr.max_x = m_htotal - 8 +-- +-- which is one character cell short AND uses an inclusive rectangle bound as a +-- count. So MAME's refresh is fast by htotal/(htotal-8) = 368/360 = 1.02222: +-- 56.6901 Hz where the registers say 55.4577. MEASURED, not read off the +-- source alone -- tools/bench/clock.lua reports both every run, and they agree +-- to six digits (FINDINGS 54.5). +-- +-- It matters in exactly two places and is harmless in the rest. Anything timed +-- by counting host frames has 1/56.69 s of granularity, not 1/55.46; and +-- anything PACED by the raster runs 2.22% fast under MAME. It does NOT touch +-- 68000 cycle figures: the CPU clock is 40 MHz/4 and has nothing to do with the +-- screen. Do not "correct" the 55.4577 below to match a measurement -- it is +-- the hardware's, derived from the dot clocks above, and it is what +-- src/player/clock.i builds its divider on. +-- -- VERTICAL registers are NOT halved. The CRTC still generates a 568-line -- 31.5kHz raster (31500/568 = 55.46 Hz); "256 lines" is a graphics-layer -- double-scan (draw_gfx() halves gfxrect, x68k_v.cpp:401). Halving them would diff --git a/tools/bench/decode.lua b/tools/bench/decode.lua index f5e4e64..bc08eaf 100644 --- a/tools/bench/decode.lua +++ b/tools/bench/decode.lua @@ -93,7 +93,7 @@ end -- The plan: one sequential correctness pass, then the cost anchors, then a -- full pass timed. Iteration counts target ~4 emulated seconds each so the --- 1/55.46 s timing granularity costs under 0.5%. +-- 1/56.69 s timing granularity (crtc_mode.lua) costs under 0.5%. -- DLX_VERIFY_ONLY=1 drops the cost anchors and runs only the correctness pass, -- so tools/bench/check.sh can gate the decoder without paying for ~2 minutes of -- timing runs that would make the green light sensitive to host load anyway. diff --git a/tools/bench/load.lua b/tools/bench/load.lua index fd5e202..6da55af 100644 --- a/tools/bench/load.lua +++ b/tools/bench/load.lua @@ -18,7 +18,7 @@ -- -- MEASUREMENT SCOPE, unchanged from decode.lua: MAME's memory carries no wait -- states, so these are pure 68000 instruction cycles -- a LOWER BOUND on real --- hardware. Interrupts are masked (SR=$2700). The host clock has 1/55.46 s +-- hardware. Interrupts are masked (SR=$2700). The host clock has 1/56.69 s -- granularity and the job takes milliseconds, so each configuration is repeated -- LITER times and divided; repeating is honest because do_load is not -- temporally recursive -- every pass rewrites what the last one wrote, from the diff --git a/tools/bench/pace_run.sh b/tools/bench/pace_run.sh index 19adbbc..b94e525 100755 --- a/tools/bench/pace_run.sh +++ b/tools/bench/pace_run.sh @@ -15,12 +15,23 @@ # # kbps 0 = unlimited pipe. There is no default rate anywhere in this tree # (FINDINGS 50) and there is none here either. +# +# DLX_PACE selects WHO KEEPS THE TIME: 1 (default) is the host writing the tick, +# 2 is the 68000 writing it off the CRTC's V-DISP (ROADMAP P3, FINDINGS 54). +# Everything else about the run is identical, which is the whole point -- the +# pace gate in src/player/stream.s cannot tell them apart, so a difference in +# the result is a difference in the CLOCK and not in the rig. set -e cd "$(dirname "$0")/../.." RING=${1:?ring KB}; KBPS=${2:?pipe KB/s, or 0 for unlimited} CUT_AT=$3; CUT_FR=${4:-1} DLX=${DLX:-tmp/rc_fr_singe_scsi_span.dlx} +PACE=${DLX_PACE:-1} TAG="r${RING}_k${KBPS}${CUT_AT:+_cut${CUT_AT}x${CUT_FR}}" +# The default tag is left ALONE when the host keeps the time: tools/bench/ +# pace_sweep.sh reads tmp/pace_r_k.log by name, and renaming the +# host-paced logs would break a sweep that has nothing to do with this option. +if [ "$PACE" != 1 ]; then TAG="${TAG}_p$PACE"; fi tools/vasm/vasmm68k_mot -Fbin -o tmp/stream.bin src/player/stream.s > /dev/null [ -f tmp/stream_disk.bin ] || python3 tools/bench/prep_stream.py "$DLX" > tmp/prep_stream.log @@ -29,7 +40,7 @@ mkdir -p "tmp/snap_pace_$TAG"; rm -f "tmp/snap_pace_$TAG/x68000"/*.png # of a prefix is not an assignment token, so bash takes the next word as the # command and the run dies with "SDL_VIDEODRIVER=dummy: command not found". CUTENV=(); [ -n "$CUT_AT" ] && CUTENV=(DLX_CUT_AT="$CUT_AT" DLX_CUT_FR="$CUT_FR") -( cd tmp && env DLX_PACE=1 DLX_RING_KB=$RING DLX_STREAM_KBPS=$KBPS \ +( cd tmp && env DLX_PACE=$PACE DLX_RING_KB=$RING DLX_STREAM_KBPS=$KBPS \ "${CUTENV[@]}" DLX_SLACK_CSV="slack_$TAG.csv" \ SDL_VIDEODRIVER=dummy stdbuf -oL timeout -k 5 900 \ mame x68000 -bios ipl10 -ramsize 2M -video soft -window -sound none \ @@ -42,6 +53,6 @@ grep -q "snapshot taken" "tmp/pace_$TAG.log" || { echo "FAIL($TAG): no snapshot marker -- the pass did not complete." tail -6 "tmp/pace_$TAG.log"; exit 1; } echo "=== $TAG" -grep -aE "decoder (PACED|FREE)|ring: |UNDERRUNS|SEEK SLACK|RING-BOUND|RATE-BOUND|BUILD TIME|PIPE CUT|DEADLINE|REQUIRED" \ +grep -aE "decoder (SELF-PACED|PACED|FREE)|FRAME CLOCK|ring: |UNDERRUNS|NO IDLE|SEEK SLACK|RING-BOUND|RATE-BOUND|BUILD TIME|PIPE CUT|DEADLINE|REQUIRED" \ "tmp/pace_$TAG.log" | sed "s/\[STR\] / /" python3 tools/bench/verify_decode.py "$DLX" --snap "tmp/snap_pace_$TAG" | tail -2 diff --git a/tools/bench/span.lua b/tools/bench/span.lua index 7dd7cb0..5499d6b 100644 --- a/tools/bench/span.lua +++ b/tools/bench/span.lua @@ -107,7 +107,8 @@ end local function launch(cfg) push(STREAM, blob, cfg.off+1, cfg.len) clear_picture() - -- ~4 emulated seconds per config: 1/55.46 s granularity costs under 0.5%. + -- ~4 emulated seconds per config: 1/56.69 s granularity (crtc_mode.lua) + -- costs under 0.5%. local est = cfg.nspans*(cfg.var == 5 and 60 or (cfg.var == 6 and 50 or 70)) + cfg.npix*10 cfg.iter = math.max(4, math.floor(4*CPUHZ/est)) diff --git a/tools/bench/stream.lua b/tools/bench/stream.lua index 685a684..c09ffba 100644 --- a/tools/bench/stream.lua +++ b/tools/bench/stream.lua @@ -53,7 +53,17 @@ -- (FINDINGS 42.1). A default is how a folklore number ends up -- silently underneath a table nobody restates it in. -- DLX_PREFILL_KB bytes to deliver before releasing the CPU (default 0) --- DLX_PACE 1 = hold the decoder to META.fps (default 0 = free-run) +-- DLX_PACE 1 = hold the decoder to META.fps, with the HOST writing +-- the tick (default 0 = free-run) +-- 2 = hold it to META.fps with the 68000 writing its own +-- tick, off the CRTC's V-DISP (src/player/clock.i, ROADMAP +-- P3). Same gate, same ring, same deadlines; what changes +-- is that nothing outside the machine decides when a frame +-- may start. Deadlines are then taken from the ticks the +-- machine actually emitted rather than from a host model of +-- 12 fps -- which matters, because MAME's raster runs 2.22% +-- fast (see tools/bench/clock.lua) and a host model would +-- quietly grade every arrival against the wrong clock. -- DLX_CUT_AT frame tick at which the pipe stops dead (a seek). Needs -- DLX_PACE; unset = no cut. -- DLX_CUT_FR how many frame times the cut lasts (default 1) @@ -88,7 +98,9 @@ local META = loadfile("stream_meta.lua")() local FLAG, ITER, NFR = 0x18000, 0x18008, 0x1800C local RD_PTR, FR_HEAD, FR_TAIL = 0x18020, 0x18024, 0x18028 local STALLS, SPINS, DESC = 0x1802C, 0x18030, 0x18100 -local PACE, PACEON = 0x18034, 0x18038 +local PACE, PACEON, CLKON = 0x18034, 0x18038, 0x1803C +local CLK_VDISP, CLK_FPS, CLK_ERR = 0x18064, 0x18068, 0x1806C +local LATEFR, LATEMAX, LATE1ST = 0x18080, 0x18084, 0x18088 local DESCN = 64 local CB1, CB4 = 0x20000, 0x22000 local RING = 0x40000 @@ -108,7 +120,8 @@ if KBPS == nil then return end local PREFILL = (tonumber(os.getenv("DLX_PREFILL_KB") or "") or 0) * 1024 -local PACED = (os.getenv("DLX_PACE") == "1") +local SELFCLK = (os.getenv("DLX_PACE") == "2") +local PACED = (os.getenv("DLX_PACE") == "1") or SELFCLK local CUT_AT = tonumber(os.getenv("DLX_CUT_AT") or "") local CUT_FR = tonumber(os.getenv("DLX_CUT_FR") or "") or 1 local SLACK_CSV = os.getenv("DLX_SLACK_CSV") @@ -269,9 +282,13 @@ local function setup() SP:write_u32(FLAG, 0); SP:write_u32(FR_HEAD, 0); SP:write_u32(FR_TAIL, 0) SP:write_u32(ITER, 1); SP:write_u32(NFR, META.nframes) SP:write_u32(PACE, 0); SP:write_u32(PACEON, PACED and 1 or 0) + SP:write_u32(CLKON, SELFCLK and 1 or 0) + SP:write_u32(CLK_FPS, META.fps) P(string.format("stream.bin=%d B, codebooks %d+%d B, disk %d B, %d frames", #code, META.cb1_len, META.cb4_len, META.disk_len, META.nframes)) - P(string.format("decoder %s%s", PACED and ("PACED at "..META.fps.." fps") + P(string.format("decoder %s%s", SELFCLK + and ("SELF-PACED at "..META.fps.." fps off V-DISP") + or PACED and ("PACED at "..META.fps.." fps by the host") or "FREE-RUNNING (tests wrap, not buffering -- 49.7.2)", CUT_AT and string.format(", pipe cut at tick %d for %.2f fr", CUT_AT, CUT_FR) or "")) @@ -292,6 +309,12 @@ local function launch() end local st, t0, t_rel = "boot", nil, 0 +-- tick_t[i+1] = emulated time of frame tick i, filled in as they are observed. +-- Under a self-clock this replaces the host's `t_rel + i/fps` as the deadline: +-- the machine's clock is the one the player is actually held to, and MAME's +-- raster is 2.22% fast, so grading arrivals against a host model of 12 fps +-- would flatter every record by that much. +local tick_t = {} local pace, min_ahead, min_at = -1, math.huge, -1 local sum_ahead, n_ahead = 0, 0 local slack_series = {} @@ -314,10 +337,21 @@ SUB = emu.add_machine_frame_notifier(function() end if st == "running" then if PACED then - local tick = math.floor((t - t_rel) * META.fps) + -- WHO WRITES THE TICK. Host-paced, the tick is a host model of + -- META.fps and this script advances it. Self-paced, the 68000 has + -- already advanced it off the raster and this script only READS it -- + -- the frame gate in src/player/stream.s cannot tell the two apart, + -- which is the point: the same bytes are gated either way. + local tick = SELFCLK and SP:read_u32(PACE) + or math.floor((t - t_rel) * META.fps) if tick > pace then pace = tick - SP:write_u32(PACE, pace) + if not SELFCLK then SP:write_u32(PACE, pace) end + -- The emulated time this tick actually happened, which is what a + -- record's deadline is measured against under a self-clock. Ticks + -- can arrive more than one apart if the host misses a frame, so the + -- whole run is filled rather than just the newest. + for k = #tick_t, tick do tick_t[k+1] = t end -- Taken BEFORE this tick's frame is decoded, so snapshot n is the -- finished picture of frame n-1. tick 0 is skipped: nothing has been -- drawn yet and it would record a black screen as a decoded frame. @@ -358,6 +392,14 @@ SUB = emu.add_machine_frame_notifier(function() .."or described wrongly, not that the codec changed.") M:exit(); return end + if fl == 0xE2 then + local e = SP:read_u32(CLK_ERR) + P("FRAME CLOCK REFUSED: CLK_ERR="..e..(e == 1 and + " (the CRTC is not in a 31.5 kHz mode, so the divider's 31500 " + .."lines/s would be wrong)" or e == 2 and + " (fps*VTOTAL does not fit the 16-bit accumulator)" or "")) + M:exit(); return + end if fl == 0xE1 then P("PRODUCER STALLED OUT -- stream.s spun SPINMAX times with no new " .."record. Delivered "..nsent.."/"..META.nframes..".") @@ -382,6 +424,25 @@ SUB = emu.add_machine_frame_notifier(function() META.nframes, dt, dt*CPUHZ/META.nframes, 100*(dt*CPUHZ/META.nframes)/FRAME12, META.fps)) end + if SELFCLK then + -- The clock's own report, in the run where it actually paced a + -- decoder rather than a busy loop. The rate is against MAME's fast + -- raster; tools/bench/clock_run.sh is where it gets de-skewed. + local vd = SP:read_u32(CLK_VDISP) + local vt = SP:read_u16(0xE80008) + 1 -- VTOTAL, as clk_init read it + -- DELIBERATELY NOT A RATE. 120 ticks is far too short a window to + -- quote fps from: the run's start and end each straddle a tick, so + -- +/-1 on 119 intervals is +/-8000 ppm and would read as drift the + -- clock does not have. What IS exact here is a count of refreshes + -- against a count of ticks. The rate figure comes from + -- tools/bench/clock_run.sh, over thousands of them. + P(string.format("FRAME CLOCK: %d V-DISP interrupts drove %d ticks " + .."(%.4f refreshes/frame; the divider's own ratio is " + .."31500/(%d*%d) = %.4f), %.0f clocks/frame of " + .."interrupt at FINDINGS 54's 181.35 each", + vd, pace + 1, vd/(pace + 1), META.fps, vt, + 31500/(META.fps*vt), 181.35*vd/(pace+1))) + end P(string.format("ring: %d wraps, %d B of hole (mean %.1f KB, %.1f%% of " .."the ring)", holes, hole_bytes, holes > 0 and hole_bytes/holes/1024 or 0, @@ -395,6 +456,20 @@ SUB = emu.add_machine_frame_notifier(function() -- opposite thing, so the two are never printed in the same words. P(string.format("UNDERRUNS: %d/%d frames waited past their %d fps slot " .."(%d polls)", stalls, META.nframes, META.fps, spins)) + -- The other way a paced frame can go wrong, and it is not the same + -- failure. An UNDERRUN is the pipe: the record was not there. This + -- is the CPU: the record was there, the slot was already open, and + -- the decoder had no idle left in the frame before. Under a + -- host-written tick every slot is 83.33 ms; under the machine's own + -- clock they alternate 72.13 and 90.16 ms, 37.9% of them short, and + -- this is what the short ones cost (FINDINGS 54). + local late, latemax = SP:read_u32(LATEFR), SP:read_u32(LATEMAX) + local late1 = SP:read_u32(LATE1ST) + P(string.format("NO IDLE: %d/%d frames found their slot already open " + .."-- the frame before used all of it; worst overrun " + .."%d whole tick%s, first at frame %d", late, + META.nframes, latemax, latemax == 1 and "" or "s", + late1 == 0xFFFFFFFF and -1 or late1)) -- WHAT THE MINIMUM OVER A RUN IS, AND IS NOT. At release the ring -- holds only what the prefill put there, so the early ticks report a -- buffer that has not been built yet, not a ring or a pipe that @@ -455,7 +530,9 @@ SUB = emu.add_machine_frame_notifier(function() for i = 0, META.nframes-1 do local a = arrival[i+1] if a then - local late = a - (t_rel + i / META.fps) + local due = SELFCLK and tick_t[i+1] or (t_rel + i / META.fps) + if not due then due = t_rel + i / META.fps end + local late = a - due if late > 0 then misses = misses + 1 if late > worst then worst = late end