Skip to content

Kernal Replace: sense HIRAM by DMA on a C128 - #78

Merged
SensoriumEmbedded merged 2 commits into
SensoriumEmbedded:mainfrom
wesbiggs:c128-kernal-dma-sense
Oct 31, 2026
Merged

SensoriumEmbedded merged 2 commits into
SensoriumEmbedded:mainfrom
wesbiggs:c128-kernal-dma-sense

Conversation

@wesbiggs

@wesbiggs wesbiggs commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On a C128 in C64 mode, Kernal Replace never serves the replacement image, and the keyboard stops working in BASIC (even with the stock 901227-03 image). Various other programs exhibit problems or corruption.

The Skoe HIRAM sense drives A14 low over the CPU's address and reads ROMH. On a C128 the expansion port's address lines come through a transceiver that drives towards the port outside DMA, so the PLA never sees the TR's address. Measured on a flat NTSC C128 with a ROMH-timing trace in KernalCheck:

  • ROMH never went low on any sense, even at Cyc_KernProp 99 (about 121 ns).
  • Every sense read "RAM", so KernalCheck asserted /GAME and drove nothing on the first $E000+ read after each $01 write.
  • The KERNAL IRQ writes $01 at $EA79, right before JSR $EA87 (the keyboard scan), so the fetch of that JSR came off an undriven bus every jiffy.

With the sense skipped and HIRAM assumed set, serving works on the same C128: BASIC, a VDrive KERNAL and CCGMS all ran. Only HIRAM detection is missing.

Change

When the menu reports a C128 (rvtcC128), HIRAM is sensed by DMA instead:

  1. On the CPU's write to $00/$01, /DMA is asserted in the following Phi2-low phase. The next cycle is always the opcode fetch after the store, a read, so the CPU halts on it.
  2. In that cycle the TR reads $A000 as bus master with /GAME and /EXROM asserted (16K mode), using DMAByte's read timing. ROMH there means HIRAM is set.
  3. /DMA is released in the next Phi2-low phase. The late rising-edge interrupt for the stolen cycle is dropped.

Handled cases:

  • VIC holding the bus (badline, sprite fetch): the read waits for BA.
  • Read-modify-write on $01, which writes twice: the sense runs after the second write. A store whose indexed dummy read hit $00/$01 serves its next read on the last verdict.
  • Another DMA user holding the line: the sense is skipped and the last verdict is used.

Cost: one CPU cycle per write to $00/$01, more when the VIC holds the bus. The C64 keeps the Skoe sense unchanged.

KernalCheck also now returns before asserting /GAME when HIRAM is known to be RAM. Under Ultimax those reads were taken away from the RAM. The DMA sense's RAM verdicts depend on this, and it also fixes RAM reads under the KERNAL on a C64. That was seen with Quest for the King, which keeps data there, and with a 3-pass RAM-under-ROM test (2-18 bad bytes per million reads in $E000-$FFFF before, 0 after).

Debug (Dbg_SerTimChg): tx# selects the method (0 Skoe, 2 DMA, 9 auto, the default) and tn### sets the ROMH sample point (default 250 ns; ROMH went low at 98-136 ns on the test C128). Both appear in the t listing.

ISR path and RAM

This changes the per-cycle path, so per CONTRIBUTING:

  • C64: the existing path is unchanged except for the early RAM return, which only removes work.
  • C128: the common cycle costs two tests (a sense pending? an $00/$01 access?) ahead of the IO1/IO2 answer. The rest is in KDS_Slow, which only those cycles call. A first version did that bookkeeping on every cycle, and IO1 transfers broke (PRG streaming from the menu, SwiftLink reads), so this split matters.
  • RAM1: npm run build:tr-plus -- --skip-minimal-build --skip-combine before and after: code 201336 → 202232 (+896), padding 28040 → 27144, free for local variables 79900 unchanged. RAM2 unchanged.

Testing

Hardware: flat NTSC C128 (C64 mode), TR+. Testing used a TR+ firmware with this change plus unrelated local patches. It ran with the same DMA sense code, but with debug counters and an earlier variant of the mode switch:

  • BASIC and the keyboard work. Replacement images are served: banner image, VDrive KERNAL with loads.
  • A memory-intensive RPG I've made runs, loads and saves.
  • The RAM-under-ROM test passes (0 errors in $D000-$FEFF).
  • CCGMS over SwiftLink works.
  • SkinnyAI (my OpenAPI chat client using SwiftLink, NMI receive, chat history in RAM under the KERNAL with $01 = $34, 2 senses per received byte) works.
  • Sprite multiplexing over bitmap images (KERNAL banked out, own vectors under it) display.

Confirmation run (maintainer): this code on a TR+ in an NTSC C128 in C64 mode. BASIC and the keyboard work with the stock, JiffyDOS and JaffyDOS images, and tx0 brings back the dead keyboard. A RAM-under-KERNAL test with $01 switched by DEC/INC came back clean on the C128 (JiffyDOS) and on an NTSC C64 with tx0 and tx2.

Not tested:

  • A sprite-heavy program with the KERNAL banked out
  • The menu button and a remote pause during a run
  • LOAD/SAVE on a real drive under Kernal Replace on the C128
  • PAL C128 and C128DCR
  • A launch that doesn't come from the menu (no C128 report, so Skoe, as today)

Built: --target tr-plus, --target tr, and tr-plus with Dbg_SerTimChg on. npm test passes (219 of 224, 5 skipped).

🤖 Generated with Claude Code

On a C128 in C64 mode, Kernal Replace never serves the image: the Skoe
sense drives A14 low over the CPU's address, but the expansion port's
address lines come through a transceiver that drives towards the port
outside DMA, so the PLA never sees it. ROMH never answers (measured at
every Cyc_KernProp up to 99), every sense reads "RAM", and the one read
after each $01 write is left undriven under /GAME. The KERNAL IRQ writes
$01 right before its keyboard scan, so the keyboard stops working.

With the menu reporting a C128 (rvtcC128), HIRAM is now sensed by DMA:
right after a CPU write to $00/$01, /DMA holds the CPU for one cycle
(the next cycle is always the opcode fetch, a read) and the TR reads
$A000 as bus master with /GAME+/EXROM asserted; ROMH there means HIRAM
is set. One CPU cycle per write to $00/$01, more when the VIC holds the
bus. The common cycle stays at two tests ahead of the IO1/IO2 answer.
A C64 keeps the Skoe sense. Serial tx# (Dbg_SerTimChg) picks the method
(0 Skoe, 2 DMA, 9 auto) and tn### the ROMH sample point.

KernalCheck now also returns before asserting /GAME when HIRAM is known
to be RAM: Ultimax took those reads away from the RAM, which the DMA
sense's RAM verdicts need, and which also corrupted RAM reads under the
KERNAL on a C64 (Quest for the King).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wesbiggs

wesbiggs commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor Author

I focused on the C128 for this PR but the same approach might work fine (or even fix some occasional bad reads I see) on C64. I haven't built and tested that theory yet but will when I get a chance (assuming some flaky memory issues I've been having on my C64 are transient). It would cost one stolen CPU cycle per write to $01, but might be worth it to simplify the code path.

@SensoriumEmbedded SensoriumEmbedded left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for getting Kernal Replace working on the C128, and for the RAM-under-KERNAL fix on the C64 along the way, great work! :)

I reviewed this merged onto main and built both targets, then ran the TR+ build of it merged onto current main (5e9d90a) on hardware. Feel free to push back on any that are inappropriate or would take significant memory/code to resolve.

Memory: on the TR+ main image this adds 896 B of ITCM (padding 28,072 → 27,176) and 49 B of DTCM, both inside the current steps, so free for local variables is unchanged. MinimalBoot, the extension image and plain TR are unchanged.

There are 3 (very minor) things I'd like you to look at before merging. 2 comments and a slight memory saving tweak, suggestions are in-line.

1. nS_KSenseSample's time base (Common_Defs.h:425)

The comment says the sample point is measured from Phi2 rising, but KDS_SenseCycle() re-latches StartCycCnt at nS_DMASetup, so it's measured from the same latch as nS_DMADataSetup: about 44 nS before Phi2 rises on an NTSC C128. Anyone tuning tn### against a scope would be about 44 nS off. If the 98-136 nS figures came from a scope against Phi2 rising, they'd be about 44 nS larger in this time base. Suggested wording inline.

The header above the DMA values also asks for DMA-Phi2-Timing.md to be updated when they change. A row in its "Timing values" table would cover it:

| nS_KSenseSample | Re-latch at setup | 250 | 250 | 250 | Kernal Replace HIRAM sense (C128): sample ROMH in the stolen cycle |

2. Name the late-interrupt dependency of KDS_Skip (IOH_KernalReplace.c:95)

KDS_Skip only works because the rising edge during the stolen cycle always re-fires isrPHI2 late: the Teensy core clears the GPIO interrupt flag before it calls the handler (irq_anyport in cores/teensy4/interrupt.c). That holds today, but if the ISR ever moved to a vector that clears the flag on exit, the late interrupt would disappear and KDS_Skip would swallow the CPU's real next cycle, the KERNAL read after every $01 write. A comment saying so would keep that from being lost. Suggested wording inline.

3. The sense-mode message's strings to flash (IOH_KernalReplace.c:88)

"Kernal HIRAM sense: %s\n", "DMA" and "Skoe" are plain literals, so they land in DTCM in every TR+ build: 40 B of the 49 above.

Suggested fix (inline): print it with Serial.println(PSTR(...)), the same form RAMBanks.c uses, which saves those 40 B of DTCM. It's thread-mode code that runs once per launch, so reading the strings from flash costs nothing that matters.

On your comment about using the DMA sense on the C64 too: tx2 also worked on a C64 here. One trade-off: the DMA sense decides at the write, so it inherits the case your description notes, a store whose dummy read hits $00/$01 (such as STA $01,X) keeping the last verdict. The C64's current sense decides at the KERNAL read itself, so it can't go stale that way.

Hardware testing: Just to document. I'll hit these at some point, not a request or requirement! :)

Your C128 testing covers BASIC and the keyboard, image serving, SwiftLink programs, RAM under the KERNAL and sprite multiplexing. I ran this exact code on a TR+ in an NTSC C128 in C64 mode. BASIC and the keyboard work with the stock, JiffyDOS and JaffyDOS images, and tx0 brings the old dead keyboard back, as expected. With JiffyDOS, a PRG launched under Kernal Replace that fills RAM under the KERNAL, switches $01 with DEC/INC both ways and compares what it reads back came back clean. On an NTSC C64 the same test passed with both tx0 and tx2. That's a confirmation run of this code on hardware, so from my side it can come out of draft.

  • PAL C128 and C128DCR
  • A sprite-heavy program with the KERNAL banked out
  • The menu button and a remote pause during a run
  • LOAD and SAVE on a real drive under Kernal Replace on the C128
  • A C128 launch that doesn't come from the menu (no C128 report, so the C64 sense)

Thank you!

…rings)

nS_KSenseSample is measured from the nS_DMASetup re-latch like
nS_DMADataSetup, not from Phi2 rising: say so, so tuning tn### against a
scope isn't 44 nS off. The 98-136 nS ROMH figures were taken by the
Teensy's cycle counter in that same time base. Add the value to the
DMA-Phi2-Timing.md table as that header asks.

KDS_Skip relies on the late rising-edge interrupt always arriving (the
Teensy core clears the GPIO flag before calling isrPHI2); name that, since
losing it would swallow the KERNAL read after every $01 write.

Print the sense-mode message with Serial.println(PSTR()) so its strings
stay in flash (40 B of DTCM).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5t9wW1fYNqPSqpncL8XeN
@wesbiggs

Copy link
Copy Markdown
Contributor Author

Thanks for the review and for the hardware runs on both machines!

All three suggestions applied, plus the nS_KSenseSample row in DMA-Phi2-Timing.md.

On the time base: the 98-136 nS figures came from the Teensy’s cycle counter in a debug build of this same sense code, measured from the nS_DMASetup re-latch, so they’re already in the time base your wording describes (no +44 nS). Over three runs of 60 senses, all read ROM, with ROMH seen low at 98-116 nS in 174 and 117-136 nS in 6. I’ve reworded the line under it to say so. 250 nS leaves about 114 nS of margin past the latest one.

On the C64: agreed. Since my earlier comment I haven’t seen a bad read on the C64 with current builds, so there’s no reason to move it to the DMA sense.

Moving out of draft.

@wesbiggs
wesbiggs marked this pull request as ready for review October 31, 2026 01:30
@SensoriumEmbedded
SensoriumEmbedded merged commit fa50942 into SensoriumEmbedded:main Oct 31, 2026
4 checks passed
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.

3 participants