NDCHAT - Architecture¶
How the chat is built: the server, the clients, the trunks between machines, the limits that
are designed in, and what it costs in time and space. Every number here is read from the source
(SINTRAN-CHAT/*.PLNC, the constants are named) or was measured on the lab machines, and says
which. Nothing is from memory.
As of 2026-09-02, server build S02-1340, client B02-1340. The companion pages:
the specification (what the server sends and to whom),
the wire registry (every message kind, generated from
chat-wire.json), CHAT-FEDERATION-DESIGN.md (why the trunks are
shaped as they are), CHAT-HISTORY-DESIGN.md, and the three manuals
beside this one.
1. The shape¶
machine A (FJELL, 100) machine B (VIDDA, 102)
+---------------------------------+ +---------------------------------+
| @CHAT @CHAT @CHAT | | @CHAT @CHAT |
| | | | XMSG port | | | | |
| +-------+-------+ to port | | +-------+ |
| | | | | |
| CHATSER (RT program, 1 bank) | trunk | CHATSER |
| *CHAT connection port |<========>| *CHAT |
| CHAT-TRUNK named port | CHAT- | CHAT-TRUNK |
| CHAT-ADMIN named port | TRUNK | CHAT-ADMIN |
| | | | | |
| @CHAT-MON (operator) | | @CHAT-MON |
| (RT)CHATH<n>:DATA history | | (RT)CHATH<n>:DATA |
+---------------------------------+ +---------------------------------+
| |
+---------------- trunk to SKOGEN (103) ------+
Three programs, one per role, all PLANC, all talking XMSG:
| Program | Runs as | Talks to | Source |
|---|---|---|---|
| CHATSER | an RT program on its own segment, no terminal, one bank (64 KW for code, data and stack) | its clients, its operator, its peer servers | CHATSV.PLNC (7,800 lines) + CHATLIB.PLNC |
| CHAT | an ordinary program a user runs; also dumped as reentrant NDCHAT on D102 |
its own machine's server only | CHAT.PLNC (6,400 lines) + CHATARR.PLNC (1,070) + CHATLIB.PLNC |
| CHAT-MON | an ordinary program the operator runs | its own machine's server, on the admin port | CHATMON.PLNC |
CHATLIB.PLNC is the message codec both sides IMPORT: the kind numbers, cmEnc/cmDec and
the field readers. It is compiled once and linked into all three, which is what keeps the two
ends of the wire agreeing - there is no second copy of the format to drift.
A client never talks to another client, and never to another machine's server. Everything between machines goes server to server over a trunk. That is the whole reason the server exists as a separate program: it is the one place that knows who is where.
2. The server¶
2.1 Three ports, and why three¶
XROUT, the XMSG name service, counts free connections against a connection port. Every seat spent there is one a human cannot have, and one leaked never comes back while the server lives. So the server opens three names and only one of them costs seats:
| Name | Kind | Reached by | Costs a seat |
|---|---|---|---|
*CHAT |
connection port | local clients - the Join is the only message addressed by name | yes, one per client |
CHAT-TRUNK |
ordinary named port | peer servers | no |
CHAT-ADMIN |
ordinary named port | CHAT-MON | no |
*CHAT offers 16 seats (maxSeats := 16, measured: 16 offered, 16 taken, the 17th join
refused with XMXEMFL). After the Join the client and server talk port to port, by the
address read off the arriving letter; XROUT is out of the picture and nothing else costs a seat.
2.2 The tables¶
The server has no heap and no allocation. Everything is a fixed array sized by a named constant:
| Table | Size | Constant | What it is |
|---|---|---|---|
| members | 16 | maxSeats |
name (16 chars, maxNameLen), room, the client's magic number, its build stamp (8 chars) |
| topic / room table | 8 | maxTopics |
one row per room that has a topic or a history; a room with neither has no row. Topic 64 chars (maxTopicLen) |
| history rings | 8 | maxHistRooms |
shares the topic table's index - the same row IS the room |
| peers | 8 | maxPeers |
one row per machine we trunk to: system number, name, state, timers |
| remote members | 32 | maxRemote |
people on other machines, as last reported by their server |
| seen relay ids | 64 | maxSeen |
(origin, line id) pairs, for dropping a line that arrives twice |
Rooms are derived, not stored. A room is the set of seats whose room field says so; it
exists while somebody is in it and gets a table row only when it acquires a topic or a history.
/list walks the seats.
2.3 The loop¶
The server is one loop with no threads. Each turn it drains the lobby port, then the admin
port, then the trunk port (maxDrain := 32 letters a turn, so one busy port cannot starve the
others), runs the periodic work (section 2.5), and sleeps on an XMSG wake-up armed on all three
ports. The wake-up is the "doorbell" - the sleep ends when a letter lands, not on a timer - with
a timer as the fallback so the periodic work still runs on a quiet machine. A run of twenty
consecutive empty wakes disarms the doorbell (maxBadRuns := 20), the guard against a stuck
flag spinning the server.
2.4 What it does with a letter¶
The specification's section 3 is the authority; the short form:
| Arrives | Server does |
|---|---|
| Join (by name, via XROUT) | takes a seat if the name is free in that room; answers Welcome (carrying the machine's name) or Reject; tells the room Joined; replays the room's history to the joiner; pushes the new member list to every peer that is up (tellMbrs) |
| Say | stamps the time, writes it to the room's history ring, broadcasts Said to everyone in the room including the speaker, and forwards it to every peer as TrunkSaid |
| Rename / Topic / Leave / Who / Rooms / Map / Direct | as the spec says; answers go to the asker, changes go to the room |
| a letter on the trunk port | section 3 |
| a letter on the admin port | CHAT-MON's command; answered with readable text |
The echo rule decides how a client is written: your own line comes back from the server with the server's time on it, and the client prints that, never its own copy. That is what gives everybody the same order.
2.5 Periodic work¶
Every turn, cheaply: the sweep looks at each seat's port through XFSEC and frees a seat
whose client has died, announcing it as Left; this is the only way a dead client is noticed
(bounce in STATUS counts messages that hit a dead seat first). The peer tick counts each
peer's silence: at 20 ticks (peerPingAt) it sends a keepalive hello, at 40 another, at 60
(peerDeadAfter) it declares the trunk down, announces that machine's members as Left, and
starts retrying on a doubling wait from 15 s to 300 s (peerFirstWait, peerMaxWait).
2.6 History¶
One file per room, (RT)CHATH<n>:DATA under the server's own user, one 512-byte block per
message (histWords := 256), written with a single MN120 - no read-modify-write. The ring is
block = id MOD histSize; a room keeps up to 200 lines (histDefault, maxHistSize), set per
room with INITIALIZE. A block carries a stamp word (histStamp := 27127) so a block written by
an older build without a time is still read correctly.
Replay to a joiner is one History letter per remembered line, oldest first, after the
Welcome. The ring's write pointer lives in memory only - after an RT-load the room starts
again from block 0, and old blocks are overwritten as it fills. Reading them back to recover the
pointer was tried and reverted: a CONTINUOUS file's unwritten blocks hold whatever was on the
disk before, and a scan counted garbage as messages.
2.7 The operator port¶
CHAT-MON is a thin program: it sends one admin kind per command to CHAT-ADMIN by name and
prints the text that comes back. STATUS is one line built by the server (buildStatus) from
its counters; nothing is computed on the operator's side. Section 2 of the admin manual reads
that line field by field.
3. The trunks¶
3.1 What a trunk is¶
A row in the peer table, and a belief. START-TRUNK 102 adds the row and sends a TrunkHello
to CHAT-TRUNK on system 102 by name; the peer adds its own row for us (unless it has
LOCK-TRUNKS set) and answers. From then on the two servers exchange:
| Kind | Carries | When |
|---|---|---|
TrunkHello (48) |
a direction byte and, since 2026-08-25, the machine's name | on start, on every retry, and as the keepalive |
TrunkWho (49) / TrunkMembers (50) |
nothing / NAME/ROOM NAME/ROOM ... |
TrunkWho is asked on every hello; TrunkMembers is the answer, and is ALSO pushed unasked to every peer that is up whenever a local member joins, leaves or renames (tellMbrs), because a list that only moved on hellos left a peer advertising a stale name for as long as it was watched. The receiver replaces that peer's rows wholesale, so a push is idempotent and self-healing |
TrunkSaid (51) |
a line one of my members said | on every Say |
TrunkRelayId (53) |
a line being passed on: origin system, hops left, and the line id the origin stamped | when a line arrives from a peer and I have other peers |
TrunkDirect (54) / TrunkDirectBad (55) |
a private message across one hop, and its refusal | on /tell D102!NAME |
One side is enough. A trunk started at either end brings both rows up. Both lab boot files start both anyway.
3.2 Flooding, relay, and exactly-once¶
A room line is flooded: every server forwards it to every peer except the one it came from,
with hopsLeft starting at maxHops - 1 (maxHops := 3 in CHATLIB) and decremented per hop.
On the D102 - D100 - D103 chain that reaches everybody; in the triangle (all three trunks up)
the same line arrives twice by two routes.
Two things keep that from looping or doubling:
- the hop count stops a line travelling for ever;
- the seen table stops it being shown twice: 64
(origin system, origin line id)pairs, and a repeat is dropped and counted asdupe. Proved on 2026-08-26 the only way it can be - on a triangle, one line typed,dupe 0 -> 1on the machine that got it both ways, and one copy on the screen.
The speaker is qualified by the origin, not by the neighbour - SYSTEM@SKOGEN on VIDDA
even though the letter came from FJELL - because the origin system number rides in the relay
header. A name is a label looked up from that number (learned on the hello); the number comes
from the sender's magic and cannot be forged.
3.3 What crosses a trunk and what does not¶
| Crosses | Does not cross |
|---|---|
| room lines (flooded) | a room's history - each machine keeps its own ring for a room of the same name |
| joins and leaves, as a member-table diff announced locally as Joined/Left | a topic (observed 2026-09-02; whether it should is undecided) |
/who (from the remote member tables) |
/list counts - local seats only |
| a direct message, one hop only - to a machine two hops away it is refused in words, on purpose: flooding a private message hands it to machines with no business seeing it |
3.4 Presence across machines¶
A peer's member table used to be replaced wholesale and in silence. Since 2026-09-01 the server
snapshots the old table, takes the new one, and announces the difference as ordinary Joined /
Left notices, so screens on this machine show remote arrivals and departures and n here
follows them. A rename on the other side therefore shows as new-name-joined, old-name-left. When
a peer goes down its members are announced as Left the same way.
4. The clients¶
4.1 The full-screen client¶
CHAT.PLNC is two halves: the send half (typing, commands, the main loop) and the
arrival half (CHATARR.PLNC: what to do with each kind that comes in), split so editing
one does not recompile the other.
It draws through VTM (PLANC-SCREEN-H, VTMR, VTMDATA, VTMARR, all linked into the
:PROG so it travels as one file). The rules it follows, all measured on the lab and written up
in Developer/Languages/Application/PLANC-INTERACTIVE-SCREEN-PATTERNS.md:
- poll the keyboard with
MON66(ISIZE) - the terminal cannot be put in no-wait mode (MON1 still blocks, unexplained), so the loop asks whether a byte is waiting and sleeps otherwise: 1 unit while lines are arriving, 10 units after 25 idle turns (sleepShort,sleepLong,idleRunsMax); - a poller flushes VTM itself -
VTWBUFis the flush, and a write that is not flushed looks exactly like a lost message; - paint the rows that differ, never the whole screen: every line is stored padded to full width, each row remembers which line number it shows, and a repaint compares line numbers (never ring slots, which are reused);
- a panel keeps its rectangle at module level so the room can be clipped behind it rather
than frozen.
/help,/who,/list,/mapand an asked-for/topicopen one; any key closes it and does nothing else.
4.2 Windows¶
Window 1 is the room; windows 2 to 4 (maxWins=4) are one-to-one conversations, one per
person, opened by the first Direct in either direction. They are alternative contents for one
rectangle, not overlapping windows - only one is painted, so there is no stack and no
clipping between them. All four share one ring: cache(0:23099), 300 lines (cacheLines) of
77 bytes, 75 per window (winLines). A room line always lands in window 1 whatever is being
looked at; the bar on the bottom row shows each window's unread count.
4.3 The line client¶
The same program on a printing terminal (type 2, or no VTM) is line by line: the same commands,
answers printed instead of panelled, private messages marked *(NAME). No windows.
4.4 State the client keeps¶
Almost none. Its nickname (CHAT:CNFG in the user's own area, written on every successful
/nick), the server's address (from the Welcome), the machine name (from the Welcome's text),
the room, and the ring. It does not know the system number - the server tells it its machine
name because a client cannot work that out for itself.
5. Limits¶
Designed in, and where each one comes from:
| Limit | Value | Where | Why |
|---|---|---|---|
| people per machine | 16 | maxSeats / *CHAT seats |
XROUT connection ports; a burst of 20 joins has taken XMSG down |
| nickname, room name | 16 chars | maxNameLen |
fits the 13-column speaker field on screen only up to 13 |
| topic | 64 chars | maxTopicLen |
|
| typed line | 80 | maxTyped |
one terminal line; wraps in the room display |
| rooms with topic or history per machine | 8 | maxTopics / maxHistRooms |
one table row each; more rooms than that exist but keep nothing |
| history per room | 200 lines, 512 bytes each = 100 KB on disk | maxHistSize |
STATUS shows disk nKB |
| peers (trunks) per machine | 8 | maxPeers |
|
| remote members remembered | 32 | maxRemote |
across all peers |
| relay hops | 3 | maxHops |
enough for any three-machine shape |
| duplicate memory | 64 lines | maxSeen |
a repeat arrives within a hop or two |
| windows per client | 4 (room + 3 conversations) | maxWins |
23 KB ring in a 64 KW bank |
| lines kept per window | 75 | winLines |
|
| direct message reach | one trunk hop | by design | privacy: no flooding of private text |
| a server's whole memory | one bank, 64 KW | RT loader | code, data and stack together; CHATSV:BRF is 35 KB |
| a client's program file | 108 KB, 53 pages | CHAT:PROG |
VTM inside |
And the ones that are consequences rather than choices:
- an RT-load orphans every client - a fresh segment is a fresh server with an empty seat table, and a client with no seat shows a normal screen and receives nothing;
- an XMSG restart needs an RT-load - a server that outlives it sends one byte offset;
- history does not survive a reload in position (2.6), and nothing is stored for a user who is away - there is deliberately no mailbox; a direct message to nobody is refused, not queued;
- no line editing beyond backspace, and no scrolling into the past on screen;
- names are per machine - two RONNYs on two machines are legal, and a direct message to a
bare
RONNYthat matches both is refused with both full names rather than guessed at.
6. Performance¶
All measured on the RetroCore lab machines (ND-100 emulation), which is the only place this runs; wall-clock numbers on real hardware would differ.
6.1 Latency of a said line¶
| Measurement | Result | When |
|---|---|---|
a bare client command answered locally (/list), floor |
112 ms | 2026-08 |
| a said line back on the speaker's own screen, floor | 514 ms before idle-sleep tuning, 483 ms after | 2026-08 |
| same, mean over a run | 683 ms before, 592 ms after | 2026-08 |
| what the remaining time is | the XMSG round trip itself, roughly 400 ms of the 480 | by subtraction |
The target was 500 ms and it is missed by about 10%. Sleep tuning has done what it can; the
lever left is to put fewer letters on the wire per message (CHAT-PLAN.md, latency section).
6.2 Across a trunk¶
Not measured as a number. What is measured: a line typed on D103 appears on D102 - two hops, through D100 - within the same one-second read of the screen used for the local case, and history replay of three lines to a joiner is not visibly slower than the join itself.
6.3 Throughput and load¶
- 16 seats, 4 users at once measured, load test of 2026-08-19: every seat accounted for by
SEATSand none leaked afterwards. - 20 simultaneous joins killed XMSG (the kernel, not the server); there is no queue and the fix is not to do that.
- A pull of the 467 KB server listing through the same XMSG kernel parked the send window
(
*** PARKED *** send window is full; 14 datagram(s) now waiting) - the kernel's window is small, and a chat that keeps its letters short lives comfortably inside it.
6.4 Sizes and times of the build¶
compile CHAT.PLNC + CHATARR.PLNC on D100 |
about 5 minutes (7,082 + 1,069 listing lines) |
compile CHATSV.PLNC |
about 5 minutes (8,439 listing lines) |
| link, either | seconds |
| push a 300 KB source through the sync daemon | about 1 minute |
| pull a 450 KB listing | 1 to 2 minutes |
| RT-load with name and trunks restored | about 1 minute (rt-load.ps1) |
| offline tests | CHATCTST 23 checks, CHATKT 49, CHATVT 48 - seconds each |
6.5 Memory¶
The server fits its one bank with room: CHATSV:BRF 35,303 bytes of object plus CHATLIB
2,220 and the three ND libraries, all under 64 KW. The client's biggest single object is the
23,100-byte line ring; the whole CHAT:PROG is 53 pages. Neither allocates at run time.
7. What is proved, and what is not¶
Proved on the machines: everything in sections 2 to 4 that carries a constant name or a date.
Not proved, and said so where it matters: topics across a trunk (3.3), a direct message across
a trunk in both directions (one direction on 2026-09-02, FJELL to VIDDA and back), the
install floppy mounting on a machine (admin manual 3.1), and the doorbell's disarm flag
clearing after a long idle (CHAT-PLAN.md). The open questions in the wire registry (C1 to
C4) are answered by this page's section 3: a trunk carries all rooms; a qualified name is
origin number plus a learned label; origin and hop count live in the relay header; a vanished
machine's members are freed by the peer tick at 60 ticks of silence.