ThunderClock Plus¶
Hardware and emulation spec for the Thunderware ThunderClock Plus, synthesized from:
- web-a2e (
src/core/cards/thunderclock/) — HLE card: host-time snapshot, serial shift, real 2 KB firmware. No IRQ, no time-set. - a2fpga_core (
hdl/thunderclock/) — gate-level uPD1990AC plus card glue, including timer-pulse IRQ and C8 ownership.
Secondary checks: ThunderClock Plus manual, MAME upd1990a.cpp / a2thunderclock.cpp, existing GSSquared notes in this file’s previous revision.
This is the spec src/devices/thunderclock_plus/ implements. See GSSquared today.
What the card is¶
A slot clock built around a NEC uPD1990AC serial calendar chip and a 2 KB firmware EPROM (2716). One I/O location, 256-byte slot ROM, 2 KB expansion ROM.
It is the clock ProDOS finds by itself. At boot ProDOS scans slots 7→1 and treats a card as a ThunderClock if slot ROM bytes are:
| Offset | Value |
|---|---|
$Cn00 |
$08 |
$Cn02 |
$28 |
$Cn04 |
$58 |
$Cn06 |
$70 |
On match it sets MACHID ($BF98) bit 0, patches $BF07–$BF08 to the firmware clock driver, and turns $BF06 from $60 (RTS) into $4C (JMP). Firmware entry points:
| Address | Role |
|---|---|
$Cn08 |
READ time (ProDOS / BASIC) |
$Cn0B |
WRITE time |
The chip has no year. ProDOS invents one from month + day-of-week (the well-known six-year table). Do not invent a year nibble in the 40-bit stream.
Typical software slots are 5 or 7. Any slot 1–7 works. web-a2e prefers 5.
Not compatible with the No Slot Clock (DS1216). Different protocol, different software.
Address map¶
n = slot. Device-select base is $C080 + (slot << 4) ($C090 in slot 1, $C0D0 in slot 5).
| Range | Function |
|---|---|
$C0n0–$C0nF |
Single control/status register, aliased on all 16 offsets |
$Cn00–$CnFF |
Slot ROM = firmware bytes $000–$0FF |
$C800–$CFFF |
Expansion ROM = firmware bytes $000–$7FF (same 2 KB chip) |
web-a2e names $C0n8 as an “aux” location but treats it as the same register. The factory manual clears IRQ with LDA $C088,Y then LDA $C080,Y; that works if every offset aliases (a2fpga: any device-select read clears IRQ).
Firmware file is 2048 bytes. The high 1 KB is often $FF. Map the whole image at $C800; do not clip to 1 KB.
C8 ownership¶
Standard Apple II expansion-ROM rules (see C800–CFFF):
- Access to this card’s
$Cnxxclaims$C800–$CFFF. - Access to
$CFFF, or to another slot’s$Csxx, releases the claim (whenINTCXROMis off). - On the IIe,
INTCXROMmaps motherboard ROM over$C100–$CFFF; the card must not drive C8 then.
a2fpga implements that in the card. GSSquared already does it in MMU_II via set_C8xx_handler.
Control register ($C0nX)¶
Write → uPD1990AC pins¶
| Bit | Name | Chip pin | Meaning |
|---|---|---|---|
| 0 | DI / DATA IN | DATA IN | Serial bit into the shift register (SHIFT mode) |
| 1 | CLK | CLK | Shift clock. Rising edge shifts when command is SHIFT |
| 2 | STB | STB | Strobe. Rising edge latches C2:C0 and runs the command |
| 3 | C0 | C0 | Command LSB |
| 4 | C1 | C1 | |
| 5 | C2 | C2 | Command MSB |
| 6 | IRQEN | (card glue) | Enable IRQ onto /IRQ (manual / Jace). Not a chip pin. a2fpga does not latch this bit |
| 7 | — | — | Unused on write |
a2fpga latches only data[5:0]. web-a2e looks at STB, CLK, and data[5:3].
Edge polarity (both sources, and the datasheet):
- STB 0→1: latch command, execute it.
- CLK 0→1: one shift if the latched command is SHIFT.
- Holding a line high does not retrigger. Falling edges do nothing.
GSSquared uses rising edges.
Read¶
| Bit | Meaning | web-a2e | a2fpga |
|---|---|---|---|
| 7 | DATA OUT (shift-register LSB, or 1 Hz square wave in REGISTER HOLD) | yes | yes |
| 6 | Live TP square wave (Thunderware CLOCK/TUT polls this) |
0 | 0 |
| 5 | IRQ asserted (latched timer pulse, not yet acked) | not implemented | yes |
| 4–0 | — | 0 | 0 |
Firmware reads DATA OUT with LDA $C0n0 / ASL (bit 7 → carry).
uPD1990AC commands (C2:C0)¶
Latched on STB rising. Encoded in write bits 5:3 (cmd = (value >> 3) & 7).
| C2:C0 | Value in bits 5:3 | Name | Action |
|---|---|---|---|
000 |
$00 |
REGISTER HOLD | Normal timekeeping. DATA OUT = 1 Hz square wave (a2fpga). Shift register idle |
001 |
$08 |
SHIFT | CLK rising: shift_reg ← {DI, shift_reg[39:1]}. DATA OUT = shift_reg[0] |
010 |
$10 |
TIME SET | time_counter ← shift_reg |
011 |
$18 |
TIME READ | shift_reg ← time_counter. DATA OUT immediately shows the new LSB. Does not require a CLK first |
100 |
$20 |
TP 64 Hz | Timer pulse 64 Hz |
101 |
$28 |
TP 256 Hz | Timer pulse 256 Hz |
110 |
$30 |
TP 2048 Hz | Timer pulse 2048 Hz |
111 |
$38 |
TP 4096 Hz / TEST | a2fpga: 4096 Hz pulse. web-a2e comments this as chip test mode. Manual only documents 64 / 256 / 2048 |
Firmware/manual interrupt-rate “control characters” are these TP commands, issued through the same STB path.
web-a2e implements TIME READ and SHIFT well enough for ProDOS. It documents the other codes and ignores them.
40-bit time format¶
LSB of the shift register is the first bit the 6502 sees (DATA OUT, bit 7). Within each nibble, bits are LSB first.
| Bits | Field | Encoding |
|---|---|---|
| 0–3 | Second ones | BCD 0–9 |
| 4–7 | Second tens | BCD 0–5 |
| 8–11 | Minute ones | BCD 0–9 |
| 12–15 | Minute tens | BCD 0–5 |
| 16–19 | Hour ones | BCD 0–9 |
| 20–23 | Hour tens | BCD 0–2 |
| 24–27 | Date ones | BCD 0–9 |
| 28–31 | Date tens | BCD 0–3 |
| 32–35 | Day of week | 0–6, Sunday = 0 |
| 36–39 | Month | binary 1–12, not BCD |
a2fpga time_counter packing (bit 0 = seconds ones):
[39:36] month | [35:32] dow | [31:28] date tens | [27:24] date ones
[23:20] hour tens | [19:16] hour ones | [15:12] min tens | [11:8] min ones
[7:4] sec tens | [3:0] sec ones
SHIFT right by one: new DI becomes bit 39; old bit 0 is discarded after being read.
web-a2e’s docs/thunderclock-debug.md lists month first. That note is stale. The C++ and unit tests match the table above (seconds first). Use the tests, not that debug page.
Month has no year and no leap-day. a2fpga uses Feb = 28 always.
Read-time sequence¶
What the firmware (and web-a2e tests) do:
- Drive STB low (and usually CLK low) so the next STB is a rising edge.
- Write
$18 | STB($1C): TIME READ, STB rising. Chip copiestime_counter→shift_reg. Bit 0 is already on DATA OUT. - Read
$C0n0; bit 7 is bit 0 of the stream. - For bits 1–39: drop CLK, raise CLK, read bit 7. Optionally keep TIME READ + STB asserted; command is already latched.
- Some firmware then issues SHIFT (
$08) and continues clocking. After TIME READ, a2fpga only shifts while the latched command is SHIFT. web-a2e also advances the bit index if the latched command is still TIME READ.
Compatible behavior:
- After TIME READ, present
shift_reg[0]immediately. - Each CLK rising in SHIFT moves one bit.
- Also advancing on CLK while still in TIME READ (web-a2e) matches firmware that never issues SHIFT. Prefer a2fpga: TIME READ loads; SHIFT clocks. If a driver only strobes
$18and then toggles CLK with$18still in the write data, STB stays high so the command is not re-latched; the latched command remains TIME READ. web-a2e still shifts. a2fpga would not.
Emulator choice: accept CLK rising as a shift whenever the last latched command is TIME READ or SHIFT. That covers both firmwares and both sources.
Set-time sequence¶
a2fpga only (web-a2e has no TIME SET):
- SHIFT mode. For 40 CLK rising edges, present each data bit on DI (bit 0). First bit written is seconds-ones LSB (same order as read).
- STB with TIME SET (
$10 | STB=$14).time_counter ← shift_reg.
The chip then keeps time from that value.
Timekeeping models¶
| web-a2e | a2fpga | |
|---|---|---|
| Source of time | localtime() on every TIME READ |
Internal BCD counter, +1 s from the FPGA clock |
| TIME SET | ignored | loads the counter |
| Apple Reset | N/A (stateless snapshot) | Chip does not reset (battery). Card IRQ latch does |
| Power-on | host now | 00:00:00, Sunday 1 January |
For GSSquared, either model is valid:
- HLE (web-a2e): TIME READ snapshots the host clock. Enough for ProDOS timestamps. TIME SET can be a no-op or can store an offset.
- Chip (a2fpga): keep a 40-bit BCD counter, tick at 1 Hz (or derive from CPU cycles). Needed if Thunderware
TIME/ set-clock utilities must stick, and for IRQ-driven apps that assume the chip is free-running.
Recommended: HLE snapshot for TIME READ, plus store TIME SET into the same 40-bit register so a following TIME READ returns what was written until the next host snapshot policy. Simplest correct policy: after TIME SET, use the loaded value and advance it with a 1 Hz tick (do not jump back to host time until the next cold start).
Interrupts¶
web-a2e: not implemented.
a2fpga:
- A TP command (
$20/$28/$30/$38) makestp_outa square wave at that rate. - Rising edge of
tp_outsetsirq_status. /IRQis asserted whileirq_statusis set (and the card is enabled).- Any read of
$C0nXclearsirq_status. - Read bit 5 reflects
irq_statusbefore the clear (same cycle: present the bit, then clear).
Factory manual (card glue, not in a2fpga):
- Put the ISR at
$03FE/$03FF. - Write
$40(IRQEN) to the control register, thenCLI. - Rates 64 / 256 / 2048 Hz via the TP commands (SET/PROTECT switch on the real card; ignore the switch in software).
- ISR ack:
LDA $C088,YthenLDA $C080,Y(both alias to the same read-clear).
$40 is bit 6 only; C2:C0 = HOLD. So IRQ enable is not a uPD1990 command. a2fpga never looks at bit 6: IRQ follows TP mode alone.
Target for GSSquared:
- Latch IRQEN from write bit 6.
- Latch TP rate from C2:C0 when STB rises on a TP command. Default 64 Hz at power-on; do not cancel TP on HOLD / SHIFT / TIME READ (Thunderware TEST only writes
$40). TIME SET forces 64 Hz. - On each TP rising edge, if IRQEN, set IRQ asserted and pull
/IRQ. - Read
$C0nX: DATA OUT in bit 7, live TP in bit 6, IRQ asserted in bit 5; then clear asserted. - Do not require a dedicated
$C0n8decoder; aliasing is enough for the manual’s two-read ack.
ISR software must not call $Cn08 firmware (that claims $C800 and can evict another card). Direct $C0n0 bit-bang only. That is a software rule, not extra hardware.
Reset and persistence¶
| Event | Chip time | Shift / command | IRQ latch | IRQEN |
|---|---|---|---|---|
| Apple Reset (Ctrl-Reset, PR#) | keep (battery) | a2fpga keeps chip state | clear | a2fpga clears control_reg |
| Emulator power-on / cold start | host now, or midnight 1 Jan | HOLD, empty shift | clear | 0 |
a2fpga splits device_reset_n (chip) from system_reset_n (card). Match that: do not zero the time of day on warm reset.
Firmware ROM¶
Ship the real image (roms/cards/tcp/tcp.rom in this tree; MAME CRC 1b99c4e3).
https://mirrors.apple2.org.za/Apple%20II%20Documentation%20Project/Interface%20Cards/Clock/Thunderware%20Thunderclock/ROM%20Images/Thunderclock%20Plus%20ROM.bin
Do not synthesize a fake $Cn00 stub if this ROM is present. ProDOS and Thunderware utilities execute it.
Source disagreements (resolved)¶
| Topic | web-a2e | a2fpga | Spec |
|---|---|---|---|
| STB / CLK edge | rising | rising | rising |
| After TIME READ, first bit | immediate, no CLK | immediate (shift_reg[0]) |
immediate |
| CLK while command still TIME READ | advances bit | no shift (need SHIFT) | accept both (TIME READ or SHIFT) |
| I/O aliases | all 16 | all DEVSEL | all 16 |
| DATA OUT bit | 7 | 7 | 7 |
| IRQ status bit | — | 5 | 5 |
| IRQ enable bit 6 | — | not implemented | implement (manual) |
| IRQ clear | — | any $C0nX read |
any read |
| TIME SET | no | yes | yes if aiming at utilities |
| Time source | host snapshot | BCD + 1 Hz | see Timekeeping |
Command 111 |
test mode | 4096 Hz | either; firmware uses 64/256/2048 |
| Debug-doc nibble order | wrong (month first) | seconds first | seconds first |
GSSquared today¶
src/devices/thunderclock_plus/thunderclockplus.cpp:
- Loads
tcp.rom; maps$Cnxxand$C800viamap_c1cf_page_read_only. - All 16
$C0nXaliases; rising STB/CLK. - TIME READ / TIME SET / SHIFT / HOLD / TP; 40-bit stream is seconds-ones first.
- Host snapshot on TIME READ until TIME SET; then 1 Hz BCD tick (Feb = 28).
- Power-on default TP 64 Hz; HOLD / SHIFT / TIME READ / warm reset leave TP running; TIME SET forces 64 Hz.
- Read: DATA OUT bit 7, live TP square wave bit 6, IRQ status bit 5 (then clear).
- IRQEN (write bit 6);
/IRQwhileirq_status && irqen. - Warm reset keeps time; cold start snapshots the host.
Implementation checklist¶
- Switch STB/CLK to rising edges; keep previous pin state.
- Latch
cmd = (value >> 3) & 7on STB rising; run TIME READ / TIME SET / TP / HOLD / SHIFT. - Present
shift_reg[0]on bit 7 immediately after TIME READ; shift on CLK rising in SHIFT (and TIME READ, for web-a2e firmware). - Keep the 40-bit layout in the table above (seconds ones first, month last, DOW 0 = Sunday).
- Alias
$C0n0–$C0nF. - IRQEN (bit 6), TP rates, latch on TP rising, status on read bit 5, clear on read, raise
/IRQ. - TIME SET copies shift → time; warm reset does not wipe the clock.
- Drop per-access
fprintf; keep aDEBUG_THUNDERCLOCKtrace if needed.
References¶
- web-a2e:
src/core/cards/thunderclock/thunderclock_card.{hpp,cpp},tests/unit/test_thunderclock.cpp - a2fpga:
hdl/thunderclock/thunderclock_card.sv,hdl/thunderclock/upd1990.sv, README - ThunderClock Plus manual (PDF) — firmware entry points, IRQ ack, 64/256/2048 Hz
- MAME:
src/devices/machine/upd1990a.cpp(chip),src/devices/bus/a2bus/a2thunderclock.cpp(card; DATA OUT only, no IRQ wire) - C800–CFFF mapping