Software

Two Emulators, One Wire

We virtualised an entire AppleTalk network — Macs, an Apple IIgs, a Unix box — so our retro software has something to argue with

·12 min read·Software

Four stations on one virtual LocalTalk bus carried over UDP multicast 239.192.76.84 port 1954: a Macintosh SE/30 at node 01, an Apple IIgs at node 7f, PhoenixTalk, and a Linux box running netatalk 4.5.1 — with the wire running on past the last station to an empty dashed outline.

Writing software for a thirty-year-old protocol has one problem that nothing else in software development quite prepares you for: you need someone to talk to.

You can read the specification. You can read Inside AppleTalk cover to cover, which we have, more than once. But a specification tells you what a machine was supposed to do, and the interesting bugs live in the gap between that and what it actually did. To close that gap you need a real conversation — a real client, speaking the real protocol, disagreeing with you in real time.

The obvious answer is to plug in the real thing. And we do, occasionally. But vintage Macs are, in the words of the man who owns them, “precious temperamental old codgers” — noisy, expensive to run, and emphatically not something you leave switched on for a fortnight so an automated test suite can hammer it at four in the morning.

So instead we built a network out of ghosts.

The wire

Start with the hard part. If you want two emulated machines to talk AppleTalk, you have to decide what the wire between them actually is.

The traditional answer is EtherTalk over a packet-capture bridge: teach each emulator to inject frames onto the host’s real Ethernet. It works, and we have a build that does it. It also requires root privileges, needs a genuine AppleTalk router somewhere on your office LAN to seed a network number, and drags in the entire address-mapping and routing stack — AARP, RTMP, ZIP, DDP — which is the least-travelled code in every emulator that implements it, and therefore the first suspect whenever anything goes wrong.

The better answer turned out to be much sillier and much more robust: LocalTalk over UDP multicast.

UDP multicast239.192.76.84 : 1954

sender id 4 bytes
the LLAP frame, verbatim n bytes
dest node
source node
type
data
  • Big-endian, and purely a self-echo filter. Multicast loopback hands you back your own datagram; the sender id is how you know to drop it. It carries no routing meaning whatsoever.
  • No framing cleverness, no checksum, no encapsulation. Because both ends are the same link layer, an LLAP frame crosses unchanged.
The entire protocol. No address translation, no half-router, no privileged socket, no external hardware — any process that can open a UDP socket can join the bus.

That is the whole of it. There is one lovely concession to physics. Real LocalTalk gives a station 200 microseconds to answer a request-to-send, which no UDP round trip on Earth can promise, so RTS/CTS handshaking is terminated locally at each end and never crosses the wire. But lapENQ/lapACK — the frames a machine uses to claim a node address — are forwarded, because “is anybody already using node 127?” is a question only the actual holder of node 127 can answer.

The cast

Onto that bus we put whatever we need that day:

  • Macintosh SE/30s under Snow, running System 7.5.3, which we can drive programmatically — set a breakpoint on a Toolbox trap by name, single-step, read memory, take a screenshot, click a menu.
  • An Apple IIgs under a locally-patched GSport, running GS/OS 6.0.1 with Apple’s Network: AppleShare installed.
  • A Linux box running netatalk 4.5.1 — a genuine, modern, actively-maintained AFP server speaking AFP 1.1 through 3.4, over both TCP/IP and AppleTalk.
  • A/UX 3.1, Apple’s Unix for the 68k Mac, because sometimes the question is what a Mac looks like when it is secretly a Unix workstation.
  • A Mac mini running High Sierra, kept deliberately obsolete: 10.15 removed support for HFS standard, and 10.14 introduced the per-app consent prompts that make unattended automation miserable. 10.13 is the sweet spot, and it is never, ever getting updated.

And our own software — PhoenixTalk, the AppleTalk router and file server, and Disk Phoenix, which reads and repairs the disk images all of this runs on.

Four stations on one virtual LocalTalk bus A single horizontal wire labelled UDP multicast 239.192.76.84 port 1954. Hanging from it: a Macintosh SE/30 at node 01 running under Snow, an Apple IIgs at node 7f running under GSport, PhoenixTalk acting as router and file server, and a Linux box running netatalk 4.5.1. At the right-hand end the wire runs on past the last station to an empty dashed outline with nothing attached to it. Macintosh SE/30 Snow System 7.5.3 01 Apple IIgs GSport GS/OS 6.0.1 7f PhoenixTalk AppleTalk router file server Linux netatalk 4.5.1 AFP 1.1 – 3.4 UDP multicast 239.192.76.84 : 1954
Every station speaks the same link layer, so nothing on this diagram translates anything. A/UX sits on the bench too, but not on this bus — that image has no Ethernet compiled in.

The moment it paid for itself

Here is the experiment that made the whole edifice worth building.

We put the Apple IIgs and a Macintosh SE/30 on the bus at the same time, with nothing else on it. The IIgs opened its AppleShare control panel, found the Mac by name, logged in as Guest, mounted the Mac’s boot volume, and copied a file off it onto its own ProDOS disk.

That sentence is quietly remarkable. An Apple IIgs is not a Macintosh. It is a completely different processor, a completely different operating system, and a completely different filesystem — and yet Apple shipped a workstation card and an FST that let it mount an AppleShare volume as if it belonged there. AFP was never a Mac-only protocol; that is simply the only half most of us ever saw.

For us it was the missing test case. Our AFP capability model had, embarrassingly, one row per AFP version — and every row was a Macintosh. That assumption sat underneath all of our reasoning, entirely unstated, until a IIgs turned up and demonstrated that “which version did we negotiate?” is not the same question as “what can this client actually hold?”

The GS/OS Finder’s window banner, for the record, read:

AppleShare
8 items 43.4 MB used 978.5 MB free
Diagram — not a screenshot. The banner text is transcribed exactly as the GS/OS Finder reported it. A slightly absurd thing to have earned.

How you know it actually happened

This is the part I would most like other people to steal, because it is the difference between a demo and a measurement.

Two emulators transmitting on a shared bus and two emulators having a conversation look identical if all you count is datagrams. So we counted something better.

Attribution comes free, by accident. Both emulators derive their LToUDP sender id from their own process id — GSport uses getpid() & 0xffff, and Snow’s bridge duly announced itself as 0x5325, which is 21285, which was exactly its PID. So every datagram on the bus is attributable to a machine without anyone having to guess. The LLAP source node is a second, independent axis — the Mac took node 01, the IIgs node 7f — so the attribution can be checked against itself. Report the split, never the sum.

Then every measurement window carried its own positive control: five synthetic datagrams under a made-up sender id, injected mid-window from a separate socket. If those five don’t come back, the instrument is broken and the window is void — no matter how plausible its numbers look.

The decisive row is the one where the IIgs went looking for a server:

Window Mac IIgs Control What was happening
both idle 0 5 5/5 The IIgs answered the control frames, five for five
IIgs seeks a server 33 38 5/5 33 broadcasts ⟶ 33 directed replies
file copy 196 76 5/5 Traffic in both directions, all directed
IIgs AppleTalk off 45 0 5/5 Negative control

Datagrams per measurement window, attributed by sender id and cross-checked against the LLAP source node. The control column is the positive arm: five synthetic datagrams injected mid-window, from a separate socket, under a made-up sender id.

The zero in the bottom row is the finding, not a gap in the data.

Thirty-three broadcasts drawing thirty-three replies addressed specifically to node 7f, one for one, is a conversation. A bus where two machines merely shout looks the same on a total and completely different here.

Thirty-three broadcasts, thirty-three directed replies The Apple IIgs at node 7f sends 33 frames addressed to the broadcast node. The Macintosh SE/30 at node 01 sends 33 frames back, each addressed specifically to node 7f. One reply for every broadcast. Apple IIgs node 7f Macintosh SE/30 node 01 33 broadcast — to everyone 33 directed — to node 7f, one for one
Two monologues and a conversation produce the same total. They do not produce the same split.

And the negative control ran with a live positive arm in the same window: with the IIgs’s AppleTalk switched off it contributed exactly zero while the untouched Mac contributed 45 and the synthetic control still scored 5/5. So the zero means “this machine’s AppleTalk is off” — not “the bus is dead”, not “the listener crashed”, not “something stole the port.”

That last failure mode nearly voided an entire session, and it is worth naming. Three processes wanted UDP port 1954. If one of them binds without SO_REUSEPORT, it silently prevents another from binding at all — and the listener then reports a beautifully controlled, utterly confident zero for a bus it broke itself. A single lsof -nP -iUDP:1954 before believing any count is now standard practice, because it reports a missing participant rather than a silent one.

Finally, a check that trusts neither guest’s user interface: after the file copy, we went looking for the string SIMPLETEXT in the raw bytes of the IIgs’s ProDOS volume. Present afterwards; absent from the checkpoint that run booted from. File data really crossed the wire — not merely a catalogue entry that looked like it had.

What it caught

A reference implementation on the bench is not a luxury. It is the thing that tells you when you have been confidently wrong for a year.

Our AFP server had been returning result code −5010 to a client that tried to write to a read-only volume. That value did not arrive by accident — it replaced an earlier wrong value, in a previous fix, by someone reasoning carefully from the documentation they had. It was recorded in our own notes as verified.

−5010 is afpFileBusy. “Cannot delete an open file.”

The code for a read-only volume is −5031. So every client we refused a write was being told the file was busy — which reads as transient, and politely invites the client to try again in a moment. Forever.

What we were sending

−5010

afpFileBusy

“Cannot delete an open file.” A transient condition. Try again in a moment.

What we should have sent

−5031

volLocked

The code for a read-only volume — which is what the client had actually run into.

Read off a live conversation with a real server, rather than off a document.

A plausible wrong answer is so much worse than an obviously wrong one. It took having a real server on the bench, and reading the constants off a live conversation, to see it at all. It is fixed, with a test that was proved red before the fix went in — which is its own small article, honestly.

We are careful about how that oracle gets used, incidentally: we measure the running server, we do not read its implementation. Result codes on the wire, timings, what it accepts and refuses — all fair game. Its internals are somebody else’s work and stay that way. Trace, don’t transcribe.

Mail from a dead network

The bus does more than files. PhoenixTalk implements PowerShare, the server half of Apple’s gloriously over-ambitious PowerTalk collaboration stack, and a System 7.5 Mac on our virtual LocalTalk can now compose a letter in AppleMail, address it through the PowerShare catalogue, submit it — and watch it come out the other end of the gateway as ordinary SMTP, into a mail catcher on the Linux box.

A 1994 Macintosh, on a network that does not exist, handing a letter to the modern internet’s mail protocol and being entirely unbothered about it.

One more thing

There is a shape missing from that bus, and if you have been counting client classes you may already have spotted it.

Apple sold a LocalTalk PC Card for IBM compatibles, and AppleShare PC to go with it, so a DOS machine could sit on your AppleTalk network and mount Mac volumes. It is a genuinely strange piece of engineering — the DOS side turns int 21h calls into SMB requests, and a second program translates those SMBs into AFP. And it obliges an AFP server to do something we currently do not do at all: keep two names for every file, a long one for the Macs and a short 8.3 one for DOS. Apple documented the requirement and then explicitly declined to specify the algorithm, which is the sort of detail that keeps me up at night.

You cannot easily buy that card any more. You certainly cannot buy the confidence that the one you buy still works.

But a card is only a bundle of behaviour, and we have got rather good at building behaviour out of nothing.

Watch this space.


Disk Phoenix and PhoenixTalk are in development. If you have an Apple II Workstation Card, a LocalTalk PC Card, or strong opinions about how AFP should derive an 8.3 filename, we would genuinely like to hear from you.