cpm-videx-series — Part 1
Why Microsoft CP/M Didn't Recognize an 80-Column Card
Microsoft SoftCard CP/M 2.20 won't boot when an Apple ][ has a Videx Videoterm 80-column card installed. Version 2.23 boots cleanly. The difference is eleven bytes of 6502 code in the boot loader — and a single byte the Videx ROM has been waiting to be asked about since 1980.
Part 1 of an ongoing investigation into the Microsoft SoftCard CP/M 2.20 → 2.23 delta that enabled Videx Videoterm support. This part identifies how 2.23 detects the Videx; Part 2 will trace what it does with that knowledge.
The investigation didn’t run linearly. I chased tangents that turned out to be dead ends, doubled back when later findings invalidated earlier claims, and got curious about pieces that didn’t matter for the main question. The articles here are reordered to follow the boot pipeline rather than the discovery order, so the reading flow is linear even though the work wasn’t. Where a tangent or correction ended up mattering, I flag it inline. The dev logs preserve the rough chronology if you want to see how the investigation actually unfolded.
The Microsoft Z-80 SoftCard wouldn’t work with my Videx 80-column emulation.
Seemingly.
That’s the message that started it. Joshua Norrid had tried booting Microsoft SoftCard CP/M on the A2FPGA core — the FPGA-based Apple II compatible I work on, which includes a Videx Videoterm emulation in its slot 3 implementation. CP/M didn’t boot. Just hung at the load screen, never made it to A>.
That couldn’t stand. The Videx emulation is supposed to be transparent — software that runs on a real Videx card should run identically on the FPGA. If Microsoft CP/M doesn’t boot, that’s an emulation defect, and emulation defects are bugs to fix.
Joshua then did the test that mattered. He pulled the FPGA out, plugged in an actual Videx Videoterm card, and booted the same CP/M image on real hardware. Same hang. Same place. The emulation matches the hardware exactly — they fail in lockstep.
That’s a relief, sort of. The emulation isn’t at fault. But the question shifts: now there’s a reason to figure out why an early-1980s Microsoft operating system can’t cope with the most popular early-1980s 80-column display card. They were contemporaries. The Videx shipped in 1980; the SoftCard in 1980; CP/M itself goes back to 1974. They were sold to overlapping customers. Why doesn’t this work?
I went spelunking the Internet for every Microsoft SoftCard CP/M variation I could find. Located a fair number. Classified each by the hardware it claims to support — 80-column cards, serial cards, parallel cards, the various display-card protocols. Joshua tested each one on his physical setup. The picture that emerged was clean:
- Microsoft CP/M 2.20 — fails to boot with a Videx (real and emulated)
- Microsoft CP/M 2.20B — fails to boot with a Videx
- Microsoft CP/M 2.23 — boots cleanly with a Videx, real or emulated, and runs in 80-column mode
The change is somewhere between 2.20B and 2.23. A focused diff between 2.20 and 2.23 should expose it. I downloaded both, sat down with a hex editor and a 6502 disassembler, and started looking. (My own Microsoft Z-80 SoftCard is reportedly in the mail. For static analysis it’s not needed; Joshua’s hardware confirms whatever the analysis predicts.)
This is the story of finding the change.
A Brief Tour of the Players
Three pieces of 1980s Apple ][ hardware are about to interact. A reader who lived through the era can skip this section.
The Apple ][ had a 6502 processor and one job at boot: load a single sector from the floppy disk via the Disk II controller’s tiny boot ROM, then turn execution over to whatever code that sector contained. The boot ROM (“P6 PROM”) was 256 bytes. It knew nothing about CP/M, or about the Videx, or about the SoftCard. All it knew was how to read sectors.
The Microsoft Z-80 SoftCard was a peripheral card with a Z-80 microprocessor on it. Plugged into a slot, it gave the Apple ][ a second CPU — one that could run the much larger library of CP/M software. The 6502 booted the system, the SoftCard’s Z-80 took over once CP/M was loaded, and the Apple ][ became (functionally) a CP/M machine while the card was active. The SoftCard was Microsoft’s first hardware product. By 1981 it was reportedly Microsoft’s largest revenue source.
The Videx Videoterm was an 80-column display card. The Apple ][ shipped with 40 columns of text — fine for games, useless for word processing or spreadsheets. The Videx had its own MC6845 CRT controller, its own 2 KB of video RAM, and its own character ROM. It generated an 80×24 display in parallel with the Apple’s native 40-column display, switching between them via a soft switch. By 1981 the Videx was the serious-business 80-column card; if you ran WordStar on an Apple ][, you ran it on a Videx. (For the full story of the Videx hardware and how the A2FPGA emulates it, see Part 1 of the A2FPGA Videx series.)
So the question is: what does Microsoft CP/M do, between the moment the Apple boot ROM hands it control and the moment the Z-80 takes over, that fails when a Videx is installed in slot 3? And what did Microsoft change in 2.23 to fix it?
Three Disks, One Surprise Up Front
The disk images came in three: CPMV233.DSK, CPM220Disk1.po, and CPM220Disk2.po. The 2.20 distribution shipped on two floppies; the 2.23 distribution on one.
Surprise number one: CPMV233.DSK is CP/M version 2.23, not 2.33. There is no 2.33. The internet-distributed filename is misleading — the boot sector at offset $0060 carries the low-ASCII string (C) 1982 MICROSOFT - CP... and the on-disk version stamp confirms 2.23. I flag this up front so anyone else chasing this trail doesn’t get spun out by the filename.
Surprise number two — actually a relief — is that the 6502 boot stub at the very start of the disk is byte-identical across all three images. Bytes $00-$2F of track 0 sector 0 match exactly. The CP/M sector skew table at $082D-$083C (the order in which the boot stub reads sectors from the disk: 00 02 04 06 08 0A 0C 0E 01 03 05 07 09 0B 0D 0F) is identical too. Whatever changed in 2.23 wasn’t in the very first 256 bytes loaded by the Disk II ROM.
That’s useful — it eliminates a region. The Videx-relevant change has to be somewhere in what the boot stub loads, not in the boot stub itself.
The Z-80 BIOS, And Why I Couldn’t Use It
CP/M is structured in three layers: the CCP (the command processor — what shows you the A> prompt), the BDOS (the OS calls — file open, character input, etc.), and the BIOS (the hardware-specific code that talks to the actual machine). CCP and BDOS come from Digital Research and don’t change. The BIOS is supplied by whoever builds CP/M for a particular machine — in this case Microsoft — and it’s where any hardware-specific behavior lives. Specifically, the routines that handle character output (CONOUT) and console initialization (BOOT and WBOOT).
So the Videx-detection code, if it lives in the Z-80 side at all, has to be in the BIOS. The CCP and BDOS would be byte-identical between 2.20 and 2.23.
A CP/M 2.x BIOS starts with a sequence of JMP instructions — C3 lo hi repeated, one per device entry point. I scanned both disk images for any run of 8 or more consecutive JMP instructions whose targets cluster in a small address range. Two distinct hits per image (a primary copy and a backup). The 2.23 BIOS lives at Z-80 address $FAB8. The 2.20 BIOS lives at $DACC. That’s an 8 KB shift up — meaning 2.23 reorganized memory to give the Transient Program Area 8 KB more room.
That alone is a much larger structural change than a Videx fix would justify. And it had a practical consequence for my plan: I couldn’t just byte-diff the BIOSes. Every absolute address moves by $2000, which dirties two bytes per JMP/CALL/LD imm16 instruction. Subsequent re-assembly shifts everything that follows. An aligned diff showed 1982 of 2048 bytes changing — close to 100%. Useless.
I tried to extract just the BIOS bytes and disassemble them. That hit a deeper problem: the bytes at the BIOS jump-table targets, in the reconstructed file image, didn’t decode as plausible Z-80 code. They looked like address tables, not routines. The on-disk layout the boot loader reads from doesn’t equal the layout that ends up in Z-80 memory. The SoftCard loader presumably reads sectors in some skew order and scatters them across non-contiguous Z-80 addresses. Reconstructing that mapping requires understanding the loader.
So the loader is what needs to be understood first anyway. The Z-80 side could wait. Pivot to the 6502 side.
There’s also a useful observation here about where to look. If the Videx-detection logic exists at all, it has two reasonable places to live: the 6502 boot loader (which runs before the Z-80 takes over) or the Z-80 BIOS. The 6502 side runs first, has direct access to slot ROMs via the Apple ][ memory map, and is where any initial device discovery would naturally happen. If I find detection logic there, I’m done with the detection question.
The Stage-Two Loader At $1000
Here’s what the 6502 boot stub actually does. The Disk II PROM loads track 0 sector 0 to memory $0800 and jumps to $0801. The code at $0801 is the CP/M boot stub, and it does one job: load ten more sectors of track 0 from the disk into RAM at $0A00-$13FF, in CP/M skew order, then jump to $1000. The code at $1000 is the stage-two boot loader, occupying about 3 KB of Apple ][ RAM. It’s responsible for everything else — initializing the Apple’s hardware, scanning the slots, copying CP/M into Z-80 memory, and finally flipping the SoftCard switch to wake up the Z-80.
This is where any 6502-side Videx detection has to live. So I built the 3 KB memory image (sectors 0, 2, 4, 6, 8, A, C, E, 1, 3, 5 of track 0, placed at memory addresses $0800, $0A00-$1300) for both the 2.20 and 2.23 versions, and ran them through the 6502 disassembler from my nibbler toolkit.
Both stage-two loaders begin at $1000 with the same two instructions:
$1000: AD 81 C0 LDA $C081
$1003: AD 81 C0 LDA $C081
That’s the Apple ][ language card switch — a double-write to enable RAM at the high addresses where the Apple’s monitor ROM normally lives. The Z-80 needs RAM there to load CP/M into. Both versions do this identically.
Then they diverge — at byte 6. 2.23 inlines what 2.20 calls as a subroutine. Different instruction sequences, but same intent. They re-converge soon after; it’s a stylistic difference from a re-assembly, not a semantic one.
The interesting region is around $1060-$10D0. Both versions contain code that walks the Apple ][ slots — slots 7 down through 1 — and tests each slot’s expansion ROM against a small fixed table of known device signatures. This is the slot scanner. Whatever device-detection happens, happens here.
What’s a Pascal Signature?
A bit of Apple ][ lore. The Apple Pascal firmware protocol defined a way for peripheral cards to identify themselves to host software. Each card with an expansion ROM at $Cn00-$CnFF (where n is the slot number, 1-7) could place a small “signature” in fixed locations within that ROM. Software that wanted to find compatible cards would page in each slot’s ROM in turn and check those locations.
The protocol came in two versions. Pascal 1.0 used just two ID bytes:
$Cn05=$38(Pascal 1.0 ID byte 1)$Cn07=$18(Pascal 1.0 ID byte 2)
Pascal 1.1 kept those two bytes and added two more:
$Cn0B=$01(Pascal 1.1 signature — fixed value, just a marker that says “I follow Pascal 1.1”)$Cn0C=$ci(high nibblec= device-type code, low nibblei= instance ID for distinguishing multiple cards of the same type)
The canonical reference is Apple II Technical Note Misc #8, Pascal 1.1 Firmware Protocol ID Bytes, written by Cameron Birse and revised by Matt Deatherage. It’s the only document Apple ever formally published on this protocol. (The Tech Note also notes that Apple never published a complete list of device-type nibble values — assignments existed but were maintained only informally and DTS stopped tracking them in the mid-1980s.)
The Videx Videoterm implements Pascal 1.1. Pulled directly from the Videx ROM 2.4 disassembly (which lives in the A2FPGA repository thanks to the Videx emulation work):
$CB05 = $38 Pascal 1.0 ID byte 1
$CB07 = $18 Pascal 1.0 ID byte 2
$CB0B = $01 Pascal 1.1 signature
$CB0C = $82 device type $8, instance $2
When the Videx’s expansion ROM is paged in, those bytes appear at $Cn05/$Cn07/$Cn0B/$Cn0C for whichever slot the Videx is in.
The Tech Note doesn’t publish a complete registry of device-signature nibbles, but it lists the actual $Cn0C values for every Pascal-1.1-compliant Apple-produced card. Reading those values back out gives a de-facto registry for the values Apple actually used:
Device-signature nibble (c) | Class (inferred from Apple’s published cards) |
|---|---|
$2 | Mouse / pointing device (Apple II Mouse Card = $20; IIc/IIGS mouse port = $20) |
$3 | Serial / communications interface (Apple Super Serial Card = $31; AppleTalk = $31) |
$8 | 80-column display card (Apple 80 Column Card = $88; IIc/IIGS slot 3 = $88) |
The Videx is a third-party card and isn’t in Apple’s table, but it follows the convention: device signature $8 (display), instance ID $2. That’s the same $8 Apple uses for its own 80-column card, just with a different instance ID to distinguish the cards. (Full table of all Apple Pascal-1.1 cards is in the reference capture of Tech Note Misc #8.)
Hold on to the byte at offset $0B (the Pascal 1.1 signature) for a moment.
The Slot Scanner
Both 2.20 and 2.23 contain a near-identical slot scanner. Both walk slots 7 down to 1, set a zero-page pointer ($3C/$3D) to point at $Cn00, and check the bytes at $Cn05 and $Cn07 against a small fixed table of four 2-byte signature pairs. Here’s the table — byte-identical between versions, only relocated within the loader:
F2 03 18 38 <- bytes expected at $Cn05 (column-indexed via X=4..1)
48 3C 38 18 <- bytes expected at $Cn07
Read column-wise, four signatures. Three are Microsoft-specific cards (probably the Microsoft Serial card, the Microsoft Parallel card, and one other). The fourth is the standard Apple ][ Pascal signature: $Cn05 = $38, $Cn07 = $18. That’s the one any Pascal-compatible card declares — the Videx, the Apple Super Serial Card, the Mountain Computer cards, anything written to be Pascal-aware.
The 2.20 scanner does this:
$1097: LDX #$04 ; X = 4 (start at table entry 4)
$1099: LDY #$05 ; Y = Pascal magic byte 1 offset
$109B: LDA ($3C),Y ; A = byte at $Cn05
$109D: CMP $1176,X ; compare against signature table[X]
$10A0: BNE skip ; mismatch → try next entry
$10A2: LDY #$07 ; Y = Pascal magic byte 2 offset
$10A4: LDA ($3C),Y ; A = byte at $Cn07
$10A6: CMP $117A,X ; compare against signature table[X]
$10A9: BEQ done ; both match → exit loop
$10AB: DEX ; mismatch on byte 2 → try next entry
$10AC: BNE next ; loop while X != 0
$10AE: INX ; X is now (matched_index + 1), or 1 if no match
$10AF: CPX #$02
$10B1: BNE skip2
$10B3: INC $03B8 ; (counter for one specific match)
$10B6: LDY $3D ; Y = current slot ROM page ($Cn)
$10B8: TXA
$10B9: STA $02F8,Y ; record device code at DEV_TABLE[$Cn]
The matched signature index becomes the device code. If a slot’s ROM matches the Pascal signature (table entry 3 — $38, $18), 2.20 stores the value 4 at $02F8 + slot. The downstream loader and the eventual Z-80 BIOS use that device code to decide how to drive the slot.
Now the 2.23 version of the same routine. The first sixteen instructions are identical (just at slightly shifted addresses because of upstream edits). Then comes an inserted block:
$10BD: CPX #$04 ; ============================
; NEW IN 2.23: did we match the
; STANDARD APPLE ][ PASCAL signature
; (entry 3 in the table — X=4 after
; the loop)?
$10BF: BNE skip
$10C1: LDY #$0B ; Y = Pascal generic device byte offset
$10C3: LDA ($3C),Y ; A = byte at $Cn0B
; (Videx Videoterm: $CB0B = $01)
$10C5: CMP #$01 ; is it $01?
$10C7: BNE skip
$10C9: LDX #$06 ; YES → override device code: $06
; ============================
$10CB: LDY $3D ; (continues identically)
Eleven bytes of new code. Read functionally:
CP/M 2.20 looks for the Apple Pascal 1.0 firmware ID bytes ($Cn05=$38, $Cn07=$18). When it finds them, it routes the slot through the Pascal 1.0 device I/O path.
CP/M 2.23 looks for the Apple Pascal 1.1 firmware ID bytes ($Cn05=$38, $Cn07=$18, and $Cn0B=$01). When it finds those, it routes the slot through the Pascal 1.1 device I/O path.
There’s a small problem hiding in plain sight there. Pascal 1.1 devices declare both the 1.0 and the 1.1 ID bytes, because the protocol is additive — the 1.0 bytes mean “I’m a Pascal-firmware card,” the extra 1.1 bytes mean “and I follow the newer protocol.” But the calling conventions are different.
Under Pascal 1.0, the ID bytes ARE the entry points. $38 is the 6502 opcode for SEC; $18 is CLC. The bytes are dual-purpose — identification and directly-executable code. JSR $Cn05 does input (carry set), JSR $Cn07 does output (carry clear), and both fall through into common firmware dispatch that uses the carry flag. Apple’s PR#n / COUT machinery hooks this up by pointing the Monitor’s character-output vector (CSW) at $Cn00, where the card does one-shot setup on the first call before CSW gets relocated to $Cn07 for subsequent calls.
Under Pascal 1.1, there is a 4-byte vector table at $Cn0D-$Cn10: INIT, READ, WRITE, STATUS — each a single-byte offset within the slot ROM page. The caller reads the offsets, JSRs to $Cn00 + offset, and must call INIT first before any of the others.
A Pascal 1.1 card may be backward-compatible at the byte level (the Videx is — $CB05 and $CB07 are real SEC/CLC instructions that fall through to dispatch), but its 1.0-compatible dispatch path typically requires the V flag to be set by an upstream BIT IORTS at $Cn00 during the V-setting preamble. PR#n / COUT-via-CSW sets that up correctly; a naive JSR $Cn07 does not.
So when CP/M 2.20 sees a Videx, it sees a Pascal 1.0 card (because the Videx truthfully declares $38, $18 at the 1.0 ID offsets), and does a Pascal-1.0-direct JSR $Cn07. That bypasses the Pascal-1.0 PR#n / CSW installer dance, bypasses the Pascal 1.1 INIT vector, and lands in dispatch code that hasn’t had its V-flag preamble or any device-internal state set up. The 6845 CRTC isn’t programmed; the VRAM window isn’t mapped; the firmware polls a status flag that never sets. The streams get crossed. Fortunately, instead of total protonic reversal, the computer just hangs.
CP/M 2.23 fixed it by reading the third ID byte and routing Pascal 1.1 cards through the 1.1 vector table at $Cn0D — calling INIT first, then WRITE/READ/STATUS through the offsets the card publishes.
What 2.23 doesn’t check is interesting in itself: it doesn’t read $Cn0C, the Pascal 1.1 device-type byte where the card declares what kind of device it is. (The Videx declares $8? — informally a display.) 2.23 could have used $Cn0C to differentiate displays from serial cards from clocks. It doesn’t. It treats every Pascal 1.1 card identically — anything declaring the 1.1 signature gets the new device code $06, regardless of declared type. The fix is “be Pascal-1.1-aware,” not “be Videx-aware.” The Videx benefits because it’s the dominant Pascal 1.1 card on the Apple ][ in 1982.
What’s in the Pascal 1.1 I/O path
Detection is the door. The room behind it — the actual code that drives a Pascal 1.1 card under CP/M 2.23 — is the engineering content. That code lives further into the 6502 stage-two loader and in the Z-80 BIOS’s CONOUT / BOOT routines. The plausible behaviors it has to perform:
- The Videx soft switches at
$C0B0/$C0B1. The Videx’s MC6845 CRT controller is programmed by writing a register number to$C0B0and the value to$C0B1. Initialization requires programming about 16 registers in sequence. 2.20 won’t do this for a card it thinks is a generic Pascal serial port; 2.23 should know to do it for device code$06. - The VRAM window at
$CC00-$CDFF. The Videx maps 2 KB of its own video memory into the Apple ][ address space when its expansion ROM is paged in. Writing characters to those addresses puts them on the 80-column screen. 2.20 wouldn’t write there; 2.23 should. - The
$CFFFROM-release switch. The expansion ROM is shared by all slots — only one card can have it paged in at a time. Code that uses the Videx has to politely release the ROM when it’s done. The character-output routine has to negotiate this on every call. - Console-input routing too. Whatever code 2.23 emits for keyboard input, it has to coexist with the Videx being the active output device. (Pascal-1.1 firmware cards have their own input convention through the same ROM, which may or may not apply here.)
None of that is in the boot stub or the 11-byte detection branch. It’s downstream — in the rest of the 3 KB stage-two loader at $0800-$13FF, and in the Z-80 BIOS’s CONOUT and BOOT paths. The detection mechanism just makes sure that downstream code knows which slot it’s writing to.
What’s Coming In Part 2
Tracing the consumer side is the next piece of work. It means going back to the Z-80 BIOS — the one whose layout I couldn’t reconstruct earlier in this article. The detour through the 6502 loader wasn’t wasted; understanding the loader in depth is what’s needed to figure out how it places sectors into Z-80 memory. With that mapping understood, the BIOS becomes accessible, and the CONOUT routine can be disassembled and diffed.
The interesting question is: how much of CP/M 2.23 is new code versus 2.20’s old code conditionalized on the device table? Did Microsoft write a brand-new Videx driver? Did they generalize the existing console driver to call out to a slot-card output routine when the table indicates one? Did the 8 KB BIOS-base shift between the two versions reflect adding the Videx code, or is it unrelated reorganization that happened to ship in the same release? These are answerable from the code.
If 2.23 contains a Videx driver of any size, that driver is the actual fix. The 11-byte detection branch is just how 2.23 knows when to call it. Part 2 will read it.
For now, what’s settled: Microsoft CP/M 2.20 hangs on a Videx because it sees a Pascal-firmware card, assumes Pascal 1.0, and tries to call a 1.0 entry point that the Videx doesn’t implement. CP/M 2.23 boots because it reads one more ID byte ($Cn0B), recognizes the card as Pascal 1.1, and routes it through the 1.1 path that the Videx actually exposes. The Videx ROM has been declaring itself a Pascal 1.1 device since 1980; Microsoft just hadn’t been asking. What that 1.1 path actually does — what it pokes at $C0B0/$C0B1, what it writes into $CC00-$CDFF, how it negotiates the $CFFF ROM-release switch — is the next installment.
Deep dives
Devlogs that fed this article — the setup, the disks, the device-code research, and the byte-level annotation of the slot scanner:
- Microsoft CP/M doesn’t boot on a Videx — and never did — Joshua’s original report and the version survey that isolated 2.20→2.23 as the boundary.
- Three disks, two versions, one shared boot stub — sector-order analysis of the three CP/M disk images, including the boot-string read that confirmed
CPMV233.DSKis 2.23. - What does device code 4 mean? What does device code 6 mean? — the Pascal 1.0 vs 1.1 calling-convention research that explains why a 1.1 card hangs when called like a 1.0 card.
- Eleven bytes that recognize a Videx — byte-level annotation of the slot scanner with the Pascal 1.1 detection branch isolated.
The full investigation lives in the Orchard repository under docs/CPM_Videx_Difference.md and cpm-investigation/. The disassembler used is nibbler, also in that repository. The Videx ROM disassembly with symbolic addresses lives in the A2FPGA repository under hdl/videx/. The canonical Pascal firmware-protocol specification is captured here as a reference page: Apple II Technical Note Misc #8 — Pascal 1.1 Firmware Protocol ID Bytes.