Fix DMA write timing on NTSC; add a bit-pattern DMA self-test - #21
Open
kfox wants to merge 4 commits into
Open
Conversation
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.
kfox
force-pushed
the
dma-write-timing
branch
from
September 12, 2026 23:06
f0fb5c5 to
0fea31d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
DMA writes on NTSC intermittently land partial bytes. On a flat NTSC C128 + TR+ the
current
nS_DMADataHoldof 430 sits inside a measured bad band:xor $ff, highunchanged)430 is the shipping value, so affected machines are running inside that band. The
NTSC hold moves to 410;
nS_DMADataSetupwas 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(?)notestays exactly as it was.
Why the existing self-test never caught it
TestDMAPage()writes$ffover a page that already holds$ff. The data linesnever move, so a readback cannot tell a successful write from no write at all. It
passes on every affected machine.
This adds
TestDMAPattern()and az###serial command that pre-fills with theopposite 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.
ExpPortDMAnow runs it too.Evidence
Three rigs, two video standards, real silicon and an FPGA:
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 certifyanything. Under this project's own precedent (the
440 not working for Rat NTSCnote) that is not enough to move a constant.
Common_Defs.hnow documents a real gap I could not close safely: autolaunch reachesRemoteLaunch()withDoCartDirectand starts a CRT without ever loading the C64menu, so
wRegVid_TOD_Clksis never written and an NTSC machine keeps the PAL 430— while the REU handler and
Write/ReadC64MemTokenare both reachable. Droppingthe 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 Teensyrather than waiting for the C64 to report it.
What would help
A
z999run on real PAL silicon, ideally a board that does show390failing.Bench protocol available on request. The specific asks:
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
$6000is a plausiblecasualty. 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:so
nSToCyc(nS_DMASetup-90)inIOH_REU.cexpands tonS_DMASetup - 54= 386cycles (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 / 85sweep recorded in that comment behavedsensibly 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.