Bridging the four-way handshake

← Acorn Econet Bridge

Bridging the four-way handshake: how forwarding really works

The Acorn Econet Bridge's forward path is substantially more subtle than it looks at first read. A casual glance at rx_a_forward suggests a simple "receive from A, retransmit on B" relay — but the routine runs through seven distinct stages of ADLC traffic, and three of them transmit a buffer whose contents are never built by the obvious code path. Working out what those extra transmissions were doing was the key that turned a puzzling sequence of instructions into a clean implementation of Econet's end-to-end frame-exchange protocol.

This note reconstructs the reasoning: what the Econet four-way handshake is, what a bridge between two segments has to do about it, and how the ROM implements that as a tight sequence of calls to three small helper routines.

Background: the Econet four-way handshake

An Econet data frame is not a single packet. Because the physical layer is a cheap, low-bit-rate shared bus with no full carrier detect and no collision-repair, Acorn chose a transaction design that is firmly conservative: every data transmission is a four-way exchange, with explicit acknowledgement at both ends.

A transmission consists of:

  1. Scout (A → B). Sender A broadcasts a scout frame — destination station and network, source station and network, control byte, port — onto its segment. The recipient B sees its own address and asserts the ADLC's address-present interrupt.

  2. Scout ACK (B → A). B replies with a short frame echoing the addresses and control byte, confirming that it is ready to receive data.

  3. Data (A → B). A sends the payload — the actual content of the conversation, framed as another HDLC frame with the same addresses and control.

  4. Data ACK (B → A). B confirms receipt of the data, closing the transaction.

At the ADLC level, each of these four frames is a complete scout or data frame — a start-of-frame flag, addresses, optional payload, CRC, end-of-frame flag. Only after all four have been exchanged is the transmission complete.

Crucially for our purposes, the four frames have to happen in sequence, with relatively tight timing, on the same physical Econet segment. If any frame is missed or corrupted, the transaction aborts and the sender has to start again.

The problem for a bridge

A bridge sits between two segments, let's call them A and B. Consider a station on segment A addressing a frame to a station on segment B:

So: two frames per direction, in the order scout, ACK, data, ACK, alternating sides. The bridge has to participate as receiver on each of the four frames and as transmitter on each of the four forwards. If any of the eight operations fails, the whole transaction is off — but the failure mode should be clean: neither endpoint should be left waiting indefinitely for a frame that isn't coming.

This is what rx_a_forward implements.

The implementation

The routine at &E208 is the forwarding path when a scout arrives on side A that turns out to be not-for-us-but-forwardable (addressed to a station on a remote network that reachable_via_b says we can reach). Its structure is:

Stage 1: forward the scout

The received scout is already in the receive buffer at rx_dst_stn (&023C) — the rx_frame_a routine drained it there before dispatching. rx_a_forward pushes those bytes directly into ADLC B's TX FIFO, two at a time, with an wait_adlc_b_irq poll between pairs to check TDRA:

    jsr wait_adlc_b_idle        ; CSMA on side B
    ldy #0
.rx_a_forward_pair_loop
    jsr wait_adlc_b_irq
    bit adlc_b_cr1
    bvc rx_a_forward_done       ; TDRA clear -> bail
    lda rx_dst_stn,y
    sta adlc_b_tx
    ...

Odd-length frames send the trailing byte after the pair loop ends; CR2=&3F terminates the burst with end-of-frame flags.

The scout is now on segment B; the addressed station has seen it and is preparing its ACK.

Stages 2–4: the three R/T pairs

Now the handshake proper begins. Each stage is a receive-and-stage on one side followed by a transmit on the other:

    lda #&5A                    ; reset mem_ptr to &045A
    sta mem_ptr_lo              ; (the staging buffer for the next
    lda #4                      ;  receive-for-forward)
    sta mem_ptr_hi

    jsr handshake_rx_b          ; Stage 2: receive ACK1 on B
    jsr transmit_frame_a        ;          forward ACK1 to A

    jsr handshake_rx_a          ; Stage 3: receive DATA on A
    jsr transmit_frame_b        ;          forward DATA to B

    jsr handshake_rx_b          ; Stage 4: receive ACK2 on B
    jsr transmit_frame_a        ;          forward ACK2 to A

    jmp main_loop               ; done

Each handshake_rx_? call does two related things in one subroutine:

Because the staging setup is done inside handshake_rx_?, the calling code's transmit_frame_? immediately afterwards has everything it needs to transmit the just-received frame verbatim on the other port. No extra bookkeeping at the call site.

Mirror symmetry

rx_b_forward at &E389 is the exact mirror, with every occurrence of "A" and "B" swapped. Scout goes inline to ADLC A; the three handshake rounds are handshake_rx_a + transmit_frame_b, handshake_rx_b + transmit_frame_a, handshake_rx_a + transmit_frame_b. The B-A-B transmit pattern at the tail is the same handshake viewed from the other direction.

Why it aborts cleanly

The failure mode of the whole scheme is one of its nicer features. Each handshake_rx_? call is an escape-to-main routine (see the escape-to-main writeup): if it times out waiting for the expected frame, or if the Frame-Valid check fails, or if the dst_net isn't reachable on the far side, it takes the PLA / PLA / JMP main_loop exit, abandoning the in-flight transaction and jumping straight back to the dispatcher.

The consequences of that abort are exactly what we want:

This is what makes the pattern work at all. A bridging protocol that tried to carry explicit transaction state would need recovery logic — timeouts, sequence numbers, retries. The Bridge has none of that. It relies on the fact that every transaction has exactly two ends, both of which have their own independent timeout handling, and all it has to do is stop participating whenever something goes wrong.

Why we couldn't see it at first

For a long time during the disassembly, sub_ce56e and sub_ce5ff were labelled as "listen restore" helpers. That mis-interpretation was natural enough — they always appear immediately after a transmit_frame_? call, and their CR1 = &82 / CR2 = &67 prologue looks exactly like the listen-mode configuration at the tail of adlc_*_full_reset. It was the drain body inside them that gave them away: unconditional reads from adlc_?_tx into (mem_ptr_lo),Y, top_ram_page-bounded, with a CR2-based end-of-frame test. That's a receiver, not a restore.

Once that recognition clicked, the three trailing transmit_frame_? calls in rx_a_forward resolved instantly. They aren't redundant; they aren't announcement bursts; they aren't mysterious. They are the three forward-after-receive pairs of the four-way handshake, with the scout having been handled by the inline loop that precedes them.

The misdirection came partly from the naming. A listen_restore_a routine would fit the call pattern perfectly if you only looked at where it's called. Only by reading the body does its real job become visible. It's a small reminder that call-graph structure is a hypothesis about semantics, not evidence — and that in a ROM this compact, every subroutine is likely pulling double duty.

Cross-references