89 KiB
Modem Wars over a SwiftLink
An alternate opponent module for Modem Wars (C64, Electronic Arts / Ozark Softscape, 1988) that
talks to a SwiftLink cartridge - a 6551 ACIA on the expansion port - instead of the shipped
module's bit-banged user-port UART. It is a drop-in replacement for the stock modem driver: same
4096 bytes at $E000-$EFFF, same jump table, same shared variables, same wire protocol.
It does not impose a line rate. The driver comes up in adopt mode: it reads the 6551's control
register before it touches anything and uses the rate, stop bits and clock source the hardware is
already configured for, so a C64 Ultimate or a SwiftLink that has been set up for something in
particular is played at its own setting. Eight data bits are the one thing it insists on, because
the frame layer above is not 7-bit clean. Seven fixed rates from 300 to 38400 are still one hot key
away for anyone who wants to pick one, and C= + U goes back to the port's own settings.
Why it works, in one sentence: the shipped module is layered, and only the bottom layer is hardware specific, so replacing the UART and nothing else leaves the packet layer, the ARQ frame layer, the ring buffers and the Hayes/link state machine bit-for-bit as Ozark Softscape wrote them.
packet / chat layer $E111-$E2FF game-facing packets, host rings, exchange flags unchanged
modem + link state $E756-$E8xx Hayes control, connection phases, carrier unchanged
frame / ARQ layer $E909-$EAB2 length + control + sequence + checksum, retries unchanged
ring buffers $E40F-$E533 four descending circular buffers unchanged
UART layer the rest CIA2 timers, NMI bit-banging, user port >> REPLACED <<
474 of the module's 4096 bytes changed. Everything else, including all three regions the rest of the game reaches into, is byte-identical to the original.
1. Hardware
What it needs
- A Commodore 64 (or 128 in 64 mode) and the original Modem Wars disk.
- A SwiftLink-232 or compatible cartridge (Turbo232 and the modern GLINK232-style rebuilds
present the same 6551 at the same addresses). Base
$DE00is the default; the module probes$DE00first and falls back to the$DF00strap option automatically, so either jumper setting works with the same disk. - The cartridge's NMI must be wired to the expansion port
/NMIline, which is how a SwiftLink is normally strapped. The module installs its own handler at$FFFA/$FFFBand chains anything that is not its own interrupt, so RESTORE still works. - At the far end: a modem, or a null-modem cable to a second C64 with a SwiftLink.
What it does NOT need
- No user-port RS-232 cable or interface. The user port is not touched at all.
- No CIA2 timers. The stock driver latched CIA2 timer A and timer B to a bit period and
bit-banged every bit out of
CIA2_PRAPA2 from the NMI. None of that is left. The only CIA2 code in the whole module issilenceCia2$E56F, which pinsCIA2_ICRat$7Fand reads it back so CIA2 lets go of/NMIfor good. - No baud-rate calibration. The line rate is one read of the ACIA control register - to find out what the port is already set to - and one write of it back.
Caveat worth knowing before you buy a board. Some modern SwiftLink rebuilds use a WDC W65C51N, whose TDRE status bit reads as permanently set. This driver is interrupt driven and never polls TDRE in a loop, so it does not hang, but on such a board the transmit interrupt fires continuously and characters would be overrun; those boards need a timed delay per character instead. A genuine 6551 - and VICE's emulation of one - behaves.
2. Building and installing
Requires ca65/ld65 from the cc65 tool chain and Python 3. From the repository root:
./swiftlink/build.sh # -> Modem_Wars_SwiftLink.d64
./swiftlink/build.sh /path/to/other.d64 # or name your own output
build.sh does three things:
- assembles
swiftlinkDriverE000.swithca65and links it throughlink.cfginto a flat 4096-byte image at$E000(swiftlink/build/swiftlinkDriverE000.bin); - refuses to continue unless that image is exactly 4096 bytes;
- runs
injectDriver.py, which copiesModem_Wars_1988_Electronic_Arts_b4.d64and writes the module into it, re-applying the EA sector cipher on the way.
The disk it produces is a full 35-track, 174848-byte .d64 that boots and plays exactly like
the original - same loader, same title screen, same copy-protection map question, same solo trainer.
It differs from the stock image in six sectors: track 18 sector 7, and track 34 sectors 3, 4, 5, 6
and 7 - module pages $E000, $E300, $E400, $E500, $E600 and $E700, the only pages any
changed byte lands in. Track 34 sectors 1, 2 and 8-15 are byte-identical to the original. (Page
$E000 used to be identical too; it stopped being so when the transmitter fix took over the dead
stock-UART constants at $E04C-$E05E, and page $E400 joined the list when the probe hardening took
over the dead ignoreLinkError stub at $E408. The per-page counts are 18, 43, 12, 111, 236 and 54
changed bytes, which add up to the 474 checkAbi.py reports, because the EA sector cipher is a
keystream XOR that depends only on the track and sector.)
The module is only fetched when the player picks COMPETE WITH MODEM OPPONENT. A solo game loads the trainer from track 35, which this build does not touch.
Checking the ABI
checkAbi.py proves that nothing the rest of the game depends on has moved:
python3 tools/build.py # writes disassembly/build/*.orig.bin from the disk
python3 swiftlink/checkAbi.py swiftlink/build/swiftlinkDriverE000.bin \
disassembly/build/game_modemDriverE000.orig.bin
It prints every changed range and then verifies the three frozen regions and the twenty-two entry points. Current output ends with:
bytes changed: 474 of 4096, in 27 region(s)
...
ABI CHECK PASSED: every address the rest of the game depends on is unchanged
3. Port settings: adopt mode, hot keys and direct connection
The point of this section in one sentence: the driver runs at whatever your port is configured
for. It does not impose a line rate, it does not impose stop bits, and it does not impose a clock
source. Two things it does insist on, and both are forced by the protocol above it rather than
chosen: eight data bits, because the frame layer is not 7-bit clean, and a usable clock, because
a SwiftLink has no external one to run from. Everything else is copied out of the chip as it stands.
If you would rather name a rate, seven of them are one hot key away, and C= + U hands control back
to the port.
Adopt mode: the port's own settings
The last entry of baudEntryTable $E710 (the table below), index 21, has no rate of its own.
aciaProbeAdopted $E655 reads the 6551's control register on the cold start - before the page probe
overwrites it, which is the only moment that value still exists - and parks it in that entry,
corrected only where it has to be:
- the rate, the stop bits and the receiver clock source are copied through untouched;
- the word length is forced to eight data bits. See the next heading; this one is not negotiable.
- a setting that cannot work is replaced.
control & $1Fbelow$11means bit 4 = 0 (an external receiver clock, which a SwiftLink does not have), or rate bits%0000(an external transmit clock), or both - which is exactly what a chip that nobody has configured reads as, since reset clears the register to$00. The fallback is$15: 300 baud, 8N1, internal generator. It is the rate the stock driver always came up in, one of the only two a stock user-port peer can produce, and the game generates about 30 bytes a second, which is precisely what 300 baud carries.
Adopt mode is the disk default: baudIndex $E055 ships as 21, so a link opened without
pressing anything runs at the port's own settings. C= + U returns to it at any time.
Measured, two machines, with the control register pre-set from the monitor exactly as a configured
cartridge would have left it (portCompatReport.md section 5, testReport.md section 11):
| What the port was set to before the game touched it | What $DE03 held after the link opened |
The link |
|---|---|---|
$98 - 2400 baud, 8 data bits, two stop bits |
$98 on both machines, adopted whole |
healthy, 75.2 s, 9.0 B/s each way, 0 of 148 seconds carrying nothing |
$B8 - 2400 baud, seven data bits, two stop bits |
$98 on both machines - only the word length was corrected |
healthy, 75.2 s, 9.0 B/s each way, 0 of 148 empty |
$00 - a chip nobody has configured |
$15 on both machines - the 300-baud fallback |
works, 75.2 s, 10.2 B/s each way, 0 of 148 empty, and the wire really runs at 300 |
The old driver wrote $15 into all three of those cases without looking at what was there.
What adopt mode does not cover: parity. Parity lives in the 6551's command register, not its
control register, and the driver rewrites that register from constants ($09, $0B, $05) every
time it arms or disarms the transmitter or suspends the receiver for a disk load - DTR, RTS and both
interrupt enables share the byte, so it cannot be part-written. All of those constants have the
parity bits clear. A port configured for parity therefore runs without it. Both ends of a Modem
Wars link run this same driver and clear those bits identically, so the two still agree; what it
would break is a peer that is not this driver and does expect parity, and there is no such peer.
portCompatReport.md section 8 has the measurement (command $69, even parity, pre-set on both
machines: the link came up and ran, with parity off).
The one setting that is not adoptable: eight data bits
The frame layer above the UART is not 7-bit clean, so eight data bits are mandatory whatever else
the port is set to. aciaProbeAdopted forces bits 6-5 of the control byte to %00 with a single
AND #$9F at $E665 - the only place in the module that masks a control byte - and leaves every
other bit alone. Three places in the unchanged protocol need the eighth bit:
- the byte-sync handshake answers
$00with$FF, and$FFis tested for exactly, by incrementing it to zero (INX/BNE restartByteSyncat$E852-$E853). On a seven-bit line the peer's$FFarrives as$7F, the test fails, and byte sync restarts for ever: the border stays red and the link never turns green; - the frame layer leads with a
$55length code and follows it with raw sequence bytes that take any value at all; computePacketChecksum$E937is a full eight-bit rotate-and-add (ASL A/ADC #$00/ADC packetBuffer,x).
Parity and stop bits cost the frame layer nothing - they change how a character is delivered, not
what is in it - which is exactly why the mask touches two bits and no others. This is a reading of
the source rather than an experiment, and it cannot be an experiment on this rig: VICE's ACIA is byte
level and will not truncate a character to seven bits however the control register is set. What
was measured is what the correction writes: a port pre-set to $B8 (seven data bits) came up as
$98, with the stop bit, the clock source and the rate bits all untouched, and $5A (six data bits)
and $3C (seven data bits) came up as $1A and $1C.
The seven fixed rates, and their hot keys
A SwiftLink clocks its 6551 from a 3.6864 MHz crystal, twice what the chip expects, so every rate in
the data sheet's table comes out doubled. The module's table is at $E710: eight three-byte
entries of {hot-key code, ACIA control byte, spare}. For the seven fixed rates the control byte is
$10 - 8 data bits, one stop bit, internal baud-rate generator - OR the rate bits, so pressing a
speed hot key selects 8N1 outright and leaves adopt mode. The eighth entry is adopt, and its
control byte is written at run time.
| Index | Hot key | Control byte | Rate | Stock user-port peer? | Two machines at true C64 speed, on this build |
|---|---|---|---|---|---|
| 0 | C= + 3 | $15 |
300 | yes | healthy, and it saturates the line: 29.6 B/s, no second carrying nothing, no error of either kind |
| 3 | C= + 1 | $17 |
1200 | yes | healthy; one receive-ring overflow on each machine, both recovered |
| 6 | C= + 2 | $18 |
2400 | no | healthy; no error of either kind |
| 9 | C= + 4 | $1A |
4800 | no | healthy; no error of either kind |
| 12 | C= + 9 | $1C |
9600 | no | healthy; no error of either kind |
| 15 | C= + 0 | $1E |
19200 | no | healthy; no error of either kind |
| 18 | C= + 8 | $1F |
38400 | no | healthy in two boots, 170 s and 245 s; no error of either kind, and no wedge in seven boots |
| 21 | C= + U | the port's own | whatever the hardware is set to | if the hardware is | the default; see above |
That column is portCompatReport.md section 3 and testReport.md section 11: one boot per rate,
three measured windows each - 75 seconds clean, 75 with the byte-sync tracepoints and 20 with the
error tracepoints - 170 seconds of link time per rate and 1194 seconds over the sweep. In the whole
sweep there were two characters rejected by a receiver and two receive-ring overflows, and
they are the same two events: one on each machine, both at 1200 baud, both in that rate's 20-second
instrumented window, both recovered from. No rate above 1200 produced a receive error at all, and no
emulator log in the sweep contains one of VICE's data register written although data has not been sent yet lines, which the pre-guard build produced for 0.8% of transmitted characters at 4800 rising
to 2.3% at 19200. (The error counts are from the instrumented windows; the "no second carrying
nothing" figures cover all 334 sampling seconds of each boot.)
Read that table with its traffic in mind. At the point every one of those measurements was taken
the game puts about nine bytes a second in each direction on the wire, and it does that at every
rate from 1200 up: the line rate makes no difference to how many ARQ frames the lock-step packet
layer produces. That is this driver's whole premise stated as a measurement - 300 baud already
carries everything the game generates - and it is why "which rate is fastest" is the wrong question
to ask of the table. 300 is the one rate that has to work for it, moving 29.6 B/s and three times as
many $55 frame-lead bytes per window as any faster rate.
What was different before the re-entrancy guard. 9600 and 19200 used to overflow the receive ring
8 to 128 times in a 20-second window and spend a visible part of every second re-synchronising, and
38400 wedged the machine four boots out of four, one of them ending in *** Main CPU: JAM at $0007.
wedgeDiagnosis.md traced all of it to unbounded NMI re-entrancy inside commNmiHandler - to the
handler, not to the rate - and the guard described in section 5 bounds the nesting at one frame
whatever the line rate. highRateReport.md's "must not be selected: 38400" was written from those
four boots and has been overturned; that file carries a note at the top pointing here, and its
measurements are left as they were taken.
Where the headroom still runs out. The raw receive ring at $E430 holds 20 characters, only the
once-per-frame link tick drains it, and awaitWholePacket $E9D9 will not copy a packet body out
until the whole payload and its check byte are resident - up to 17 bytes - so the real headroom is
about three bytes. There is no RTS throttling anywhere in this driver. A video frame is 0.5
character times at 300 baud, 4 at 2400, 8 at 4800, 16 at 9600, 32 at 19200 and 64 at 38400
(highRateReport.md section 1), so a peer's ARQ frame arriving as a burst starts to outrun the ring
somewhere above 4800, and at 38400 three bursts can land inside one frame. What the driver does about
an overflow is the part that matters: linkErrorCount forces a byte-sync round, the round completes,
and the packet layer carries on. That path was exercised at 1200 on this build and at 9600 and 19200
on the previous one. What the guard changes is the next step - on the old build a resync flood at
38400 tipped the handler into the runaway that ate page 1, and that link in the chain is gone.
38400 is the rate with the least margin, and the margin is measured. At the traffic this game produces the handler is entered about eighteen times a second and, in 3252 entries across 90 seconds, never nested once; but half of the gaps between consecutive entries were under 300 cycles, with a median of 266 - exactly one character time at 38400 - against a handler that costs 175 cycles for one half and 256-266 for a pass that services both. The margin is between zero and ninety cycles. Nothing in this game saturates a 38400 line, so the saturated case was never provoked here; the arithmetic still says a saturated 38400 line costs more CPU than a C64 has, and what that now buys is ring overflows and resyncs rather than a dead machine.
Note also that a SwiftLink rebuilt with the standard 1.8432 MHz part will run every entry in that table at half the speed shown.
Indices 0 and 3 keep the meanings the stock baudParameterTable gave them, because the module's own
code uses those numbers: the CONNECT 1200 result-code sniffer at $E7B0 loads 3.
baudIndex $E055 is not saved anywhere. It comes off the disk as 21 (adopt) every time the
module is loaded, and the game never reads or writes it. So the working sequence is: pick COMPETE
WITH MODEM OPPONENT, wait for the module to load and the "GET OPPONENT ON PHONE AND PRESS A OR O"
prompt to appear, then press a speed hot key if you want to override the port's own setting, then
A or O, then space. Pressing a speed key also reprograms the ACIA immediately, so it works on a live
link too - but both ends have to agree, whether by adopting the same configuration or by pressing the
same key.
$DE00 or $DF00: the strap looks after itself
Base $DE00 is the SwiftLink default; $DF00 is the usual jumper alternative, taken when something
else already lives at $DE00. The same disk works either way and there is nothing to set. On
the cold start - once, before the module has touched the chip for any other reason -
aciaDetect $E644 calls aciaProbeAdopted $E655 on $DE00: it reads that page's control
register, writes $1E, reads it back, writes $15, reads it back, and if both patterns survive it
latches $DE into aciaPageLatch $E5BF and self-modifies the six accessors to that page. If the
first pattern does not read back - an empty $DE00 is open bus - it repeats the whole sequence on
$DF00 and latches $DF.
Three properties of that matter for port compatibility. The first was measured on the current build
(portCompatReport.md section 9.1, testReport.md section 11.6); the other two were measured on the
build that introduced the page latch (hardenReport.md) and re-checked in the current build's strap
traces, which show one probe sequence per cold start and no mirror access:
- The fallback reads your chip, not the open bus. The
$DF00pass reads$DF03before it writes a probe pattern into it, so a cartridge strapped to$DF00gets its own settings adopted. The byte the failed$DE00read returned -$F6in the measured run, which is screen data on the floating bus - sits in the adopt entry for a few instructions and is then overwritten by the real one. - The probe runs once per cold start, not once per link open and once per hot key. That matters
when something else is at
$DE00, which is the usual reason to strap to$DF00in the first place: the build before the latch wrote$1Einto that foreign device's register on every link open and every speed key, on a live link. Measured inhardenReport.md: eight calls toloadBaudParametersper machine - two link opens and six landed hot keys - and the probe ran once, with the six hot-key writes$17 $18 $1A $1C $1E $15and no probe pattern in front of any of them. - Nothing touches the other page, or the mirrors. On a
$DF00machine the whole of the driver's business with$DE00-$DE03is over in three accesses on the current build - the adopt read, one probe write, one failed read-back - and it never goes back;hardenReport.mdmeasured two on the build before the adopt read, and zero during a hot-key sweep and a 20-second census of 3281 register accesses. The register mirrors$DE04-$DEFF/$DF04-$DFFFwere never addressed at all, on either strap, on either build. That last one is not cosmetic: one of those mirrors is the data register, and reading it eats a received character.
If you are running this on a C64 Ultimate
The Ultimate's SwiftLink emulation is configured in the Ultimate's own menus, not by the game, and it can be set to a much faster line than Modem Wars was ever written for. That is the case this driver was reworked for.
- The driver takes the setting; it does not make one. It comes up in adopt mode and uses the
control register the device already holds, so whatever the Ultimate is configured for is what the
game runs at - as long as the device presents that configuration in the control register the way
VICE and a real SwiftLink do. If it presents
$00there (an unconfigured 6551 reads that after reset) the driver falls back to 300 baud rather than trying to run on an external clock that is not there. Any speed hot key still overrides it, andC= + Ugoes back. - Every rate came up healthy on this build, 300 through 38400: continuous traffic in both directions in every one of 334 sampling seconds per rate, no receive error above 1200 baud, and no transmitted character lost. The 9600 and 19200 "degrades and recovers" warning in the older documents, and the "38400 must not be selected" one, both belong to the build before the re-entrancy guard.
- Two stop bits, or seven data bits, or an unconfigured chip were all handed to the driver with
two machines linked:
$98came through whole,$B8came through with only the word length corrected, and$00fell back to 300 baud. Parity is the one thing that does not survive; see above. - If the two ends disagree on the rate, there is no negotiation in the protocol to fix it. On this rig a 38400/300 mismatch did no harm - VICE paces whole bytes and does not garble them, so the pair simply ran at the slower end's rate - but on real hardware the slow receiver would raise framing and overrun errors on nearly every character, and the symptom is the one in section 4: the border stays red, the link never turns green, the game never gets past "OPPONENT PICKING GAME/MAP." The fix is the same in both cases: put both ends on the same setting, either by configuring both cartridges the same way or by pressing the same hot key on both.
- None of this has been run on hardware. Every measurement in this repository is VICE 3.7.1. No physical SwiftLink, no real cable, and no C64 Ultimate has ever been touched by any of it - and adopt mode in particular is only as good as what the device puts in its control register. Every preset in every adopt run was written there from the VICE monitor.
The measurements behind that list are in portCompatReport.md (the current build: every rate, adopt
mode on two machines, the eight-bit guard, both straps) and wedgeDiagnosis.md (why 38400 used to
take the machine down), with the earlier highRateReport.md, rate1200Report.md, realtimeReport.md,
strapReport.md and hardenReport.md behind them; testReport.md folds all of it together.
Hot keys
$E015 is the game's only keyboard read, so these work anywhere the game is reading keys
(except while inputLockoutTimer $0B7D is non-zero, which is when the raster IRQ skips the
keyboard poll entirely). Every combination except C= + RETURN is swallowed, so the game never sees
the keystroke - that was already true of the stock driver.
| Key | Action |
|---|---|
| C= + 3 / 1 / 2 / 4 / 9 / 0 / 8 | 300 / 1200 / 2400 / 4800 / 9600 / 19200 / 38400 baud - impose a fixed rate and 8N1, leaving adopt mode |
| C= + U | adopt mode: use the rate, stop bits and clock source the port is already configured for (the default) |
| C= + A | answer mode, then re-program the ACIA |
| C= + O | originate mode, then re-program the ACIA |
| C= + RETURN | re-open the link without re-sending the AT string (the only key passed on to the game) |
| C= + P | hang up: drop DTR, then queue +++ / ATH0 |
| C= + H | toggle DTR |
| C= + C | toggle the carrier override - report carrier without looking at DCD |
| C= (held) | paint the border with the link-status colour |
Two changes in behaviour from the stock driver, both forced by the hardware:
- C= + H used to flip CIA2 PB2 and PB5 together. It now flips bit 0 of the ACIA command
register. On a 6551 clearing that bit does not only drop DTR, it also shuts the receiver and
every ACIA interrupt down - which is exactly right for "hang up", but it means the link stays dead
until you press C= + H again.
startNextTxCharrefuses to arm the transmitter while DTR is down, so a hang-up stays hung up. - C= (held) still paints the border from
linkStatusBorderTable$E05F, indexed byconnectionPhase*2 + carrier, and that table is unchanged: black/yellow = phase 0, light blue/blue = phase 1 (talking to the modem, always painted), light red/red = phase 2 (byte sync), light green/green = phase 3 (the packet protocol is running). Green is what you want.
Direct connection (null-modem cable, no modem)
This works out of the box and needs no separate build. DCD, DSR and CTS are pulled up on the cartridge, so a three-wire null-modem cable with no DCD wire reads as permanent carrier, which is what a direct connection wants. The sequence is:
- Both machines: COMPETE WITH MODEM OPPONENT.
- Both machines: nothing, if both cartridges are configured the same way - adopt mode is the default and both ends will come up on their own settings. Otherwise the same speed hot key on both, which also puts both ends on 8N1.
- One machine A (answer), the other O (originate). The byte-sync handshake at
$E805is symmetric, so this choice does not decide who talks first - but the game still asks, and$E011still records it. - Both machines: space.
The module still queues the Hayes answer/originate string (modemOptionFlags $E048 ships as
$CC, bit 7 = "a modem is attached"). Those nine characters go down the cable and are discarded by
the peer's byte-sync phase, and after roughly two seconds - the 121-frame answer timer at $E7D5 -
the state machine falls through into byte sync and the link comes up. That two-second wait is the
entire cost of leaving the Hayes layer switched on.
If you want it truly silent, clear bit 7 (and bit 6, since there is no result code to sniff) of
modemOptionFlags at $E048. That byte lies outside the frozen region and is a plain constant in
the source; queueModemCommandString $E393 then returns immediately, modemReplyTimer stays 0,
and the module goes phase 0 -> 2 -> 3 without ever entering phase 1. Nothing else has to change.
C= + C is redundant on a three-wire cable, but it is still there for a cable that does carry a real DCD from a peer that is not asserting it.
4. Wire compatibility with the stock driver
A SwiftLink player and a stock user-port player can play each other, at 300 or at 1200 baud.
Nothing above the UART changed, so both machines put the same bits on the line: the same $00/$FF
byte-sync handshake, the same length + control + sequence + checksum frame, the same retransmit
rules, the same packet and chat layers, and the same $E030 build id ($00 = "I am the modem
module") exchanged during exchangePlayerNamesAndSettings. Neither end can tell what kind of UART
the other is using, because nothing on the wire says.
Framing is the one thing to watch now that the driver adopts the port's settings. The stock user-port driver is 8N1 and nothing else; a SwiftLink in adopt mode is 8 data bits and whatever stop bits and parity the cartridge is configured for. Eight data bits are guaranteed at both ends - the driver corrects the word length and nothing else - so a SwiftLink whose port happens to be set to 8N1, which is what an unconfigured one falls back to, is bit-for-bit what the stock driver produces. A SwiftLink set to two stop bits or to parity will not talk to a stock user-port peer, and the fix is either to configure the port for 8N1 or to press a speed hot key, which selects 8N1 outright.
300 and 1200 are the only two speeds the stock module can produce, so they are the only places the two builds can meet. Two SwiftLinks can meet anywhere in the table - including on a setting neither of them chose, if both cartridges are configured the same way, which is what adopt mode is for.
If the two ends are set to different rates, there is no rate negotiation anywhere in the
protocol to rescue them. On real hardware the receiving 6551 samples at the wrong bit period, so the
characters arrive as garbage or raise framing and overrun errors; the NMI pushes every one of those
through reportLinkError $E3BC -> countLinkError $E73F, and a non-zero linkErrorCount
$E047 makes runLinkStateMachine restart byte sync no matter which phase it was in. The expected
symptom is a link that sits in phase 2 for ever: hold C= and the border stays red / light red and
never turns green, and the game never gets past "OPPONENT PICKING GAME/MAP." Fix it by putting both
ends on the same hot key.
That failure has not been observed, and it cannot be on this test rig: VICE's ACIA paces whole
bytes at whatever rate the control register asks for but does not shift them bit by bit, so two
emulated ends set to different rates do not garble each other. A deliberate 38400-against-300
mismatch was run for 246 seconds (highRateReport.md section 7) and was the healthiest pair in
that whole report - both ends reached phase 3, no interval carried nothing, not one receive-ring
overflow or bad character at either end. The pair ran at the slower end's rate, because the packet
layer is lock-step and will not send the next thing until the previous exchange completes, so the
slow peer paces the fast one for free. What that establishes is that neither state machine falls
over on the mismatch itself; what it cannot establish is what the error storm on a real line would
do.
One exception is automatic: if a real Hayes modem answers with a verbose CONNECT 1200 result code,
the sniffer at $E7A9 switches that machine to index 3 (1200 baud) on its own, exactly as the stock
driver did.
5. What was verified, and what was not
Everything below is from testReport.md and the eight companion reports it folds in -
realtimeReport.md (two machines at true C64 speed, 300 and 2400, and the transmitter fix),
hotkeyReport.md (the Commodore-key hot keys), strapReport.md (the $DF00 jumper),
rate1200Report.md (1200 baud and the CONNECT 1200 sniffer), highRateReport.md (4800, 9600,
19200, 38400 and a rate mismatch), hardenReport.md (the page latch), wedgeDiagnosis.md (why 38400
used to wedge) and portCompatReport.md (the current build: adopt mode, the eight-bit guard and
every rate including 38400). All of it is VICE 3.7.1 (x64sc), run on 2026-08-21 and 2026-08-22.
Read those for the full detail; this is the honest summary.
Where the older reports and this file disagree, this file and portCompatReport.md are the current
build. The rate advice in highRateReport.md and the 38400 retirement in hardenReport.md were
both written before the NMI re-entrancy guard and have been overturned; both files carry a note at the
top saying so, and neither has had its measurements rewritten.
Verified
- The baud doubling. The task brief's claim was checked against independent sources before the
table was written. From the test report: "Two independent sources agree with it, so the claim
holds" - cc65's SwiftLink driver
libsrc/c64/ser/c64-swlink.s, whoseBaudTablemaps 300 to$05, 1200 to$07, 2400 to$08, 4800 to$0A, 9600 to$0C, 19200 to$0Eand 38400 to$0F- twice the data sheet's nominal rate for each code - and VICE's ownsrc/aciacore.c, whoseget_acia_bps()returnsacia_bps_table[ctrl & 0x0F] * 2inACIA_MODE_SWIFTLINK. CMD's SwiftLink-232 Application Notes v1.1 say the same thing in prose. Re-checked against the current cc65 source while writing this file: it still reads that way. - The patched disk boots like the stock disk. Same options menu, no crash, no hang, no missing
text (
shots/stockMenu.pngvsshots/swiftlinkMenu.png). - The right module is loaded. After picking COMPETE WITH MODEM OPPONENT,
$E000read4C 11 E1 4C 9A E2 4C A9 E0 4C F6 E0 4C CB E3 60- "byte for byte the SwiftLink build, and nothing like the trainer that was there a moment earlier" - and$E540-$E56Fheld the six one-instruction ACIA accessors aimed at page$DE. - No ACIA register is touched until both modem prompts are answered. Watchpoints on
$DE00-$DE03stayed silent through the menu, the module load and both prompts. - The module programs the 6551 correctly. First accesses, in order: probe pattern
$1Eto the control register and read back, then - since the probe became a two-pattern one - pattern$15and its read-back, control$15(8N1, internal clock, 300 baud), drain the receiver, read the status, command$09(DTR on, RTS low, receiver IRQ on, transmit IRQ off). Command$05appears whenever there is something to send, and reverts to$09when the transmit ring runs dry. (That order has one more step in front of it now: the control register is read before the first probe pattern is written, which is how adopt mode recovers the user's own setting. The rest is unchanged.) - It transmits. The bytes handed to the data register were
$0D $41 $54 $51 $30 $56 $31 $58 $31 $41 $0D= CRATQ0V1X1ACR, the answer-mode Hayes string, followed by the$00stream ofbeginByteSyncPhase. - Two machines play each other. Two emulator instances cross-connected through a TCP relay (a null modem made of sockets) both reached connection phase 3, the game's own packet protocol, and exchanged thousands of bytes in both directions. Machine A showed "OPPONENT PICKING GAME/MAP." and machine B was on the GAME TYPE menu, so the two had already agreed over the link which of them plays side 0 and which side 1.
- Real time, not warp: at 300 baud the link is healthy and saturates the line. Two machines at
true C64 speed (VICE's monitor has a
warpcommand even though it has noWarpModeresource) ran a 75-second measurement window with no monitor input in it: from t=2 s to t=55 s almost every one-second bucket held exactly 30 bytes in each direction (54.09 s averages 29.14 and 29.43 bytes/s), and at 8N1's ten bits per byte 30 bytes/s is exactly 300 baud - 100% of what the line can carry.linkErrorCount$E047was$00at both ends and a tracepoint oncountLinkError(then at$E403, now$E73F) counted zero events in the window, so no framing, parity or overrun character and no ring overflow. The emulators' own cycle counters confirm they ran at 100.5-100.9% of real time throughout. - The hot keys work, and the Commodore key in VICE is Tab. Under the default symbolic keymap
(
gtk3_sym.vkm)Tabis matrix row 7 column 5;Control_Lis the C64 CTRL key, which the game'sscanKeyboardreports as "no key at all", which is why an earlier session'sctrl+cdid nothing. Held with Tab: C= + C togglescarrierOverrideFlags$E04Abetween$00and$40, and with a "no carrier" DCD forced intoaciaStatusSave$E5BCthe sampler was single-stepped both ways - override on skips the DCD test entirely and publishes$E03D=$40(carrier), override off runs it and publishes$00(no carrier). C= + 2 putbaudIndex$E055= 6 and the ACIA control register$DE03=$18(2400), C= + 3 put both back to 0 /$15(300), on a live link and at the A/O prompt. C= held really does paint the border: a checkpoint on the module's ownSTA $D020at$E30Cfired withA=$02(red) andX=$80. ciaIcrShadowis corrected at run time. Both running machines read$E033back as$7F, which is what stops the main program's film-buffer routines re-enabling CIA2 interrupts behind the driver's back. Its stored byte in the module image is$90and must stay that way, since it is inside the frozen region.- The 2400-baud transmitter deadlock was found, fixed, and the fix re-measured. From
realtimeReport.mdsection 8: nine 75-second windows at 2400 baud over three boots, 675 seconds of measured link time, reached both ways (C= + 2 on a live link and at the A/O prompt), and both directions carried traffic in every single second of every window. The state that defined the fault -txCharActive$E5BBnon-zero whileaciaCommandShadow$E5BEhas bit 2 clear - did not occur once in 66 state readings, and the rig's deadlock probe never fired, so nothing in those numbers is the test repairing the driver. What it replaced: the same rig, the same rate, the same game state, one machine silent for 225 seconds while its peer flooded$00. - 1200 baud carries a game.
rate1200Report.md: six 75-second windows over three boots, 452 seconds, 13121 bytes one way and 12109 the other, and not one second in either direction carried zero bytes. Per-second buckets ran from 6 to 119 bytes/s, and 119 is 99% of the 8N1 ceiling at 1200 baud, so the line does get saturated in bursts even though the average is a quarter of it. - The receive ring really does overflow, and the driver really does recover. 1200 baud is the
first rate at which it was seen: 32 overflows across those six windows, every one proved to be a
ring overflow rather than a bad character - a conditional tracepoint on the
CPX #$14insidepushUartRxRingreturned counts identical to the unconditional error tracepoint - and every one followed by a byte-sync round that completed and a return to the packet phase. - The
CONNECT 1200auto-baud path selects 1200. A fake Hayes modem answered the module's AT string withCONNECT 1200; the driver sniffed it and left ACIA control$DE03=$17withbaudIndexstill 0, which onlyswitchTo1200Baud$E7B2can produce. Driving$E7B2directly with X = 0, 3 and 6 gave$15,$17and$18, so the three-byte stride ofbaudEntryTableis right and not just the one index (rate1200Report.mdsection 5). - 4800 baud is healthy.
highRateReport.mdsection 2: four windows, 246 seconds, none of the 450 sampling intervals carried zero bytes, and the error tracepoints recorded zero hits of either kind - no bad character and no ring overflow.uartRxCountsampled 24 times per machine peaked at 7 of 20. - 9600 and 19200 degraded and recovered on the build before the guard.
highRateReport.mdsection 3: no sampling interval at either rate carried nothing (0 of 442 and 0 of 450); every error counted was a ring overflow, the conditional and unconditional tracepoints returning identical counts at both rates; and byte-sync rounds were entered and completed at roughly 1.3 to 1.8 a second. The cost was real - 2.1% to 2.3% of transmitted characters lost to the driver writing a data register that had not emptied, and the extra line time buying no extra ARQ frames - but the link stayed up and the game kept moving. On the current build neither rate produced a receive error or a lost transmit character at all (portCompatReport.mdsection 3); the degradation described here is what the re-entrancy guard removed, and this bullet is kept as the "before". - 38400 no longer wedges, and its hot key is back. Two machines at true C64 speed, 38400 baud,
200 seconds with
trace exec e685 if sp < $b4armed from before the link was opened: not one trace line on either machine, page 1 untouched below$01C0at the end, both machines still running game code with the interrupt flag clear. On the old build the same instrumentation recorded 34 nested frames and three stack descents of 8 to 13 ms. Seven boots at 38400 on this build, none of them wedged (portCompatReport.mdsection 4.4), where the previous build wedged four out of four. See "Fixed: the 38400 wedge" below. - Every rate in the table was measured on this build, and all seven are healthy.
portCompatReport.mdsection 3: one boot per rate, two machines at true C64 speed, three windows and 170 seconds of measured link time each (38400 got a second boot of 245 s), 1194 seconds over the sweep. No rate had a single one-second sampling interval carrying nothing in either direction, in 334 intervals per rate; the whole sweep produced two rejected characters and two receive-ring overflows, which are the same two events, one on each machine, both at 1200 baud; anduartRxCountnever reached its 20-byte limit at any rate. Every window ran at 100.5% of real time or better against the emulators' own cycle counters. - The transmit-side loss is gone. VICE logs
data register written although data has not been sent yeteach time the driver writes a data register that has not emptied - one transmitted character lost, on emulation and on a real 6551 alike. The previous build produced them at 0.8% of offered characters at 4800, 2.1% at 9600 and 2.2-2.3% at 19200; every emulator log in the current sweep contains zero of them, at every rate, including both 38400 boots.wedgeDiagnosis.mdsection 4 predicted exactly that, having traced the overwrite to a handler pass interrupted betweenlda txCharActiveanddec uartPendingCount. - Adopt mode was measured with two machines linked, not just one.
testAdoptPair.py,portCompatReport.mdsection 5: both machines booted to the A/O prompt with the chip untouched (aciaPageLatchand the adopt entry both$00), the control register then written from the monitor as a configured cartridge would have left it, and the link opened and measured for 75 seconds.$98(2400 baud, two stop bits) came through whole on both machines;$B8(seven data bits) came through as$98, only the word-length bits corrected;$00fell back to$15and the wire really ran at 300 baud (6.9-30.0 B/s per second, against 5.9-12.0 at every rate from 1200 up). None of the three windows had a second carrying nothing, in 148 per run. - The
$DF00fallback adopts from the real chip.portCompatReport.mdsection 9.1 has both strap traces on the current build. On$DF00the driver reads$DE03(open bus,$F6), fails the first probe pattern there, then reads$DF03before writing to it and adopts that value; on$DE00the very first cartridge access in the whole session is the control-register read, ahead of either probe pattern. Both runs ended with all six accessors andaciaPageLatch$E5BFon the right page, zero accesses to the other page's mirrors, and a link open. - A rate mismatch did not wedge either end. 38400 on one machine against 300 on the other, 246 seconds: both reached phase 3, none of 474 sampling intervals carried nothing, and there was not a single ring overflow or bad character at either end. The pair ran at the slow end's rate, because the lock-step packet layer will not send the next thing until the previous exchange completes. Read section 4 for what this does and does not say about real hardware.
- Every hot key in the baud table programs the chip, first press.
$15,$17,$18,$1A,$1C,$1Eand$1Fhave all been seen in the ACIA control register with the matchingbaudIndex.$1F(38400) was one of them; its hot key was taken away for one build after the wedges and is back, whichtestAdopt.pyre-checked - see the C= + 8 bullet below. - The
$DF00strap fallback is taken and works. With VICE started as-acia1base 0xDF00the probe writes$1Eto$DE03, fails to read it back, re-aims all six accessors at$DF00, and every one of 796 subsequent register accesses lands on$DF00-$DF03; two machines strapped that way reach connection phase 3 and exchange ARQ frames at 300 and 2400 baud, and a watchpoint on the register mirrors$DE04-$DEFF/$DF04-$DFFFnever fired.strapReport.mdhas the whole run, and the two probe weaknesses it lists at the end have since been closed - see the next bullet. - The probe is hardened, and the hardening was re-measured on the
$DF00strap.testStrap.pywas re-run against the current build with watchpoints on both pages: over an 800-stop log covering two link opens,$DE03was written once and read once - the single probe pattern$1Eand its read-back, which returned$00and failed - and every other one of the 800 accesses landed on$DF00-$DF03.aciaPageLatch$E5BFread back$DF, all six accessor operands read$DF, the mirror tripwire on$DE04-$DEFF/$DF04-$DFFFrecorded 0 stops, and the machine sent itsATQ0V1X1Astring and its byte-sync$00flood as before. The old build wrote$1Einto$DE03once per link open and once per speed hot key; this one writes it once per cold start. - The latch was then re-measured with the hot keys included, on both straps, on a phase-3 link.
hardenReport.md: two machines per strap, two link opens and six landed speed hot keys per machine- eight calls to
loadBaudParameters- and the probe ran once. During the whole hot-key sweep the control register took exactly six writes,$17 $18 $1A $1C $1E $15, one per key in the order pressed, with no probe pattern in front of any of them. On the$DF00strap each machine touched$DE00-$DE03exactly twice in the entire session - the one probe write and its one failed read-back, which returned$F6- and not at all during the sweep or during a 20-second census of 3281 register accesses. On the$DE00strap$DF00-$DF03was never touched at all.
- eight calls to
- Both probe patterns have now been seen to run. On the
$DE00strap the chip answered$1Eand then$15, which is the first time the second pattern has executed at all. On the$DF00strap the first pattern already fails, so the second is never written. - C= + 8 selects 38400 again.
testAdopt.py, one machine on a live link: pressing it putbaudIndex$E055=$12(18) and the ACIA control register =$1F. (The earlierhardenReport.mdmeasurement of the same key -baudIndexand the control register unchanged, the keystroke reaching$E38Aand being answered with$FF- was of the build in which its table byte had been blanked, and is the "before" for this one.) - Adopt mode uses the port's own settings, and corrects only what cannot work.
testAdopt.py, one machine, 46 of 46 checks passed. At the A/O prompt, before the module had touched the chip:baudIndex$E055=$15(21, adopt - the disk default), the adopt entry's control byte$00and the page latch$00, i.e. nothing decided yet. With$18then written into the ACIA's control register from the monitor - which is what a configured cartridge would have left there - the link came up with the adopt entry holding$18, the control register reading$18andbaudIndexstill 21. Seven more presets were fed through the same code by clearing the page latch and pressing C= + 3 (which re-probes and imposes 300 baud) and then C= + U (which goes back to the port's own setting):$18->$18and$9F(38400 with two stop bits) ->$9F, copied through untouched;$5A(six data bits) ->$1Aand$3C(seven data bits) ->$1C, the word length corrected and the rate left alone;$00,$0C(external receive clock) and$10(internal clock but no rate) ->$15, the 300-baud fallback. Every C= + 3 putbaudIndex= 0 and$15in the register and every C= + U put it back to 21 and the adopted byte, so switching between adopt mode and a fixed rate works in both directions. - 1200 baud was re-measured after both changes.
hardenReport.mdsection 6: two boots, four 75-second windows, 303 seconds at true C64 speed, both machines in phase 3 on both boots, none of 592 one-second sampling intervals carried zero bytes,linkErrorCount$E047$00at all 24 window edges, and the transmitter-deadlock shape absent from every one of them. The 22 receive errors counted were all ring overflows, at the same rate per secondrate1200Report.mdmeasured before the changes.
Not verified
- No real hardware, and no C64 Ultimate. Every result above is VICE 3.7.1 with
-acia1mode 1 -acia1irq 1and base$DE00or$DF00. Nothing has been run on a physical SwiftLink, on a real cable, on a real modem, or on the device this whole exercise is aimed at. Adopt mode in particular is only as good as what the device puts in its control register, and no device other than VICE's 6551 has been asked. - Adopt mode was fed by a monitor, not by a configured device. Every preset in the adopt table
was written into
$DE03from the VICE monitor. That is the same byte a configured cartridge would leave there, and the driver cannot tell the difference, but "an Ultimate set to 9600 in its own menu presents$1Cin the control register" is an assumption this rig cannot test. - The 8-data-bits correction was tested for what it writes, not for what it prevents.
$5Aand$3Ccame back as$1Aand$1C, and a two-machine run pre-set to$B8came up$98, so the word-length bits really are corrected; no run has been made with a peer actually transmitting seven-bit characters, because VICE's ACIA is byte level and cannot produce one. That eight data bits are required is a reading of the source - the$FFtest at$E852, the$55length code, the eight-bit checksum - not an experiment. - Parity is not adopted, and nothing measures what that costs. Parity lives in the 6551 command
register, which the driver rewrites from constants at four sites, so a port configured for parity
runs without it (
portCompatReport.mdsection 8). Both ends of a link run this same driver and clear the same bits, which is why the$69two-machine run worked; no measurement says what a device or a peer that really insists on parity would do. - 38400 after the fix is seven boots, not a soak. Two 200-second stack watches, two
testHighRates.pyboots carrying 170 and 245 seconds of measured windows, and threetestGuard.pyruns. That is enough to say the runaway does not happen where it used to happen within a minute, four times out of four; it is not a statement about an hour of play. - 38400 is healthy at this game's traffic, not at any traffic. Both directions carry about nine bytes a second at the point every measurement was taken, which is 0.2% of a 38400 line. The arithmetic that killed the old build - two interrupts per 266-cycle character time against a 178-cycle handler - still says a saturated 38400 line costs more CPU than a C64 has, and nothing in this game saturates one, so that case was never provoked on this build. "It would then overflow the ring and resync, rather than wedge" is an argument from the guard's construction plus the overflow-and-recover behaviour measured at 1200 here and at 9600/19200 on the previous build; it is not a measurement of a saturated 38400 line.
- The guard's nested path has only ever been exercised in runs that were not at true C64 speed.
The 18.5% / 9.4% counts, and the later 8.5% / 8.6% over 405 nested passes, both come from windows
the emulators ran faster than real time (one of them about eight times faster, an accident recorded
as such in
portCompatReport.mdsections 2 and 4.3). The one window measured at true C64 speed - 90 seconds at 38400, both machines at 100.7-101.1% - counted 3252 handler entries and not one nested pass. So the path that absorbs a nested NMI is proven to run, and proven to leavenmiHandlerStateat$00afterwards; what has not been observed is it running at this game's traffic on a machine held to real time. Tracing also puts a line on the monitor socket per hit, which is emulator work the emulated machine does not pay for but the wall clock does. - VICE's ACIA is byte level. It paces whole bytes at the rate the control register asks for but does not shift them bit by bit, so no framing or parity error can occur on this rig at all. Every "zero bad characters" and "every error was a ring overflow" above is a statement about this emulation, not about a noisy real line - and the rate-mismatch failure described in section 4 cannot be reproduced here at all.
- SwiftLink-to-stock interop was not tested. Every two-machine run in every report had SwiftLink builds at both ends. The wire-compatibility claim in section 4 rests on the fact that the frame and packet layers are byte-identical, not on an observed run against the stock user-port driver - and 1200 baud, the rate that pairing would most likely use, has now been measured only SwiftLink to SwiftLink.
- The 38400 wedge has since been explained, and this is what is still missing from the
explanation.
wedgeDiagnosis.mdestablished the cause - 34 nested handler frames decoded off a live page 1, three timed stack descents, and the cycle budget behind them - so the earlier "which bytes are stuck" characterisation is no longer where the answer stands. What that diagnosis does not contain: the double decrement ofuartPendingCountis shown as a stack frame stopped one instruction short of theDEC, not as a countedDEC/INCimbalance during a storm, because instrumenting it prints thousands of lines a second into a fault that lasts eight milliseconds. - The
uartPendingCountrepair probe has never been run against the shape it was written for. It was run on the boot whose CPU had jammed, where no write to any driver byte could have worked (highRateReport.mdsection 6.3). - The transmitter fix is proven by absence, not by proof. 675 seconds at the rate that used to
fail within twenty says the deadlock did not recur; it does not say no path into the state remains.
healStuckTransmitteris what makes any remaining path self-healing within one video frame. - Why the driver overwrote its own transmit data register is traced structurally, not counted.
VICE counted 0.8% to 2.3% of offered characters lost that way between 4800 and 19200 on the old
build.
wedgeDiagnosis.mdsection 4 found handler frames interrupted at$E6C4and$E6C7, either side ofdec uartPendingCount$E6C9, which is the mechanism; the confirmation is negative rather than positive - with the guard in, the current build's logs contain none of those messages at any rate. - The 1200-baud overflows are unexplained. All 32 were on the same machine, over three boots, and none at all on the other. Not chased, for the same reason as above.
- One boot per rate, and no long soak anywhere. 246 seconds per rate on the old build and 170 on the current one is enough to say the link keeps moving and recovers; it is not enough to say it always will. 38400 is the exception in both directions - four boots on the old build because it failed, seven on this one because it had to be shown not to.
- Timing was measured in one game state. Every real-time window in every report sits in the
side-0/side-1 negotiation - machine A on "OPPONENT PICKING GAME/MAP.", machine B on the GAME TYPE
menu. A quieter moment puts fewer bytes on the wire at any rate. Sections 1-4 of
testReport.mdstill ran in warp and say nothing about real-time behaviour. - The C= + C override was verified against an injected DCD, not an emulated one. VICE's 6551
never sets status bit 6 - the bit a SwiftLink carries DCD on - so "no carrier" had to be written
into
aciaStatusSave$E5BCat a breakpoint. That is the exact byte the carrier sampler reads and the exact bit the source documents, but a real peer dropping a real DCD has not been observed, and the override was never tried with two machines actually playing. - Five of the hot keys have never been pressed: C= + H (DTR), C= + P (hang up), C= + A, C= + O
and C= + RETURN. The seven speed keys are the only ones exercised, and on the
$DF00strap only C= + 2 was ever seen to land. - The second probe pattern has never been seen to fail. On this rig the first pattern decides
every case: at
$DE00the chip answers$1Eand then answers$15, and at$DF00the open-bus read of$DE03misses on$1Eand the second write never happens. A page that matches one pattern and not the other - the case the second pattern exists for - has not been produced, and cannot be produced on this rig, because VICE's open bus is not the C64's. - No complete game was played, at any rate or either strap. The two machines reach the GAME TYPE menu and stay there.
- The disk-load suspend handshake was not instrumented.
$E039/$E03Awere never watched directly, though both read$00in the 38400 wedge dumps, which is at least evidence that a stuck handshake was not the cause there. A setup overlay did load over a live link and the link survived it, but that is an inference from the screen, not a measurement. - W65C51N boards were not tested and are expected not to work without a timed transmit delay (see section 1).
Fixed: the 2400-baud transmitter deadlock
This is recorded because the earlier documents warned about it and the warning is now wrong.
The fault. At 2400 baud both machines reached connection phase 3 and then, within about twenty
seconds, one machine stopped transmitting for good while the other poured out byte-sync $00
characters at one per video frame. It happened in three consecutive 75-second windows in each of two
runs, whether the rate was chosen on a live link or at the A/O prompt. No framing, parity or overrun
error was counted at either end while it happened. The stalled machine was left with txCharActive
$E5BB = $01 while aciaCommandShadow $E5BE = $09 said the transmit interrupt was off - a
state the driver could not leave, since startNextTxChar returns early while the flag is set and the
NMI's transmit half checks the command shadow before it will clear it. Writing a single $00 over
$E5BB from the monitor brought the machine straight back to life.
The fix, three sites and one idea: txCharActive means "a byte of ours is sitting in the ACIA's
transmit holding register" and nothing else, so it must never outlive the transmit interrupt that
clears it.
| Site | What it does |
|---|---|
aciaSetCommandIdle ($E72A, later $E408), called from the NMI's disarm |
wrote the command register and cleared txCharActive with it - closing the one path that was traced, where an inner NMI pass primes a byte and does INC txCharActive while the outer pass finds the ring empty and disarms the interrupt. This routine is gone: the re-entrancy guard closes that path at its source, so the disarm site provably runs with the flag already clear |
healStuckTransmitter $E057, now the first thing startNextTxChar $E607 does |
once a frame, whatever caused it: if the command shadow says the transmit interrupt is disarmed, clear the flag. Returns the same A the old LDA txCharActive did, so the caller is unchanged |
aciaSetControlIdle $E04C, called from the jsr at $E35C inside loadBaudParameters $E353 |
a rate change forgets any byte the driver believed was in the chip, so a speed hot key landing on a character in flight cannot strand the flag |
The first closes the traced path, the second makes the whole class of paths self-healing within one
video frame, the third removes the rate change as a way in. The measurement is in the verified list
above and in realtimeReport.md section 8.
Fixed: the 38400 wedge, and the NMI re-entrancy behind it
The fault. highRateReport.md sections 5 and 6: four boots, four wedges, no recoveries, every
one within about a minute of the link coming up. The four looked different - both machines frozen in
phase 3 with both receive rings full; a machine flooding byte sync for ever at one byte per video
frame; a machine's game back on the plain OPTIONS menu; and *** Main CPU: JAM at $0007, the 6502
executing zero page with its bank register overwritten.
The cause, established in wedgeDiagnosis.md and not inferred: the handler could be re-entered
without bound. Reading the 6551's status register is the interrupt acknowledge, so from that
instruction on the chip is free to raise a fresh edge while the handler still has 100 to 240 cycles
of work left, and a 6502 does not mask NMI. At 38400 with traffic both ways there are two events per
character time - one every 133 cycles - against a handler that costs 175 to 266, so service time
exceeded inter-arrival time and the nesting grew without bound. A machine stopped mid-runaway had
34 complete nested frames decoded off page 1, and three descents from a healthy stack to an
exhausted one were timed at 8.4, 11.5 and 12.5 ms. Once the pointer wrapped, every RTI popped a
program counter, a flag byte and a saved $01 belonging to a different frame; the four different
wedges are that one failure photographed at different moments.
The fix, in commNmiHandler, two co-operating parts:
- the guard
nmiHandlerState$E5BD. A second NMI that finds a pass already running does not run the handler body: it adds one to that byte as a note and returns, which costs six bytes of stack and about fifty cycles. It cannot nest again, because a nested pass never reads the status register -/IRQstays asserted, the shared/NMIline stays low, and an edge-triggered NMI cannot fire again until the running pass acknowledges. The depth is bounded at one nested frame whatever the line rate, with no arithmetic about cycles per character needed to make it true. The one qualification: the guard byte is read and then incremented, two instructions apart, and an NMI landing between them would let one extra pass through. Our own chip cannot do it - it has not been acknowledged yet at that point, so there is no edge to take - and only a foreign NMI arriving first opens the window at all. The order is that way round on purpose: increment-then-read would make the same window leave the byte stuck at 2 and the driver deaf for good, where this way the worst case is the single nesting the guard already tolerates and the byte still ends at 0. - the drain loop. Refusing a nested pass would otherwise lose the interrupt the old comment
warned about - a dropped
TDREstalls the transmitter for good - so the running pass loops: it services whatever the status register shows, and atnmiIdleit decrements the guard and goes round again whenever the decrement leaves a note behind. The transmit half loops from the other end too, going back for another look after feeding the chip instead of returning. The decrement comes first atnmiIdle, so from that instruction the guard reads zero and any NMI arriving during the exit sequence is a full pass that drains everything; there is no instant at which work can be left behind with the interrupt swallowed.
Measured after the fix (2026-08-22, VICE 3.7.1, two machines at true C64 speed, 38400 baud):
testStack.py, two boots, 200 seconds each. The tracepointtrace exec e685 if sp < $b4- one NMI frame below the deepest entry any healthy machine has ever made - printed nothing at all, on either machine, in either boot, where the same instrumentation on the old build produced the staircases above. In the second boot the stack tripwires were armed as well (break exec e685 if sp < $20and a watchpoint on any store into$0100-$0140) and neither fired; both machines were in connection phase 3 withlinkErrorCount= 0 when the window opened, page 1 was untouched below$01C0at the end, both were still executing game code with the interrupt flag clear, and the relay counted 2638/2040 and 2954/2461 bytes crossing in the two directions.testGuard.py, the guard's own path, counted: with both machines in phase 3, tracepoints on the handler entry and onnmiLeaveNotecounted 65 entries of which 12 were nested (18.5%) on one machine and 64 of which 6 (9.4%) on the other, over five seconds. So second NMIs really do arrive while a pass is running - roughly one entry in five to one in ten, at nothing like a saturated line - and every one of them is now six bytes of stack and a note instead of a full re-entrant pass.nmiHandlerStateread$00on both machines afterwards, which is its resting value.- 1200 baud still works after the rewrite (
testGuard.pyagain, two machines, the rate a stock user-port peer can also produce): both ends reached connection phase 3, the control register read$17andbaudIndex3 on both,linkErrorCountwas$00at the end, and a ten-second window counted 6045 and 4719 handler entries of which 39 (0.6%) and 52 (1.1%) were nested - two orders of magnitude rarer than at 38400, which is what the cost model says should happen.nmiHandlerStaterested at$00on both. No throughput numbers were taken: this is a "does the ordinary rate still come up and stay error-free" check, not a repeat ofrate1200Report.md. (Both of thosetestGuard.pywindows were later found not to have been held to real time - see the qualification in the "Not verified" list.) - The whole rate table, re-measured (
portCompatReport.mdsection 3,testReport.mdsection 11): seven boots, one per rate, two machines at true C64 speed, 170 seconds of measured link time each. No rate had a sampling second carrying nothing in either direction; the sweep produced two rejected characters and two ring overflows, both at 1200 baud, both recovered from; and every emulator log contains zero "data register written although data has not been sent yet" lines, where the old build produced them at 0.8-2.3% of transmitted characters from 4800 up. - The margin at 38400, measured rather than argued (
portCompatReport.mdsection 4.2): in a 90-second true-speed window the handler was entered 1632 and 1620 times with zero nested passes, but 817 and 1027 of the gaps between consecutive entries were under 300 cycles with a median of 266 - exactly one character time at 38400 - against a handler that costs 175 cycles for one half and 256-266 for both. The margin is between zero and ninety cycles, and it was never lost here.
What this does not claim. A saturated 38400 line still costs more CPU than a C64 has: two interrupts per 266 cycles cannot be serviced in 266 cycles, so a burst in both directions at once would still overrun the receive ring and make the frame layer resynchronise, the way 9600 and 19200 did before the guard. Nothing in this game saturates a 38400 line - it produces about nine bytes a second each way - so that case has not been provoked on this build, and what would happen then is an argument from the guard's construction, not a measurement. What the guard changes is the failure mode: degradation instead of a dead machine. It does not change throughput, and throughput was never the problem.
Known race, by design
Reading the 6551 status register releases /IRQ, so a transmit or DCD event arising between that
read and the rest of the pass raises a fresh NMI. Since the guard went in, that NMI does not
re-enter the handler body: it leaves a note and returns, and the running pass picks the work up on
its next time round the drain loop. What it costs now is six bytes of stack and about fifty cycles
per occurrence, rather than a duplicated byte, a double-decremented uartPendingCount and a
possibility of the stack running away. A foreign NMI - RESTORE - that lands inside a pass is
swallowed rather than chained, which is the one thing given up; both chain targets in this game are a
bare RTI ($E010 on disk, $1298 once the game has patched the vector), so nothing observable
goes with it.
The transmitted characters VICE reported the driver overwriting (0.8% at 4800 rising to 2.3% at
19200) were traced in wedgeDiagnosis.md section 4 to exactly this re-entrancy - an outer pass
interrupted between lda txCharActive and dec uartPendingCount, which then counted the same byte
off twice and wrote the data register twice. That path is closed by construction now. It has not
been re-measured at those rates.
6. What changed inside the module, address by address
checkAbi.py prints the raw ranges; some are split by bytes that happen to be identical either
side. Grouped by routine:
| Range | Size | Was | Is now |
|---|---|---|---|
$E04C-$E055 |
10 | dtrToggleMask, userPortDdrbValue, userPortDtrDropped, userPortIdleTable, serialShiftPatternTable and its 1200-baud answer entry - CIA2 bit-banger constants |
aciaSetControlIdle at $E04C and clearTxCharActive at $E04F: program the control register, then forget any byte the driver believed was in the transmitter |
$E057-$E05E |
8 | bitPeriodHi, txPaceReload and the stock six-byte baudParameterTable |
healStuckTransmitter: txCharActive set with the transmit interrupt disarmed cannot be true for any healthy reason, so clear it on sight. Returns the same A the load it replaced did |
$E327-$E331 |
11 | C= + H: EOR dtrToggleMask / STA CIA2_PRB, toggling PB2 and PB5 |
EOR #$01 into aciaCommandShadow -> aciaSetCommand; tail padded with NOP |
$E342-$E34F |
14 | two hard-coded tests, CMP #$B3 (300) and CMP #$B1 (1200) |
a backwards walk of the eight-entry baudEntryTable, same three-byte stride, starting at index 21 - the adopt entry, C= + U - and running down through 38400 to 300 |
$E38E-$E38F |
2 | hangUpModem's JSR dropDtrLine operand |
the same call, following dropDtrLine to $E408. This is the only byte pair outside the driver's own block that had to move at all |
$E3C7-$E3CA |
4 | linkIoVectorTable's error pair: $E408 (the never-selected no-op stub) and $E403 (countLinkError) |
both halves now point at $E73F, countLinkError's new home |
$E402-$E406 |
5 | the RTS of selectFramedLinkVectors and the first four bytes of countLinkError |
LDA #$00 / STA aciaPageLatch and then the RTS: the cold-start clear of the ACIA page latch, in the one routine initCommModule calls exactly once |
$E408-$E40E |
7 | ignoreLinkError, the raw-mode error hook - dead, because initCommModule always selects countLinkError |
dropDtrLine, moved out of $E574 so that silenceCia2 could leave the NMI block. It replaced aciaSetCommandIdle, which the re-entrancy guard made unnecessary |
$E353-$E35E |
12 | loadBaudParameters: three bytes into $E056-$E058 via sta $DF59,y |
aciaDetect, then the entry's control byte into bitPeriodLo and through aciaSetControlIdle into the ACIA control register. Still returns A = $FF |
$E540-$E578 |
57 | configureUserPortLines (CIA2 DDRB, PRB, PRA, both timer latches, the SDR write) and dropDtrLine |
configureUserPortLines at $E540 (control, command $09, drain, status); the six one-instruction ACIA accessors at $E551-$E56E, with aciaSetControl now falling through a NOP into aciaGetControl so that a write and its read-back are one call; silenceCia2 at $E56F, moved down out of the NMI block |
$E587-$E588 |
2 | restartUart's first call, JSR clearUartState |
JSR aciaResumeReceiver |
$E593-$E5BA |
40 | setCiaNmiMask (the mask into CIA2_ICR, CIA2_CRA, CIA2_CRB) and clearUartState |
JSR silenceCia2, then arm or disable the ACIA receiver IRQ with command $09 / $0B and read the status so no latched IRQ flag holds /NMI down - packed tight, with the four NOPs gone - and then aciaResumeReceiver at $E5B1, moved down out of the NMI block into the bytes clearUartState no longer needs |
$E5E8 |
1 | the carrier sampler's BEQ displacement |
the same branch, retargeted to aciaDetectDone $E654, which is the bare RTS the transmit path's shared exit L_E662 used to be |
$E5EC-$E5F7 |
12 | the carrier sampler reading CIA2_PRB bit 4 |
BIT aciaStatusSave: V = status bit 6 = DCD on a SwiftLink, active low |
$E607-$E642 |
53 | startNextTxChar: the pacing counter and the CIA2 timer B start of a bit-banged character |
call healStuckTransmitter in place of the plain LDA txCharActive; check the command shadow (DTR up and transmitter idle), the suspend flag and carrier; pop the ring; prime the data register by hand; arm the transmit interrupt with command $05 |
$E645-$E684 |
63 | the rest of the bit-bang transmit start, setBitPeriodFull, setBitPeriod, nmiStartNextChar and the old "not mine" NMI exit |
aciaDetect at $E644 (return at once if aciaPageLatch is set, otherwise try $DE00 and fall back to $DF00) with its shared RTS still at $E654; aciaProbeAdopted at $E655, which reads the page's control register before it writes either probe pattern - the whole of adopt mode's input; and the not-mine NMI exit at $E67B, which restores $01 before chaining through nmiChainVector |
| $E688-$E732 | 169 | commNmiHandler's bit-level receive and transmit halves and the CIA2 ICR juggling | commNmiHandler with the re-entrancy guard and the drain loop, aciaSetPage $E6FA, the eight-entry baudEntryTable $E710 and queueAndKickTx $E727. The block is exactly full: not one spare byte in it |
| $E73C-$E742 | 7 | queueByteForTransmit's INC uartPendingCount and RTS | JMP queueAndKickTx - the byte must also start the transmitter now, and a tail jump rather than a JSR/RTS pair leaves four bytes at $E73F for countLinkError |
Some structural moves are worth calling out because they are what made the whole thing fit:
baudEntryTablecould not stay at$E059. Seven entries need 21 bytes and$E05FislinkStatusBorderTable, so the table moved into the space freed by the bit-banged receiver, first to$E6FD, then$E706, and now$E710with an eighth entry on the end. The index semantics did not move: 0 is still 300 baud and 3 is still 1200 baud, the stride is still three, and 21 - the new one - is adopt.setCiaNmiMaskhad to grow, and nothing below it may move, so its CIA2 half was moved out tosilenceCia2- into the freed receiver space at$E71E, then$E727, and now back down to$E56Fwhere the accessors anddropDtrLineleft room, because the NMI block needed those twelve bytes. The fourNOPs it once left at$E597are gone with the same shuffle.- The re-entrancy guard, the drain loop and the read-before-you-write probe together needed about
fifty more bytes than the block round
commNmiHandlerhad, which was six. Three routines left the block to pay for them -aciaResumeReceiverto$E5B1,silenceCia2to$E56FanddropDtrLineto$E408- two were deleted (aciaSetCommandIdle, whose second half the guard makes provably unnecessary, andclearUartState, whose whole inline block was one byteclearTxCharActivealready clears), one dead stub and one duplicateRTSwent with them, and the handler itself gave back eight bytes to some tighter tests.ADDRESS_CHANGES.mdhas the old-to-new map. - The two-pattern probe needed 15 more bytes than the one-pattern probe it replaced, and the freed
receiver space had exactly three bytes of slack left in it. Six of the fifteen came from making
aciaSetControlfall through intoaciaGetControl, so that a write and its read-back are one call instead of two; the rest came from shiftingbaudEntryTable,queueAndKickTxandsilenceCia2nine bytes down the block and movingaciaSetCommandIdleout of it entirely, into the seven bytesignoreLinkErrorhad been occupying since 1988 without ever being called.countLinkErrormoved in turn, from$E403to$E73F, to free the five bytesselectFramedLinkVectorsneeded in order to clear the ACIA page latch on a cold start; the four bytes it landed in came from turningqueueByteForTransmitinto a tailJMP. Nothing that the game or the reports name by address moved, andscanIoAccess.pystill finds the six accessors and nothing else naming the cartridge page - in either of its passes.
The seventeen dead stock-UART constants that used to sit at $E04C-$E05E are gone, and the
transmitter-deadlock fix lives in the bytes they vacated. They were dtrToggleMask $E04C,
userPortDdrbValue $E04D, userPortDtrDropped $E04E, userPortIdleTable $E04F,
serialShiftPatternTable $E051 and its 1200-baud answer entry $E054, bitPeriodHi $E057,
txPaceReload $E058 and the old baudParameterTable $E059 - all of them CIA2 timing values that
nothing had read since the software UART was replaced. The fix needs three separate places to be
able to clear one flag and the .res gaps inside the UART layer are all five bytes or fewer, which
is why the shared parts landed here. The two live bytes in the middle are why checkAbi.py reports
two ranges and not one: baudIndex $E055 and bitPeriodLo $E056, the latter reused to hold the
live ACIA control byte, which is a convenient thing to peek at from a monitor.
Guaranteed unchanged
checkAbi.py enforces the first three; the rest are unchanged in fact, and the changed-range list
above is exhaustive.
| Region | Why it must not move |
|---|---|
$E000-$E017 |
the seven jump-table entries - $E000 per-frame request, $E003 link control, $E006 get byte, $E009 send byte, $E00C per-frame poll, $E012 keyboard hook (whose operand the game patches to its own scanKeyboard), $E015 key entry - plus the constants wedged among them: the spare RTS at $E00F, the RTI byte at $E010 that nmiChainVector points at until the game patches it, and $E011 isOriginateMode, which the game writes from the A/O prompt |
$E01D-$E047 |
shared variables the game reads and writes: packet buffers, host counts, $E030 build id, the $E031/$E032 NMI chain vector, $E033 ciaIcrShadow, the $E039/$E03A suspend handshake, $E03B link active, $E03C/$E03D link status - including the leftover junk bytes $44 $54 $48 ("DTH") at $E045-$E047 |
$EC00-$EFFF |
the thirteen utilities the map generator overlay calls: $EC00, $EC0F, $EC1C, $EC2B, $EC54, $EC58, $EC91, $ECF3, $ED43, $EE26, $EE4A, $EEB9, $EEDD |
$E111-$E2FF |
the packet and chat layer |
$E40F-$E533 |
the four ring buffers |
$E756-$E908 |
the modem state machine, the connection phases and the byte-sync handshake |
$E909-$EAB2 |
the frame / ARQ layer: length, control, sequence, checksum, retransmit |
$EAB7-$EBFF |
packet state, remoteCodeBuffer $EB03, and the Hayes strings, stored back to front at $EBD6-$EBFC (+++ / ATH0 at $EBD6, ATQ0V1X1A at $EBE3, ATQ0V1X1D at $EBF0) |
$E030 deserves its own line: it must stay $00. It is the "which module am I" byte, and the
game reads it in five places. A $FF there would make the game believe the solo trainer is
resident, reload track 34 over the top of this module every time a modem game starts, suppress the
hang-up handshake, and rewrite $0BA5 so the game becomes a solo game.
7. If you are going to modify this
Three things found by inspection that the driver must keep doing. They are recorded in full in
DESIGN_NOTES.md. Three more were found by measurement and are just as load-bearing:
- The NMI handler must not be re-enterable. Reading the status register is the acknowledge, so
the chip is free to raise a fresh edge for the rest of the pass; at any rate where the events
arrive faster than a pass completes, unbounded nesting eats page 1 in milliseconds. The guard is
nmiHandlerState$E5BD, and the property that makes it work is that a nested pass never reads the status register -/IRQstays asserted, so no further edge can be raised until the running pass acknowledges. Anything added to the nested path that touches the status register throws that away. - A refused interrupt must be handed on, not dropped. The nested pass leaves a note and the
running pass loops on it, and the decrement at
nmiIdlecomes before the test, so a note left during the exit sequence turns the next NMI into a full pass instead of being lost. A droppedTDREstalls the transmitter for ever, which is the failure the source used to warn about. - Eight data bits are not optional. The frame layer sends
$55length codes, raw sequence bytes and rotate-and-add checksums; it is not 7-bit clean. Adopt mode may take anything else from the port's own configuration, but the word length is corrected at$E665and must stay corrected.
- The NMI handler has to be bank-safe.
readFilmByte$5874,writeFilmByte$5893andcopyPageUnderIo$58B2set$01 = $34to reach the game-film buffer in the RAM under$D000-$DFFF, and they mask CIA2 while they do it. That was airtight for the stock driver, whose interrupt was a CIA2 interrupt. A SwiftLink interrupt is not, andSEIdoes not mask NMI, so the handler can and will fire with the I/O area banked out - for about 2600 cycles at a time incopyPageUnderIo, roughly ten character times at 38400 baud. The handler therefore saves$01, forces$35, and restores the whole saved byte on both exits. ciaIcrShadow$E033must be pinned at$7Fat run time. Those same routines write that byte straight back intoCIA2_ICRafter every access. Its stored value in the module image is$90and cannot change (frozen region), sosetCiaNmiMaskcorrects it throughsilenceCia2on every state change. Clearing the mask is not enough on its own: a source that latched before the mask was cleared keeps/NMIlow, sosilenceCia2also readsCIA2_ICRto release it.- Never index into the ACIA page. The stock
loadBaudParameterswrote its three bytes withsta $DF59,y, and an indexed store on a 6502 performs a dummy read from the un-carried address first. With a SwiftLink strapped to$DF00those dummy reads land on the mirrored registers, and one of them mirrors the data register, whose read consumes a received byte. The replacement addresses$E056directly, and the six accessors are the only code in the module that names an address in$DE00/$DF00- they are self-modified byaciaSetPage.
Also useful: the spec files were written before the driver and are the contract it was built
against. spec_uart.md gives the exact behaviour, range and callers of every hardware-touching
routine in the stock module, plus a hazards checklist. spec_link.md covers everything that
surrounds the UART - the suspend handshake, carrier detection, the connection phases, the hot keys,
the baud table and the build id - and ends with a checklist and the open questions (which clones
swap DCD and DSR, whether to use CTS for flow control during disk loads).
8. Files
| File | What it is |
|---|---|
swiftlinkDriverE000.s |
the module, fully commented; ca65 source for all 4096 bytes |
build.sh |
assemble, size-check, and install onto a copy of the game disk |
link.cfg |
places the module as a flat 4096-byte image at $E000 |
injectDriver.py |
writes the image into track 18 sector 7 and track 34 sectors 1-15, re-encrypting |
checkAbi.py |
proves the frozen regions and entry points are intact |
spec_uart.md |
the hardware contract of the stock UART layer, routine by routine |
spec_link.md |
everything above the UART that the replacement has to satisfy |
DESIGN_NOTES.md |
the three inspection findings above, in full |
ADDRESS_CHANGES.md |
the two old-to-new address maps, and the table of report conclusions later work overturned |
testReport.md |
what was run in VICE, what was observed, and the loose ends |
realtimeReport.md |
the two-machine runs at true C64 speed: throughput at 300 and 2400, errors, the 2400 transmitter deadlock, and the three-site fix re-measured over 675 seconds |
hotkeyReport.md |
the Commodore-key hot keys: which host key C= is, C= + C, the border paint, the baud keys |
strapReport.md |
the $DF00 strap option: the probe taking the fallback, two machines at $DF00, and whether anything else in the game can reach the cartridge page |
rate1200Report.md |
1200 baud with two machines in real time, the first receive-ring overflows seen anywhere, and the CONNECT 1200 auto-baud path |
highRateReport.md |
4800, 9600, 19200 and 38400, plus a deliberate 38400-against-300 rate mismatch: which rates survive, which degrade, and the four 38400 wedges. Its "must not be selected: 38400" recommendation is overturned; the note at the top of the file points at the newer result |
hardenReport.md |
the page latch and the 38400 retirement: cold detect on both straps, the probe not re-running on a live link, nothing written to the other page, C= + 8 doing nothing (in that build), and a 1200-baud regression check. The retirement was reversed; same kind of note at the top |
wedgeDiagnosis.md |
why 38400 used to wedge: 34 nested NMI frames decoded off a live page 1, three timed stack descents, and the cycle budget behind them |
portCompatReport.md |
the current build: every rate including 38400, adopt mode on two machines, the eight-data-bit guard, the unusable-value fallback, what adopt does not cover, and both straps re-probed |
testAdopt.py |
adopt mode and the 38400 hot key: presets written into the ACIA's control register and read back out of the driver's choices |
testAdoptPair.py |
the same, with two machines: the control (and command) register pre-set on both, then the link opened and measured |
testGuard.py |
the re-entrancy guard's own path, counted: handler entries against nested entries on a live link |
testStack.py, testWedge.py, testCost.py |
the wedge instrumentation: the deep-nesting tracepoint and stack-page tripwires, the pass/exit pairing, and the per-pass cost measurement |
viceHarness.py, testBoot.py, testModemSelect.py, testTwoMachines.py, testRealtime.py, testHotkeys.py, testStrap.py, testStrapPair.py, test1200AutoBaud.py, testHighRates.py, testHarden.py |
the test rig; SWIFTLINK_ACIA_BASE=0xDF00 puts the whole rig on the other strap |
parseTrace.py |
re-parses a saved VICE tracepoint log into an ordered access list and a per-page census |
scanIoAccess.py |
static scan: every instruction in the whole game that can reach $DE00-$DFFF |
probeInput.py, probeFire.py, probeModemPrompt.py, probeBankOut.py, probeOpenBus.py |
small experiments used to work out how to drive the game headlessly, and two about the cartridge page |
testLogs/stack.gd384a.txt, stack.gd384b.txt |
the two 38400-baud runs after the fix: the deep-nesting tracepoint that printed nothing, and the run with the stack tripwires armed |
testLogs/guard.gu384.txt, guard.gu12.txt |
the runs that counted handler entries against nested entries, at 38400 and at 1200 |
testLogs/adopt.adoptG.txt |
the adopt-mode session: every preset, every hot key, 46 of 46 checks |
testLogs/portCompat300.txt ... portCompat38400.txt, portCompat38400run2.txt |
the seven-rate sweep of portCompatReport.md section 3, one boot per rate |
testLogs/portCompatAdopt98.txt, ...AdoptB8.txt, ...Adopt00.txt |
the three two-machine adopt runs |
testLogs/portCompatStack38400b.txt, portCompatGuard38400c.txt |
the 200-second stack watch and the 90-second guard count, both with their own speed readings |
testLogs/portCompatStrapDe00.txt, ...StrapDf00.txt |
both straps re-probed with the adopt read in front |
shots/, testLogs/ |
the screenshots and logs the test report cites |
build/ |
build output (git-ignored) |
Addresses that moved, and conclusions that were overturned
Two passes have now relocated internal helpers to make room in a block that never had more than a
handful of spare bytes: the probe-hardening pass moved six, and the adopt-mode and re-entrancy-guard
pass moved five more and deleted four routines outright. Nothing the game, the ABI or the frozen
regions name by address moved, and checkAbi.py still passes. ADDRESS_CHANGES.md has both
old-to-new mappings, and explains why the older test reports still quote the previous addresses: they
are records of runs made against the build of the day, so rewriting them would describe a run that
never happened.
The same rule now covers their conclusions. highRateReport.md's "must not be selected: 38400",
hardenReport.md's 38400 retirement and strapReport.md's two open probe weaknesses have all been
overturned by later work; each file keeps its measurements and carries a note at the top pointing at
the newer result, and ADDRESS_CHANGES.md lists the three in one table. spec_uart.md has a note of
the same kind on its hazards checklist.
checkAbi.py currently reports 474 of 4096 bytes changed in 27 regions, all of them inside the
driver's own UART, hot-key and NMI code.