Skip to content

Commit 6c09eb6

Browse files
doublegateclaude
andauthored
fix(ppu): derive the H-IRQ dot from the clock, not a constant (T-06-A) (#300)
* fix(ppu): derive the H-IRQ dot from the clock, not a constant (T-06-A) HIRQ_TRIGGER_DELAY = 4 was a dot-domain rounding of ares' `hcounter(10) == (HTIME+1)<<2`, exact only while every dot is four clocks -- which stopped being true when dots 323 and 327 became six. The match is now computed where it happens: clock 4*HTIME + 14, mapped to the first dot boundary at or after it. Below the long dots the two agree EXACTLY, because 4*HTIME + 14 is never a multiple of 4 and the next boundary is HTIME + 4. They diverge only for HTIME 321..=337, where the six-clock dots have displaced every later boundary: the old constant fired up to a whole dot late, and at HTIME 336 suppressed an IRQ that does fire. HTIME 337 lands on dot 340's boundary, which exists only on the long line, so it is honoured there and suppressed elsewhere. The plan records this change as attempted and reverted once because it moved hdmaen_latch_test_2's golden. It does not this time: no framebuffer golden moved, the undisbeliever suite passes unchanged, and cross-validation is byte-identical. B4.16 is a weaker guard than its own doc claimed. Measured either side of the change both its readings are unchanged, including the HTIME = 330 one whose trigger dot moved 334 -> 333: the CPU takes an IRQ at an instruction boundary, so the handler-entry dot quantises to the spin loop's instruction length and a one-dot shift is absorbed. It can say "nothing regressed"; it cannot say "the change took effect". The new unit test does that, sweeping every HTIME and asserting equality with the old constant below the long dots and strict inequality above them. LONG_DOTS and the per-dot clock count move to rustysnes-ppu, which owns the dot model and now needs the layout twice; the scheduler delegates rather than keeping a second copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(roadmap,plan): close T-06-A's H-IRQ item and correct two predictions The roadmap predicted the clock-domain conversion "would shift IRQ timing by ~2 clocks" and re-bless framebuffer goldens. It shifts nothing below the long dots and moved no golden; only HTIME 321..=337 changes. The plan predicted the change was "the likeliest thing under B1.05's residual". It is not, and B4.16 shows why directly: its HTIME = 330 trigger dot moved 334 -> 333 and its recorded reading did not move, because the handler-entry dot quantises to the spin loop's instruction length. B1.05 stays blocked on the same missing clock-domain instrument. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e7fcca2 commit 6c09eb6

7 files changed

Lines changed: 224 additions & 55 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Added
1313

14+
- **The H-IRQ comparator moves into the clock domain (`T-06-A`), and nothing below the long dots
15+
moves with it.** `HIRQ_TRIGGER_DELAY = 4` was a *dot-domain rounding* of ares'
16+
`hcounter(10) == (HTIME+1)<<2` — exact only while every dot is four clocks, which stopped being
17+
true when dots 323 and 327 became six. The match is now computed where it actually happens:
18+
clock `4·HTIME + 14` (`hirq_match_clock`), mapped to the first dot boundary at or after it
19+
(`hirq_trigger_dot`).
20+
21+
**Below the long dots the two agree exactly**, because `4·HTIME + 14` is never a multiple of 4 and
22+
the next boundary is `HTIME + 4`. They diverge only for `HTIME` **321..=337**, where the six-clock
23+
dots have displaced every later boundary — the old constant fired up to a whole dot late, and at
24+
`HTIME = 336` suppressed an IRQ that does fire. `HTIME = 337` lands on dot 340's boundary, which
25+
exists only on the long line, so it is honoured there and suppressed elsewhere.
26+
27+
This is the change the plan recorded as *"attempted and reverted because it moves
28+
`hdmaen_latch_test_2`'s golden"*. It does not, this time: **no framebuffer golden moved**, the
29+
undisbeliever suite passes unchanged, and cross-validation is byte-identical
30+
(`snes9x: OK (14 known)`, `Mesen2: OK (2 known)`).
31+
32+
**`B4.16` is a weaker guard than its own doc claimed, and that is worth knowing.** Measured either
33+
side of the change, *both* of its readings are unchanged — including the `HTIME = 330` one, whose
34+
trigger dot moved 334 → 333. The CPU takes an IRQ at an instruction boundary, so the handler-entry
35+
dot quantises to the spin loop's instruction length and a one-dot shift is absorbed. `B4.16` can
36+
say "nothing regressed"; it cannot say "the change took effect". A unit test does that, sweeping
37+
every `HTIME` and asserting equality with the old constant below the long dots and strict
38+
inequality above them.
39+
40+
`LONG_DOTS` and the per-dot clock count also move to `rustysnes-ppu`, which owns the dot model and
41+
now needs the same layout twice. `rustysnes-core`'s scheduler delegates to it rather than keeping
42+
a second copy.
43+
1444
- **AccuracySNES: the scene protocol publishes on a known field, and the interlace three-way split
1545
is down to a two-way one.** `run_scenes` now sets the scene ID only on frames whose `$213F` bit 7
1646
is set, so every sighting the host counts is the same field. `SCENE_FRAMES` grew 8 → 12: at half

‎crates/rustysnes-core/src/bus.rs‎

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -68,20 +68,11 @@ const RDNMI_OPEN_BUS_MASK: u8 = 0x70;
6868
const TIMEUP_OPEN_BUS_MASK: u8 = 0x7F;
6969
const HVBJOY_OPEN_BUS_MASK: u8 = 0x3E;
7070

71-
/// The two dots that take **6** master clocks instead of 4 (`T-06-A`).
72-
///
73-
/// Hardware's scanline is 340 dots and 1364 master clocks, which `338 × 4 + 2 × 6` satisfies and a
74-
/// uniform `341 × 4` also satisfies — which is why a model can be wrong here and keep perfect frame
75-
/// timing. fullsnes' *PPU H-Counter-Latch Quantities* histogram settles it by measurement rather
76-
/// than prose: sampling `$2137` once per master clock across a line reports dots 323 and 327
77-
/// latching **six** times each, dot 340 **never**, and everything else four. bsnes, ares and Mesen2
78-
/// all implement exactly this; snes9x uses 322/326 and is the outlier.
79-
///
80-
/// Both sit deep in hblank — past the visible window (dots 22-277), past hblank's start at 274, and
81-
/// past [`HDMA_RUN_DOT`] — so dots `0..=322` keep their previous clock alignment exactly and no
82-
/// rendered pixel or HDMA transfer moves. What does change is the `OPHCT`/`$213C` latch value for
83-
/// `H >= 323`, which was up to one whole dot early.
84-
const LONG_DOTS: [u16; 2] = [323, 327];
71+
// The two 6-clock dots (`T-06-A`) used to be declared here. They now live in the PPU
72+
// (`rustysnes_ppu::LONG_DOTS` / `dot_clocks`), which owns the dot model and needs the same layout
73+
// to place the H-IRQ comparator — and two copies of "which dots are six clocks" is exactly the kind
74+
// of fact that drifts apart. The measurement that settles it, and why hblank is where they sit, are
75+
// documented at the declaration.
8576

8677
/// Master clocks the dot currently being completed lasts for.
8778
///
@@ -92,11 +83,7 @@ const LONG_DOTS: [u16; 2] = [323, 327];
9283
/// ([`rustysnes_ppu::Ppu::is_short_scanline`]) because every input is its own; this function only
9384
/// turns that into clocks.
9485
const fn dot_length(dot: u16, short_line: bool) -> u32 {
95-
if !short_line && (dot == LONG_DOTS[0] || dot == LONG_DOTS[1]) {
96-
MASTER_PER_DOT + 2
97-
} else {
98-
MASTER_PER_DOT
99-
}
86+
rustysnes_ppu::dot_clocks(dot, short_line)
10087
}
10188
/// PPU dot at which each visible scanline's HDMA transfer fires — ares' `hdmaPosition` of hcounter
10289
/// 1104 (`sfc/cpu/timing.cpp`) divided by [`MASTER_PER_DOT`]. Running the table at this exact dot

‎crates/rustysnes-ppu/src/lib.rs‎

Lines changed: 136 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,82 @@ const ACTIVE_DOT_START: u16 = 22;
118118
/// test and `cgram_write_target`'s gate so the two cannot desync.
119119
const HBLANK_START_DOT: u16 = 274;
120120

121-
/// Dots by which the HV-IRQ horizontal comparator lags the programmed `HTIME`, modelling the
122-
/// SNES hardware communication delay between the counter unit and the CPU's interrupt logic
123-
/// (ares `hcounter(10) == (HTIME+1)<<2` ⇒ fire at dot `HTIME + 3.5`; see `check_hv_irq`).
124-
const HIRQ_TRIGGER_DELAY: u16 = 4;
121+
/// Master clocks into a scanline at which the HV-IRQ horizontal comparator matches `HTIME`.
122+
///
123+
/// ares stores `io.htime = (HTIME + 1) << 2` and tests `hcounter(10) == io.htime`
124+
/// (`sfc/cpu/io.cpp:181-183`, `sfc/cpu/irq.cpp`), where `hcounter(n)` is the counter `n` clocks
125+
/// ago — so the match is at clock `4·HTIME + 4 + 10`. **That is a clock, not a dot.** The two
126+
/// 6-clock dots do not move it; they move which dot contains it, which is the whole reason this
127+
/// is computed rather than folded into a constant. See [`hirq_trigger_dot`].
128+
const fn hirq_match_clock(htime: u16) -> u32 {
129+
4 * htime as u32 + 14
130+
}
131+
132+
/// The dot on which an `HTIME` H-IRQ is observed: the first dot boundary at or after
133+
/// [`hirq_match_clock`].
134+
///
135+
/// # This replaces a constant that was only ever right by accident
136+
///
137+
/// It used to be `HTIME + 4`, a **dot-domain rounding** of the clock-domain compare above — exact
138+
/// while every dot is 4 clocks, and silently wrong once dots 323 and 327 became 6. Below the long
139+
/// dots the two agree *exactly*: `4·HTIME + 14` is never a multiple of 4, so the next boundary is
140+
/// `HTIME + 4` and not one framebuffer golden moves. They diverge only for `HTIME` in **321..=337**,
141+
/// where the six-clock dots have displaced every later dot boundary by 2 or 4 clocks and the old
142+
/// constant fired up to a whole dot late — and, at `HTIME = 336`, suppressed an IRQ that does fire.
143+
///
144+
/// # `B4.16` guards this, but less finely than its own doc claims
145+
///
146+
/// It records the raw latched dot from an IRQ handler at an `HTIME` below the long dots and one
147+
/// above, precisely so the before/after is a fact. Measured either side of this change, **both of
148+
/// its readings are unchanged** — including the `HTIME = 330` one, whose trigger dot moved from 334
149+
/// to 333. The reason is that the CPU takes an IRQ at an *instruction boundary*, so the
150+
/// handler-entry dot quantises to the spin loop's instruction length and a one-dot shift is
151+
/// absorbed. `B4.16` therefore says "nothing regressed", which is what a guard is for, but it
152+
/// cannot say "the change took effect" — `the_h_irq_dot_is_unchanged_below_the_long_dots_and_moves_
153+
/// above_them` is what does that, and this note exists so the next person does not read a static
154+
/// `B4.16` as evidence the model did not move.
155+
///
156+
/// Returns [`u16::MAX`] when the match clock falls past the end of the line — on hardware the
157+
/// counter never reaches it, so the IRQ simply never fires for that `HTIME`.
158+
const fn hirq_trigger_dot(htime: u16, short_line: bool) -> u16 {
159+
let target = hirq_match_clock(htime);
160+
let mut dot = 0u16;
161+
let mut clock = 0u32;
162+
// Walked rather than closed-form: the layout is two irregular dots in a 340-dot line, and a
163+
// closed form would have to encode their positions a second time. `DOTS_PER_LINE + 1` covers
164+
// the long line's extra dot; the loop is const-evaluable and runs at most 341 steps.
165+
while dot <= DOTS_PER_LINE {
166+
if clock >= target {
167+
return dot;
168+
}
169+
clock += dot_clocks(dot, short_line);
170+
dot += 1;
171+
}
172+
u16::MAX
173+
}
174+
175+
/// Master clocks dot `dot` lasts for: 4, except the two that are 6.
176+
///
177+
/// The canonical statement of the long-dot layout lives here because the PPU owns the dot model;
178+
/// `rustysnes-core`'s scheduler consumes it to distribute clocks across a line. `short_line` is
179+
/// `B2.02`'s scanline, where the decomposition the references give is a flat `340 × 4 = 1360`.
180+
#[must_use]
181+
pub const fn dot_clocks(dot: u16, short_line: bool) -> u32 {
182+
if !short_line && (dot == LONG_DOTS[0] || dot == LONG_DOTS[1]) {
183+
6
184+
} else {
185+
4
186+
}
187+
}
188+
189+
/// The two dots that last six master clocks instead of four.
190+
///
191+
/// fullsnes' *PPU H-Counter-Latch Quantities* histogram settles this by measurement: sampling
192+
/// `$2137` once per master clock across a line reports dots 323 and 327 latching **six** times
193+
/// each and dot 340 never. bsnes, ares and Mesen2 all implement exactly this; snes9x uses 322/326
194+
/// and is the outlier. Both sit deep in hblank, past the visible window and past [`RENDER_DOT`],
195+
/// so dots `0..=322` keep their clock alignment exactly.
196+
pub const LONG_DOTS: [u16; 2] = [323, 327];
125197

126198
/// The dot at which a V-only IRQ's comparator is sampled.
127199
///
@@ -974,20 +1046,19 @@ impl Ppu {
9741046

9751047
/// Level-evaluate the HV-IRQ comparator at the current (h, v).
9761048
///
977-
/// The horizontal match is asserted [`HIRQ_TRIGGER_DELAY`] dots *after* the programmed
978-
/// `HTIME`, modelling the SNES's hardware communication delay between the H/V counter unit
979-
/// and the CPU's interrupt logic. ares encodes this as `hcounter(10) == io.htime` with
980-
/// `io.htime` stored as `(HTIME + 1) << 2` clocks (`sfc/cpu/irq.cpp`, `sfc/cpu/io.cpp`), i.e.
981-
/// the IRQ fires at hcounter `HTIME*4 + 14` = dot `HTIME + 3.5`. Without this delay an
982-
/// IRQ-gated register write (e.g. the `hdmaen_latch_test` `STA $420C`) lands ~3–4 dots early,
983-
/// which — combined with the dot-1104 HDMA latch — collapses the test's banded HDMAEN-vs-latch
984-
/// crossing into a uniform per-line alternation.
1049+
/// The horizontal match is derived in the clock domain — [`hirq_match_clock`] gives the clock
1050+
/// the comparator matches at, [`hirq_trigger_dot`] the dot boundary it is observed on — which
1051+
/// models the SNES's hardware communication delay between the H/V counter unit and the CPU's
1052+
/// interrupt logic. Without that delay an IRQ-gated register write (e.g. the
1053+
/// `hdmaen_latch_test` `STA $420C`) lands ~3–4 dots early, which — combined with the dot-1104
1054+
/// HDMA latch — collapses the test's banded HDMAEN-vs-latch crossing into a uniform per-line
1055+
/// alternation.
9851056
const fn check_hv_irq(&mut self) {
9861057
// Adding the delay can push the target past the end of the line. On hardware the H counter
9871058
// never reaches those values (ares' stored `(HTIME+1)<<2 + 10` clocks then exceeds the max
9881059
// hcounter), so the IRQ simply never fires for such HTIME — suppress rather than wrap into
9891060
// the next line, which would be a spurious match hardware/ares never produce.
990-
let h_target = self.irq_h + HIRQ_TRIGGER_DELAY;
1061+
let h_target = hirq_trigger_dot(self.irq_h, self.is_short_scanline());
9911062
let h_match = if self.irq_enable_h {
9921063
// Bounded by THIS line's dot count, not the constant: the long line has a dot 340 that
9931064
// a normal line does not, so an `HTIME` landing there is a real match on that line and
@@ -1629,13 +1700,60 @@ mod tests {
16291700
}
16301701
assert!(fired_at.is_some());
16311702
let (h, v) = fired_at.unwrap();
1632-
// The H comparator lags HTIME by `HIRQ_TRIGGER_DELAY` dots (hardware counter→IRQ
1633-
// communication delay; see `check_hv_irq`), and it is evaluated at the start of tick
1634-
// before H increments, so the IRQ is observed at dot `HTIME + HIRQ_TRIGGER_DELAY` (or the
1635-
// dot after) on the programmed scanline.
1703+
// The comparator matches at CLOCK `4*HTIME + 14` (hardware counter→IRQ communication
1704+
// delay; see `hirq_match_clock`) and the IRQ is observed at the next dot boundary. It is
1705+
// evaluated at the start of tick before H increments, hence the "or the dot after".
16361706
assert_eq!(v, 50);
1637-
let target = 100 + HIRQ_TRIGGER_DELAY;
1707+
let target = hirq_trigger_dot(100, false);
16381708
assert!(h == target || h == target + 1);
1709+
// HTIME 100 is far below the long dots, so the clock-domain derivation must agree with the
1710+
// old `HTIME + 4` constant EXACTLY. This is the assertion that says the change moved
1711+
// nothing it was not supposed to move.
1712+
assert_eq!(target, 104);
1713+
}
1714+
1715+
/// The clock-domain H-IRQ mapping agrees with the old constant everywhere the old constant was
1716+
/// right, and only there.
1717+
///
1718+
/// This is the whole safety argument for `T-06-A`'s comparator change written as an assertion.
1719+
/// `HIRQ_TRIGGER_DELAY = 4` was a dot-domain rounding of `hcounter(10) == (HTIME+1)<<2`, exact
1720+
/// while every dot is 4 clocks. Below dot 323 it still is — so every existing framebuffer
1721+
/// golden, every raster test and every H-IRQ a game arms in the visible window must be
1722+
/// untouched. Above it the six-clock dots have displaced the boundaries and the old constant
1723+
/// fired late.
1724+
#[test]
1725+
fn the_h_irq_dot_is_unchanged_below_the_long_dots_and_moves_above_them() {
1726+
// Everything that can land before dot 323 keeps the old answer exactly.
1727+
for htime in 0..=319u16 {
1728+
assert_eq!(
1729+
hirq_trigger_dot(htime, false),
1730+
htime + 4,
1731+
"HTIME {htime} moved, and nothing below the long dots is allowed to"
1732+
);
1733+
}
1734+
// 320 still coincides: its match clock is 1294, and the next boundary is dot 324's at 1298
1735+
// — which is also 320 + 4. The first genuinely displaced HTIME is 321.
1736+
assert_eq!(hirq_trigger_dot(320, false), 324);
1737+
for htime in 321..=336u16 {
1738+
assert!(
1739+
hirq_trigger_dot(htime, false) < htime + 4,
1740+
"HTIME {htime} is past the long dots and must now fire EARLIER than the old constant said, not at the same dot"
1741+
);
1742+
}
1743+
// The old constant suppressed HTIME 336 by pushing it to 340; the clock it matches at is
1744+
// 1358, which is inside the line, so it fires on the last dot.
1745+
assert_eq!(hirq_trigger_dot(336, false), 339);
1746+
// 337 lands on dot 340's boundary, which exists only on the LONG line — so the caller's
1747+
// `h_target < dots_this_line()` guard suppresses it on an ordinary line and honours it on a
1748+
// long one. That asymmetry is the point of bounding by the line rather than by a constant.
1749+
assert_eq!(hirq_trigger_dot(337, false), 340);
1750+
// Past the end of the line there is genuinely nothing to match, on any line.
1751+
assert_eq!(hirq_trigger_dot(338, false), u16::MAX);
1752+
1753+
// On the short line every dot is 4 clocks again, so the old constant is right throughout.
1754+
for htime in 0..=335u16 {
1755+
assert_eq!(hirq_trigger_dot(htime, true), htime + 4);
1756+
}
16391757
}
16401758

16411759
#[test]

‎docs/accuracysnes-plan.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,6 +1504,16 @@ that is where the line-length non-uniformity the row is trying to see enters the
15041504
Note the shape: `B1.05` cannot be measured because the cart reads dots, and the same limit is why
15051505
`A5.20` is marked `[NOT CART-MEASURABLE]`. These are one obstacle, not two.
15061506

1507+
**Re-assessed 2026-08-01, after the H-IRQ comparator moved into the clock domain.** The `v1.29.0`
1508+
plan flagged that change as *"the likeliest thing under `B1.05`'s residual"*. It is not, and the
1509+
reason is the one above: the change corrects where the *comparator* fires, which the cart observes
1510+
through an IRQ handler that latches `H` — **in dots**. `B4.16` demonstrates the limit directly:
1511+
its `HTIME = 330` trigger dot moved 334 → 333 and its recorded reading did not move at all, because
1512+
the CPU takes an IRQ at an instruction boundary and the handler-entry dot quantises to the spin
1513+
loop's instruction length. A model change strictly finer than the instrument cannot show up in it.
1514+
`B1.05` stays blocked on the same missing clock-domain instrument, and the H-IRQ item is now closed
1515+
without having moved it.
1516+
15071517
### `A6.15` — "all 256 opcodes defined" is a table-extension job, not a test
15081518

15091519
The 65816 has no illegal opcodes: all 256 encodings are defined, and only `STP` halts. What a core

‎docs/ppu.md‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,15 @@ The crate is a working dual-chip model. Public API the scheduler/bus call:
262262
frame, as vblank begins, and **only while forced blank is off** (`end_of_scanline`). Sprite
263263
evaluation leaves the running counter wherever it finished, so without the reload an address a
264264
game programmed would not survive a frame. AccuracySNES **C1.06** covers it.
265-
- **HV-IRQ sampling:** the horizontal comparator fires `HIRQ_TRIGGER_DELAY` (4) dots after the
266-
programmed `HTIME`. With H-IRQ **disabled** (V-only), the comparator is sampled at a single dot
265+
- **HV-IRQ sampling:** the horizontal comparator matches at **clock** `4·HTIME + 14` within the
266+
line, and the IRQ is observed at the first dot boundary at or after it (`hirq_match_clock` /
267+
`hirq_trigger_dot`). Below the two 6-clock dots that is `HTIME + 4` exactly, which is what the
268+
retired `HIRQ_TRIGGER_DELAY` constant hardcoded; from `HTIME = 321` up the six-clock dots have
269+
displaced the boundaries and the constant fired up to a whole dot late, and at `HTIME = 336`
270+
suppressed an IRQ that does fire. `HTIME = 337` lands on dot 340's boundary, which exists only on
271+
the long line — so it is honoured there and suppressed elsewhere, which is why the caller bounds
272+
by `dots_this_line()` and not by a constant. With H-IRQ **disabled** (V-only), the comparator is
273+
sampled at a single dot
267274
`VIRQ_TRIGGER_DOT` (2) rather than being treated as matching across the whole line — otherwise
268275
`V == VTIME` is a level that re-raises the IRQ every dot and `$4211` cannot acknowledge it. See
269276
`docs/scheduler.md` §H/V-IRQ; AccuracySNES **B4.08**/**B4.12**.
@@ -276,10 +283,11 @@ The crate is a working dual-chip model. Public API the scheduler/bus call:
276283
- **Timeline:** `tick_dot(&mut self, bus: &mut impl VideoBus)` advances H 0..=340 / V per region
277284
(262 NTSC / 312 PAL), sets VBlank at V=225 (V=240 overscan) and HBlank, fires
278285
`notify_scanline`/`notify_vblank`, raises NMI at VBlank start, and level-fires the HV-IRQ
279-
comparator (`set_hv_irq(enable_h, enable_v, h, v)` programs it). The horizontal match is
280-
asserted `HIRQ_TRIGGER_DELAY` (4) dots **after** the programmed `HTIME`, modelling the SNES
281-
counter→CPU interrupt communication delay (ares `hcounter(10) == (HTIME+1)<<2`; see
282-
`docs/scheduler.md` §H/V-IRQ). Without it an IRQ-gated register write lands a few dots early.
286+
comparator (`set_hv_irq(enable_h, enable_v, h, v)` programs it). The horizontal match is derived
287+
in the **clock** domain (ares `hcounter(10) == (HTIME+1)<<2`, i.e. clock `4·HTIME + 14`) and then
288+
mapped to a dot, which is what makes it survive the two 6-clock dots; see the HV-IRQ bullet above
289+
and `docs/scheduler.md` §H/V-IRQ. Without the delay an IRQ-gated register write lands a few dots
290+
early.
283291
- **Polls (the scheduler reads these — no extra `VideoBus` methods were added):**
284292
`nmi_pending()`/`ack_nmi()`, `irq_pending()`/`ack_irq()`, `in_vblank()`/`in_hblank()`,
285293
`dot()`/`scanline()`, `frame_ready()`/`take_frame()`/`frame_count()`, `framebuffer() -> &[u16]`.

0 commit comments

Comments
 (0)