Skip to content

Fix DMA write timing on NTSC; add a bit-pattern DMA self-test - #21

Open
kfox wants to merge 4 commits into
SensoriumEmbedded:mainfrom
kfox:dma-write-timing
Open

Fix DMA write timing on NTSC; add a bit-pattern DMA self-test#21
kfox wants to merge 4 commits into
SensoriumEmbedded:mainfrom
kfox:dma-write-timing

Conversation

@kfox

@kfox kfox commented Sep 10, 2026

Copy link
Copy Markdown

What this fixes

DMA writes on NTSC intermittently land partial bytes. On a flat NTSC C128 + TR+ the
current nS_DMADataHold of 430 sits inside a measured bad band:

hold behaviour
455+ overruns Ø2 falling, cycle alignment lost, whole bytes wrong (xor $ff, high unchanged)
430–450 intermittent single-bit errors, ~6 per million bytes
≤425 clean

430 is the shipping value, so affected machines are running inside that band. The
NTSC hold moves to 410; nS_DMADataSetup was swept 310–450 on the same rig
(clean at 350+) and is left at 390.

PAL is deliberately untouched. The existing 390 (err), 470 OK, 430 OK(?) note
stays exactly as it was.

Why the existing self-test never caught it

TestDMAPage() writes $ff over a page that already holds $ff. The data lines
never move, so a readback cannot tell a successful write from no write at all. It
passes on every affected machine.

This adds TestDMAPattern() and a z### serial command that pre-fills with the
opposite value and uses alternating patterns, so the bus swings on every DMA cycle.
It reports bad-byte counts, unchanged (write never landed vs. landed mid-settle),
an XOR mask, and a per-bit 1→0 / 0→1 table — enough to tell the two failure modes
apart. ExpPortDMA now runs it too.

Evidence

Three rigs, two video standards, real silicon and an FPGA:

NTSC C128 (flat) C64 Ultimate, PAL C64 + VIC-II Kawari, PAL
Bus cycle 978 nS 1015 nS 1015 nS
Hard cliff 455 480–485 480–485
Margin before next Ø2 fall 93 nS 90–95 nS 90–95 nS
Soft band 430–450 none seen none seen

The cliff moves 30 nS between standards; the Ø2 period differs by 37 nS. The
mechanical model predicts its own numbers, which is what makes the NTSC number
trustworthy.

What is deliberately NOT changed

The PAL hold stays at 430, and the PAL-biased power-on defaults stay as they are.

The Kawari rig swept 385–475 clean over 12.8 MB — but it also refuses to reproduce
the 390 (err) in the original note, so it is a forgiving board and cannot certify
anything. Under this project's own precedent (the 440 not working for Rat NTSC
note) that is not enough to move a constant.

Common_Defs.h now documents a real gap I could not close safely: autolaunch reaches
RemoteLaunch() with DoCartDirect and starts a CRT without ever loading the C64
menu, so wRegVid_TOD_Clks is never written and an NTSC machine keeps the PAL 430
— while the REU handler and Write/ReadC64MemToken are both reachable. Dropping
the power-on default to 410 would close it, but that needs a PAL board that
reproduces 390 (err) first. The durable fix is probably timing Ø2 on the Teensy
rather than waiting for the C64 to report it.

What would help

A z999 run on real PAL silicon, ideally a board that does show 390 failing.
Bench protocol available on request. The specific asks:

  • where the hard cliff lands (expected 480–485)
  • whether partial-byte errors appear anywhere below it, and over what volume and duration — the fault drifts over hours, and the whole battery runs in under three minutes
  • how 410 behaves against 430 in an interleaved A/B, since that decides the power-on default above

One warning: sweeping past the cliff can drop the machine into BASIC. Misaligned
writes are not confined to the test page, and the menu at $6000 is a plausible
casualty. Reproduced deliberately — 430 ran 25 rounds clean with the menu up, 490
killed it at round 7. Expected, harmless, resets away.

Unrelated, noticed in passing — not fixed here

nSToCyc(N) does not parenthesize its argument:

#define nSToCyc(N)  (N*(F_CPU_ACTUAL>>16)/(1000000000UL>>16))

so nSToCyc(nS_DMASetup-90) in IOH_REU.c expands to nS_DMASetup - 54 = 386
cycles (643 nS)
, not the intended 210 cycles (350 nS). The PSRAM slow-read guard
arms far later than the comment implies. The Teensy core's own version of this
expression does parenthesize.

Worth noting why it survived: the slope is identical in both expansions
(−0.6 cycles/nS), so the 75 / 80 / 85 sweep recorded in that comment behaved
sensibly and converged — it was locally correct, just offset by a constant 293 nS.
Which also means fixing the macro invalidates that calibration and needs a re-tune
on hardware, so I left it alone.

DataPortWriteWaitDMA() released the data bus a hardcoded 430nS for both video
standards.  Split the setup/hold constants PAL/NTSC, make them tunable like every
other timing constant, and drop the NTSC hold to 410.

Measured on a flat NTSC C128 + TR+ (9/9/26), two distinct failure modes:

- Deterministic, reproducible on demand: at 455+ the wait runs past Phi2 falling,
  so the port/buffer teardown and the next DMAByte() lose cycle alignment.  Whole-
  byte corruption (the readback keeps the page's previous contents), collapsing
  completely by 460 - even the same-value control fails.
- Intermittent, varying over hours: partial-byte errors at 430-450 (30 over
  ~1.5MB) while 360-425 stayed clean (~2.9MB).

Pooled over every run: <=425 gave 2 bad bytes in 8.5MB (2.3e-7/byte), 430-450 gave
31 in 5.3MB (5.9e-6/byte), 455+ gave 1326 in 164KB (8.1e-3/byte).  That 25x
separation is pooled across time windows, not measured under matched conditions,
and the intermittent mode went quiescent partway through: a same-conditions
interleaved A/B of 410 vs 430, 1.28MB per arm, found zero on both, with 460 still
collapsing as a positive control.  410 is not error-free either (2 bad in 4.5MB).
So it is chosen on the pooled rate plus margin - it sits 45nS from the
deterministic cliff where 430 sits 25nS from it - and not on a matched-conditions
measurement, which this rig would not yield once the fault went quiet.

PAL stays at 430.  Its collapse point was located at 482 on a C64 Ultimate in PAL
mode (9/10/26), exactly where the model predicts - both machines fail once the bus
is released less than ~90-95nS before the next Phi2 falling edge, on different
silicon and different video standards, which is independent confirmation of the
mechanism rather than a fit to one rig.  So 430 has 52nS of mechanical margin,
more than NTSC's 410 has.  But that rig is an FPGA with its own bus buffers and
never showed the partial-byte mode at any value, which is the mode that actually
drove the NTSC change - so PAL's analog margin remains deliberately unverified.

Note the constants are measured from StartCycCnt, which DMAByte() re-latches
nS_DMASetup after Phi2 *falling*, not from Phi2 rising; the swept windows are only
valid at that constant's default.

Changes:
- Common_Defs.h: Def_nS_DMADataSetup/Hold PAL+NTSC and their runtime variables,
  following the existing nS_DMASetup pattern.
- DMAControl.ino: use the variables instead of bare literals.  The nS->cycles
  conversion is hoisted out of the spin - F_CPU_ACTUAL is volatile, so
  WaitUntil_nS() re-runs a load/multiply/divide every pass, and leaving that in
  would coarsen the very wait being tuned (DataPortWriteWait() already hoists it).
  This does make every DMA data wait end slightly earlier than before.  Measured
  from the generated code: the old spin was 9 instructions with 2 volatile loads
  and a reciprocal-multiply divide, the new one is 4 with a single load, so at
  600MHz the exit granularity drops from ~17-23nS to ~8-10nS.  A nominal hold
  therefore overshoots by ~4nS instead of ~10nS.  The NTSC values here were swept
  under the new behaviour; PAL's were not, so PAL keeps its nominal value but
  holds ~5nS shorter on average than shipped builds.  That is negligible against
  its 52nS of margin to the collapse point (see Common_Defs.h) but is unmeasured
  against the partial-byte mode.
- IOH_TeensyROM.c: carry both new constants through the PAL/NTSC discovery.
- SerUSBIO.ino: tw###/ty### to tune them live and 'z' to run the pattern test,
  both under the existing debug gates.  'z' echoes its pass count, since GetDigits
  needs all three digits and silently falls back to 64 when given fewer.
- StatusFunctions.c: TestDMAPattern() writes ValA/ValB alternating byte-by-byte
  over a controlled prior value, and reports the whole error distribution - bad
  bytes per page, XOR mask, per-bit direction, and how many bytes kept their
  previous contents.  Uniform fills (ValA==ValB) cannot see this bug at all: a
  page already holding the value verifies clean whether or not the write landed,
  which is why TestDMAPage() never caught it.  Alternating values additionally
  make the bus swing between consecutive DMA cycles, and that is the only pattern
  that trips at all near the chosen default.  The pre-fill is read back rather
  than trusted, since it is subject to the same faults, and its miscompares count
  as a failure.  Wired into ExpPortDMA() with a same-value control.
The prior note said PAL's collapse point came from a C64 Ultimate and that
PAL's partial-byte margin was unverified, because an FPGA has no real DRAM
to be marginal.  Re-ran the sweep on an original C64 with a VIC-II Kawari
configured for PAL, TR+ installed.

PAL detection was confirmed to actually fire rather than being read back
from the identical power-on defaults: poisoning nS_MaxAdj to 900 and
nS_DMASetup to 400, then resetting the C64, restored 1030/440, which only
the PAL branch writes.

Results, ~15.6MB of DMA writes total:
  - hold 430 baseline, 1.28MB, clean across all five bit patterns
  - collapse between 480 and 485, whole-byte, xor=$ff - the same boundary
    the Ultimate gave, so the mechanical model now holds on two PAL rigs
    and one NTSC rig, on different silicon and different video standards
  - swept 385-475 at 1.28MB each, all clean - no partial-byte band
  - interleaved A/B of 430 vs 410, 768000 bytes per arm, both 0 bad

No constant changed.  430 sits mid-window with ~50nS of mechanical margin
and there is no evidence for moving it.

The clean 385-475 sweep is not a general acquittal of the partial-byte
mode on PAL: this board also does not reproduce the 390 error recorded in
the existing note, so it is a forgiving board.  The comment now says the
mode is untested on PAL rather than absent.
The initializers read as "PAL is the default" when what they actually mean
is "this window runs before detection and something had to be picked".
Two of the four carried a trailing note saying so; the hold and read
constants did not.

Records the part that is not obvious: the PAL bias is deliberate rather
than arbitrary.  NTSC's MaxAdj of 993 sits under PAL's 1015nS cycle, so
initializing to the NTSC set would make a PAL machine re-adjust on every
interrupt - the asymmetry only degrades safely in the direction it is
already pointing.

Also records why the 430 hold in that window is not a live defect on NTSC
despite sitting in NTSC's bad band: nothing DMAs before detection.  Every
DMA caller is either a serial host command, ExpPortDMA (reached through a
C64-side rCtlExpPortDMAWAIT write from the menu), or REU emulation, and
the menu writes wRegVid_TOD_Clks in its first ~100 instructions.  A C64
reset does not restore these, so the window is Teensy boot to first menu
load only.

No values changed.  Lowering the hold initializer to a both-standards-safe
410 stays blocked on a PAL rig that can actually fail: 410 is clean on the
Kawari board, but that board will not reproduce the 390 error in the note
above, so it cannot certify the value.
Common_Defs.h: the pre-discovery comment claimed nothing DMAs before the
menu writes wRegVid_TOD_Clks.  That is false.  Autolaunch reaches
RemoteLaunch() with DoCartDirect and starts a CRT without ever loading the
menu, so an NTSC machine keeps the PAL 430 hold - inside NTSC's own
430-450 partial-byte band - while the REU handler and Write/ReadC64MemToken
are both reachable.  Corrected to describe the real exposure.  Not fixing it
by dropping the default to 410: that still needs a PAL board that reproduces
the 390 (err), and the one PAL rig measured so far does not.

TestDMAPattern(): the bit-level detail block was gated on BadBytes, so a
run where only the pre-fill write dropped bytes printed a bare count.  That
is the max-transition case the test exists to catch ($00 over $ff), and the
partial-byte vs whole-byte discrimination was lost exactly there.  Pre-fill
mismatches now carry their own xor mask and 1->0/0->1 totals.

td: restored PAL constants unconditionally, so on an NTSC rig it silently
re-armed the 430 hold mid-sweep with nothing to re-latch 410 until the C64
was reset.  Now selects by the detected standard and prints which.

z: forced PIT/ENET back on when it finished, overriding ExpPortDMA and the
handlers that disable them deliberately.  Saves and restores instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant