Disassembled BTL4OPT.EXE StopMissionMessageHandler (@0x47b864): it turns the RIO egress lamps on (the "LightsOut"-named routine, flag=1) and schedules the lights-out/exit for now + 30.0*timebase + 0.5 (30.0f const @0x47b8e4). So the customer-egress window is a hardcoded ~30s timer in the GAME, not the console and not any INI/env value -- exactly matching the operator's memory (StopMission -> floor lights on -> ~30s hold -> lights off -> exit -> BAT restart). Answers "the delay must be somewhere": it is compiled in. Noted the reconcile-with-3.4s-measured task (the tapped 154.9s sweep was likely teardown; a 90s+ tap past mission end settles wall-time). Disasm helper: scratchpad btdis2.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
659 lines
37 KiB
Markdown
659 lines
37 KiB
Markdown
# Networking subsystem — recon & bring-up plan
|
||
|
||
Goal: bring the pod network up under the emulator so a pod boots the
|
||
production way (`netnub -f BTL4OPT`, mission egg delivered **over the
|
||
wire**) instead of the `-egg test.egg` dev bypass. This is also the
|
||
substrate the ops-console port will plug into.
|
||
|
||
## What the pod network actually is (recon 2026-07-04)
|
||
|
||
The stack, bottom to top:
|
||
|
||
1. **Ethernet NIC + Novell ODI** (AUTOEXEC.BAT): `lsl` (Link Support
|
||
Layer) → `lnepci` (Lance/PCnet PCI ODI driver) → **`odipkt`** (Dan
|
||
Lanciani's ODI→Packet-Driver shim). The ODI/Netware login side
|
||
(IPXODI/VLM/NET LOGIN in STARTNET.BAT) is for file-server access; the
|
||
game itself only needs the **packet-driver interface** odipkt exposes.
|
||
`NET.CFG`: LNEPCI, FRAME Ethernet_II + 802.2.
|
||
2. **WATTCP** — the TCP/IP stack. Confirmed by `WATTCP.CFG` in
|
||
`REL410/BT`, `REL410/RP`, and per-pod `VGL_LABS/THISPOD`:
|
||
```
|
||
my_ip = 200.0.0.113 netmask = 255.255.255.0
|
||
gateway = 200.0.0.1 nameserver = 200.0.0.1
|
||
```
|
||
So each pod is a static host on an isolated **200.0.0.0/24** LAN; the
|
||
ops console is almost certainly **200.0.0.1**.
|
||
3. **NetNub** (`netnub.exe`, real-mode) — launches the game as a child
|
||
(`netnub -f BTL4OPT`) and is the network server for the
|
||
protected-mode game. Interface (`NetNub/NETNUB.HPP`): a shared `Netcom`
|
||
struct (version 11, 64KB buffer) + a software interrupt. The game sets
|
||
a Function code (TCP_OPEN=3, TCP_LISTEN=4, TCP_CLOSE=5,
|
||
RESOLVE_ADDRESS=6, CHECK_SOCKET=7, UDP_*, plus remote file
|
||
OPEN/READ/WRITE/SEEK/CLOSE 12-19), copies the marked fields to real
|
||
mode, INTs, copies back. `tcp_Socket` is ~4300 bytes = classic WATTCP.
|
||
4. **L4NetworkManager** (`L4NET.HPP/.TCP`) — the game's net brick. The
|
||
**console is master and connects to the pods**; the pod receives
|
||
`ReceiveEggFileMessage` (the mission egg), replies
|
||
`AcknowledgeEggFileMessage` ("connected, ready, send the next host"),
|
||
and tracks `HostConnected/HostDisconnected`. If the console drops, the
|
||
pod is built to auto-start anyway.
|
||
|
||
Topology to replicate:
|
||
```
|
||
[ops console 200.0.0.1] --TCP--> [pod 200.0.0.113] (+ more pods .114..)
|
||
(master, egg source) (listens, ACKs, runs mission)
|
||
```
|
||
|
||
## Emulator enablers (already in the fork)
|
||
|
||
- **NE2000** ISA NIC emulated (`hardware/ne2000.cpp`, Bochs-derived);
|
||
config `[ne2000] ne2000=true, nicbase=, nicirq=, macaddr=, backend=`.
|
||
- **Two Ethernet backends** built: `misc/ethernet_pcap.cpp` (bridge to a
|
||
host NIC via npcap) and `misc/ethernet_slirp.cpp` (user-mode virtual
|
||
net / NAT). Plus `ethernet_nothing`.
|
||
|
||
Key simplification: the emulated card is an **NE2000, not a PCI Lance**, so
|
||
`lnepci` won't bind. We don't need the Novell ODI chain at all — WATTCP
|
||
finds a packet driver by scanning INT 0x60-0x80 for the `PKT DRVR`
|
||
signature, so we load a generic **NE2000 packet driver** (Crynwr
|
||
`NE2000.COM`) directly against the emulated card's base/IRQ. That drops
|
||
lsl/lnepci/odipkt/VLM entirely and hands NetNub/WATTCP the packet
|
||
interface they expect.
|
||
|
||
## Bring-up plan
|
||
|
||
**Backend choice.** Two viable paths:
|
||
- *pcap + host-only adapter (recommended, matches real topology):* bridge
|
||
the NE2000 to a host virtual switch; run the pod at 200.0.0.113 and the
|
||
stand-in console at 200.0.0.1 on that segment. WATTCP's static IP + LAN
|
||
assumptions hold exactly; the console connects inbound to the pod with
|
||
no NAT. Cost: npcap + a host-only/loopback adapter + admin.
|
||
- *slirp (fallback, self-contained):* no host NIC/admin, but it's NAT and
|
||
defaults to 10.0.2.0/24 — the pod LISTENS, so inbound needs slirp
|
||
host-forwarding and a guest-network/IP reconciliation with WATTCP's
|
||
hard-coded 200.0.0.113. Investigate whether DOSBox-X slirp allows the
|
||
custom net + static guest IP + inbound forward cleanly.
|
||
|
||
**Milestones**
|
||
1. **NIC up**: `[ne2000]` on, NE2000.COM packet driver loaded, WATTCP/
|
||
NetNub start clean; pod boots via `netnub -f BTL4OPT` (no `-egg`) and
|
||
sits waiting for the console. Verify NetNub reports net address
|
||
200.0.0.113 and a TCP_LISTEN is queued. (New scratch conf, mirror the
|
||
RIO/sound conf pattern.)
|
||
2. **L3/L4 reachability**: from the host segment, confirm the pod answers
|
||
ARP/ping at 200.0.0.113 and a raw TCP connect to its listen port
|
||
completes (proves NE2000↔backend↔host path end-to-end).
|
||
3. **Decode the console→pod egg protocol**: `NetworkPacketHeader` +
|
||
message framing from `network.hpp`/`hostmgr.hpp` + the
|
||
ReceiveEggFileMessage layout, cross-checked with a live capture of the
|
||
pod's listen/ACK. (Pin the listen port here — not yet found in source;
|
||
grep NETNUB.EXE strings / capture.)
|
||
4. **Eggs over the wire**: a minimal host-side **stand-in console**
|
||
(Python) connects to the pod, pushes a mission egg, handles the ACK →
|
||
pod runs the mission with no `-egg` bypass. **This is the headline
|
||
goal.**
|
||
5. *(later, joins the console-port workstream):* replace the stand-in
|
||
with the ported/emulated Mac ops console; multi-pod coordination
|
||
(HostConnected/Disconnected, mission review, camera ship).
|
||
|
||
## Milestone 1 — DONE (2026-07-04): pod boots on the network path
|
||
|
||
Verified end to end under DOSBox-X (slirp backend), no `-egg` bypass:
|
||
- `[ne2000] ne2000=true, nicbase=300, nicirq=3, backend=slirp` → NE2000
|
||
emulated at Base=0x300 irq=3; slirp 4.9.1 initialized.
|
||
- **The Novell ODI chain works against the emulated NE2000**, no external
|
||
packet driver needed: `lsl` → `ne2000` (Novell/Eagle NE2000 MLID v1.53,
|
||
from NWCLIENT) → `odipkt` (FTP Software ODI packet driver). ODIPKT
|
||
installed at **SINT 0x60**, MLID NE2000, MAC CE:3D:72:67:38:69, frames
|
||
Ethernet_II (board 1) + 802.2 (board 2).
|
||
- GOTCHA: the ODI tools read `NET.CFG` from the directory the `.COM` loads
|
||
*from*, and the stock `NWCLIENT\NET.CFG` says `Link Driver LNEPCI` — with
|
||
no NE2000 section the MLID defaults to 802.2-only and odipkt fails
|
||
("An MLID could not be found"). Fix without touching ALPHA_1: keep an
|
||
emulator `NET.CFG` (`Link Driver NE2000` + `FRAME Ethernet_II`) beside
|
||
copies of lsl/ne2000/odipkt on a scratch drive and load from there.
|
||
- `netnub -f btl4opt` (no egg) launches the game as `btl4opt -net 250224`,
|
||
sets up the game↔netnub channel at **INT 0x61** (separate from odipkt's
|
||
0x60), initializes the network manager ("Changing blocking from 0 to 1"),
|
||
and the game boots through the VPX handshake to an open (blank) render
|
||
window — **waiting for a console to deliver a mission egg.**
|
||
|
||
Working scratch files: `scratchpad/net_stageB.conf`,
|
||
`scratchpad/net/{NET.CFG,LSL.COM,NE2000.COM,ODIPKT.COM}`. Launch env:
|
||
VPXLOG + VPX_RESPOND=1 + VPX_RENDER=1 (VPX board must answer or the game
|
||
exits before networking).
|
||
|
||
## Console side: a Mac emulator stands in for the ops console (user, 2026-07-04)
|
||
|
||
The user is building a **Mac emulator running the real 410console** as the
|
||
console peer (instead of a from-scratch Python stand-in). This merges the
|
||
networking and console-port workstreams: the real console software will
|
||
connect to the pod and push eggs. Implication for topology — two separate
|
||
emulators (DOSBox pod + Mac console) must share an L2 segment, which slirp
|
||
(NAT, per-process isolation) cannot bridge. **Plan: move the pod's NE2000
|
||
to `backend=pcap` on a host-only/loopback adapter; bridge the Mac emulator
|
||
to the same adapter; pod=200.0.0.113, console=200.0.0.1 on 200.0.0.0/24.**
|
||
Then milestone 3 (protocol) can be captured live from the real console
|
||
traffic rather than reverse-engineered blind.
|
||
|
||
## Console emulator = SheepShaver (2026-07-04)
|
||
|
||
The real ops console is a **Power Macintosh 6100/66** (PowerPC 601) →
|
||
emulate with **SheepShaver** (PPC Mac, Mac OS 7.5.2–9.0.4). Basilisk II
|
||
(68k) is out. GOTCHA: the 6100's OWN ROM does NOT work in SheepShaver
|
||
("Unsupported ROM type" — SheepShaver emulates a PCI 9500; the 6100 is
|
||
NuBus). Use a compatible old-world PPC ROM instead (7100/66, 7500, 7600,
|
||
or 8500) — PPC apps are Toolbox/OS-based, not ROM-specific, so the
|
||
410console app runs regardless. Target Mac OS 7.5.5–7.6.1 (console era).
|
||
Networking: SheepShaver TAP ↔ DOSBox-X NE2000 pcap, both bridged to a
|
||
host adapter on 200.0.0.0/24 (console .1, pod .113).
|
||
|
||
**Console 4.10 OS compatibility (from the binary, 2026-07-04):** classic
|
||
PowerPC PowerPlant/CodeWarrior app; PEF imports are only `InterfaceLib`,
|
||
`MathLib`, `ObjectSupportLib`. NO `AppearanceLib` (⇒ does NOT need Mac OS
|
||
8) and NO Open Transport libs (⇒ classic **MacTCP API**). Resource fork
|
||
has the startup check "TCP/IP not installed. Install either MacTCP or
|
||
OpenTransport…" via Gestalt/SysEnvirons — so it accepts either stack.
|
||
→ **Runs System 7.1.2–Mac OS 9.0.4; practical floor 7.5** (ObjectSupportLib
|
||
ships with 7.5). **Recommended SheepShaver target: System 7.5.5 / 7.6.1**
|
||
(period-correct for a 6100, includes Open Transport 1.1.x so the TCP/IP
|
||
Gestalt check passes, most stable under SheepShaver w/ a 7100/7500/8500
|
||
old-world ROM). Set TCP/IP control panel to 200.0.0.1.
|
||
|
||
**Leverage the real 6100 (user has it):** (1) image its hard drive → get
|
||
the exact console software + OS + MacTCP/OpenTransport config → drop into
|
||
SheepShaver for a faithful reproducible console; (2) fastest path to a
|
||
first egg + LIVE protocol capture = put the real 6100 on a physical
|
||
Ethernet with the pcap-bridged pod (needs an AAUI→RJ45 transceiver) and
|
||
capture the console→pod egg exchange off the wire (hands us milestone 3).
|
||
Sequence: real 6100 first (seeds image + capture) → SheepShaver as the
|
||
archival console built from that image.
|
||
|
||
## Console software EXTRACTED + protocol port FOUND (2026-07-04)
|
||
|
||
The user unstuffed `410consoleArchive.sit` (via infinitemac.org) to
|
||
`4_10extractedConsole/`. Contents: **`Console 4.10`** (the app), per-site
|
||
config, fonts, logs. This means the dead 6100 is NOT a blocker — we have
|
||
the console software directly; run it in SheepShaver (no disk image
|
||
needed; fresh Mac OS 7.6.1/8.1 + MacTCP set to 200.0.0.1).
|
||
|
||
- **App is PowerPC** — data fork magic `Joy!peffpwpc` (PEF/PowerPC),
|
||
Metrowerks CodeWarrior 1993-95; 3.4MB resource fork. Confirms
|
||
SheepShaver (needs standard shared libs: InterfaceLib/MathLib, present
|
||
in any 7.5+ install).
|
||
- **`Console.ini`** is the master config: `[NetworkEndpoint::Cockpit::*]`
|
||
sections define every pod. **THE TCP PORT IS 1501** (`defaultPort` /
|
||
`localHostPort`) — this answers the milestone-3 "listen port unknown"
|
||
question. Console connects to each pod IP:1501; pod LISTENS on 1501.
|
||
|
||
Cockpit endpoint roster (base Console.ini) — our emulated pod = **"Puck"
|
||
200.0.0.113**:
|
||
| cockpit | IP | hostType |
|
||
|---|---|---|
|
||
| Frequent Flyer | 200.0.0.11 (sic) | 0 |
|
||
| Privateer | 200.0.0.112 | 0 |
|
||
| **Puck** | **200.0.0.113** | 0 |
|
||
| Carpe Diem | 200.0.0.114 | 0 |
|
||
| Man O' War | 200.0.0.115 | 0 |
|
||
| Divine Wind | 200.0.0.116 | 0 |
|
||
| Icarus | 200.0.0.117 | 0 |
|
||
| Gypsy | 200.0.0.118 | 0 |
|
||
| Alpha Mission Review | 200.0.0.119 | 2 |
|
||
| Alpha Camera | 200.0.0.120 | 2 |
|
||
|
||
hostType 0 = playable cockpit, 2 = special (mission-review / camera ship).
|
||
`ini Folder/` holds real per-venue configs (DBAtlanta/Chicago/Houston/
|
||
Toronto/LaZerPark/... — the actual VWE centers), same .11x/1501 scheme.
|
||
|
||
Revised topology: SheepShaver console @200.0.0.1 → TCP 200.0.0.113:1501 →
|
||
pod "Puck". Remaining protocol unknown is just the on-stream message
|
||
framing (NetworkPacketHeader + ReceiveEggFileMessage) — capture it once
|
||
the console connects, or read it from network.hpp.
|
||
|
||
## Egg-delivery protocol — decoded from source (2026-07-04)
|
||
|
||
Decoded from `CODE/RP/MUNGA/{NETWORK,RECEIVER,HOSTID}.HPP` +
|
||
`MUNGA_L4/L4NET.HPP`. The real send/receive *implementation* (framing on
|
||
the byte stream) did NOT survive in the archive — only headers + a test
|
||
harness (`L4NET.TCP`'s `TestClass`, `#if 0`). So the logical message
|
||
layout below is solid; two low-level details (stream framing + endianness)
|
||
need a live capture or a binary disasm to pin — see caveats.
|
||
|
||
**Transport:** console → TCP connect to pod IP : **1501** → the pod
|
||
(NetNub `TCP_LISTEN`) accepts. All base types are 32-bit
|
||
(`Enumeration=int`, `size_t`, `LWord`, `Time::ticks=long` → 4 bytes each).
|
||
|
||
**On-wire unit = NetworkPacket = NetworkPacketHeader + a Receiver::Message.**
|
||
|
||
NetworkPacketHeader (16 bytes):
|
||
| off | field | type |
|
||
|--|--|--|
|
||
| 0 | clientID | ClientID enum (0=NetworkMgr,2=HostMgr,5=Console...) |
|
||
| 4 | gameID | Enumeration |
|
||
| 8 | fromHost | HostID (Enumeration) |
|
||
| 12 | timeStamp | Time (long ticks) |
|
||
|
||
Receiver::Message header (12 bytes) that every message starts with:
|
||
| off | field | type |
|
||
|--|--|--|
|
||
| 0 | messageLength | size_t (= sizeof the whole message) |
|
||
| 4 | messageID | Enumeration (ReceiveEggFileMessageID etc.) |
|
||
| 8 | messageFlags | LWord (bit0 ReliableFlag=1) |
|
||
|
||
**ReceiveEggFileMessage** (the egg carrier; messageLength = 1024):
|
||
| off | field | type |
|
||
|--|--|--|
|
||
| 0 | (Receiver::Message header) | 12 B |
|
||
| 12 | sequenceNumber | int (chunk index) |
|
||
| 16 | notationFileLength | int (total egg size) |
|
||
| 20 | thisMessageLength | int (bytes valid in this chunk, ≤1000) |
|
||
| 24 | notationData[1000] | char (the egg chunk) |
|
||
|
||
So a full egg packet on the wire = **16 (header) + 1024 (message) = 1040
|
||
bytes** — matches the `nb≤1040` payload cap seen on the VPX/iserver link.
|
||
**Egg-delivery algorithm:** the console splits the mission egg (a text
|
||
"notation file", same INI/notation format as Console.ini) into
|
||
ceil(len/1000) chunks; sends each as a ReceiveEggFileMessage with
|
||
sequenceNumber 0..N, notationFileLength=total, thisMessageLength≤1000. The
|
||
pod's `ReceiveEggFileMessageHandler` reassembles into `eggTempBuffer` by
|
||
sequence, and when `notationFileLength` bytes have arrived, parses it as
|
||
the mission notation file. Pod replies `AcknowledgeEggFileMessage`
|
||
("connected, ready, next host"). `messageID` values start at
|
||
`NetworkClient::NextMessageID`; ReceiveEggFileMessageID is the first
|
||
NetworkManager message ID.
|
||
|
||
**Caveats to confirm with the first live capture (or a binary disasm of
|
||
Console 4.10 send / BTL4OPT receive):**
|
||
1. *Stream framing:* whether each 1040-B NetworkPacket is one discrete
|
||
NetNub/WATTCP record, or the receiver frames within the TCP stream via
|
||
the leading `messageLength`. (NetNub `RECEIVE_PACKET` returns up to
|
||
1600 B; MAX aligns with one packet.)
|
||
2. *Endianness:* console is **big-endian PPC**, pod is **little-endian
|
||
x86** — the multi-byte header/length ints must be byte-swapped by one
|
||
side (or sent in network order). `notationData` (egg text) is
|
||
endian-agnostic. The capture will show which order the length fields
|
||
use; a stand-in sender must match it.
|
||
|
||
Everything needed to PARSE a capture and BUILD a stand-in egg-sender is
|
||
here except those two bytes-on-the-wire details, which the console-connect
|
||
milestone resolves immediately.
|
||
|
||
## Console VM standup (user, 2026-07-04): SheepShaver + OS 9.0.4 + old-world ROM
|
||
|
||
Valid combo (old-world ROMs support the full 7.5.2–9.0.4 range); OS 9's
|
||
Open Transport provides the MacTCP-API compat the app's Gestalt check
|
||
wants, so Console 4.10 runs. Networking initially set to "Basilisk II
|
||
Router" — NOTE that's **NAT** (assigns a 10.x IP, routes to host), fine
|
||
for building/updating the VM but it CANNOT reach the pod at 200.0.0.113.
|
||
For the console↔pod link both must be **bridged (TAP)** on one L2 segment:
|
||
console SheepShaver TCP/IP = manual 200.0.0.1 / 255.255.255.0 on a TAP;
|
||
pod DOSBox-X moves off `backend=slirp` to **`backend=pcap`** bound to the
|
||
same TAP/bridge (200.0.0.113 already via WATTCP). Then the console's
|
||
outbound TCP to 200.0.0.113:1501 reaches the pod and we capture the egg
|
||
exchange (resolves the framing + endianness caveats above).
|
||
|
||
## MILESTONE: real console <-> real pod talking over the wire (2026-07-05)
|
||
|
||
The emulated SheepShaver console and the emulated DOSBox pod now exchange
|
||
the live console protocol on TCP 1501. Hard-won setup (all required):
|
||
|
||
1. **DOSBox-X pcap backend had to be rebuilt + npcap DLL path.** config.h
|
||
had `C_PCAP 1` but the stale `ethernet.o` predated it; force-recompile
|
||
(`rm src/misc/ethernet.o ethernet_pcap.o; make`). Runtime: npcap installs
|
||
its DLLs in `C:\Windows\System32\Npcap\` (npcap-only mode), NOT System32,
|
||
so DOSBox couldn't load wpcap.dll -> **launch dosbox-x.exe with
|
||
`C:\Windows\System32\Npcap` prepended to PATH.**
|
||
2. **Two-TAP Windows bridge** (single shared TAP fails: SheepShaver holds the
|
||
user-mode handle, DOSBox rides NDIS, frames don't cross). Console
|
||
SheepShaver -> TAP1 (`ether tap` + etherguid); pod -> TAP2; both bridged.
|
||
3. **TAP2 media status = Always-Connected** (registry MediaStatus=1 on the
|
||
TAP2 class key, needs elevation; the GUI toggle didn't persist). Without
|
||
it TAP2 reports "unplugged" and the bridge won't forward to it.
|
||
4. **Pod pcap binds to the BRIDGE MINIPORT, not a member TAP.** Injecting on
|
||
a bridge *member* (TAP2) isn't re-forwarded by the bridge (the pod TX'd
|
||
but the console never saw it). Bind DOSBox `realnic` to the bridge
|
||
miniport = "Microsoft Network Adapter Multiplexor Driver". GOTCHA: DOSBox
|
||
`realnic` matches a substring of the pcap device NAME (`\Device\NPF_{GUID}`)
|
||
and parses a *leading-digit* value as an interface index -- the bridge
|
||
GUID `5DB5521D...` starts with 5 -> picked iface #5 (Bluetooth!). Use a
|
||
letter-leading fragment: `realnic=DB5521D`.
|
||
5. Diagnostics: added `PCAP TX/RX` counters to `ethernet_pcap.cpp`
|
||
(SendPacket/GetPackets) -- confirmed the pod WAS transmitting all along;
|
||
the bridge forwarding was the only gap. Capture the wire with
|
||
`dumpcap -i <bridge#> -f "arp or tcp port 1501" -w cap.pcapng`.
|
||
|
||
**Verified protocol on the wire (little-endian, matches the source decode):**
|
||
- Console->pod **StateQuery** (32B): hdr clientID=4(App) gameID=0 fromHost=1
|
||
ts=.. ; msg len=16 id=3 flags=0 ; body requestingHost=1.
|
||
- Pod->console **StateResponse** (40B): hdr **clientID=5 (ConsoleClientID)**
|
||
gameID=0 fromHost=0 ts=.. ; msg len=24 id=1 **flags=1 (Reliable)** ; body
|
||
respondingHostID=0, **applicationState=1**, application=1 (BTL4/BattleTech).
|
||
(My earlier synthetic guess had clientID=4/flags=0/host=1/state=0 -- all
|
||
wrong, which is why the real console rejected the stand-in. These are the
|
||
correct values for pod_responder.py.)
|
||
- Console polls StateQuery ~every 2s; pod ACKs + StateResponse each time.
|
||
Clean 3-way handshake; zero-length "dup ACKs" are benign keepalives.
|
||
|
||
OPEN: with this working, the console still hadn't enabled mission-send in
|
||
the stripped-down (keyboard-only, no-RIO, no-sound) pod boot -- retrying
|
||
with a full RIO+sound boot (`net_full.conf`) so the pod presents its normal
|
||
state (applicationState may change when fully booted). Need the console UI
|
||
readout to know what state it wants before it will send the mission egg.
|
||
|
||
## EGG DELIVERED OVER THE WIRE (2026-07-05) — headline goal achieved
|
||
|
||
The real SheepShaver console sent a real mission egg to the real DOSBox pod
|
||
over the emulated network, captured + reassembled byte-perfect:
|
||
- Console -> pod, TCP 1501, **8x ReceiveEggFileMessage chunks** (1040-B
|
||
NetworkPackets each = 16 hdr + 1024 msg), **7514 bytes total**, declared
|
||
notationFileLength=7514 (exact match). Reassembled with
|
||
`net-tools/decode_egg.py`; saved `net-tools/captured_cavern_mission.egg`.
|
||
- The egg is a text NOTATION file (same format as Console.ini). Contents =
|
||
the operator's actual mission: `[mission] adventure=BattleTech map=cavern
|
||
scenario=freeforall time=night weather=clear temperature=27 length=120`;
|
||
`[pilots] pilot=200.0.0.113`; `[200.0.0.113] name=cyd vehicle=madcat
|
||
experience=veteran badge=VGL dropzone=one color=Grey advancedDamage=1
|
||
role=Role::Default` + embedded pilot badge bitmaps + ordinal fonts.
|
||
- Confirms the whole decoded protocol end to end: StateQuery/StateResponse
|
||
poll, then egg chunks, pod ACKs, no `-egg` bypass. **Networking DONE.**
|
||
|
||
**BUT the pod CRASHES loading the mission** (game-side, NOT network):
|
||
`nn.log` shows `Reference to a page you don't own / PF cr2=FF008B5B /
|
||
Unhandled exception 000E at 00FF:219D ErrCode 0004 / NETNUB Munga exited
|
||
code 14` -- a page fault (wild pointer 0xFF008B5B) in the mission-load path
|
||
right after the egg arrives. Suspects: (a) RIO was DISABLED for this run
|
||
(net_pcap.conf) and the mission sets up the player's vehicle (madcat) /
|
||
controls, which may deref a null RIO/control struct; (b) missing cavern
|
||
content or a pilot-config/badge path bug. Note: cavern/night via `-egg
|
||
test.egg` with RIO ENABLED rendered fine earlier this session, so RIO-off
|
||
is the leading suspect. Real pods run RIO-on headless (no focus stealing),
|
||
so this may not reproduce there. NEXT: retry with RIO enabled + DOSBox
|
||
`priority=highest,highest` (so clicking the console can't starve the RIO's
|
||
ACK deadline during load); if it still faults, disassemble around game
|
||
code 00FF:219D. This joins the Division-renderer/crash workstream.
|
||
|
||
## FULL NETWORKED MISSION WORKS END TO END (2026-07-05, later) — all fixed
|
||
|
||
A complete networked mission now runs start to finish: console queues a
|
||
mission -> egg over the wire -> pod loads it -> RIO live -> **all cockpit
|
||
heads render** -> mission ends on the console timer. Four fixes got here:
|
||
|
||
1. **Mission-load crash was RIO-OFF** (confirmed). Booting with the RIO
|
||
enabled (`net_full.conf`: serial1 COM1 rxpollus:100 rxburst:16, full
|
||
RIO+sound production boot) — the page fault at 00FF:219D is gone and the
|
||
mission loads + runs. Real pods run RIO-on, so this was the whole thing.
|
||
|
||
2. **NE2000 `BX_PANIC` fixed in the emulator.** A full production boot runs
|
||
the packet driver's internal loopback self-test, which sets the NIC's TCR
|
||
inhibit-CRC bit; Bochs' `ne2000.cpp` `BX_PANIC`'d and aborted the whole
|
||
emulator. Patched the TCR write handler to record crc_disable/ext_stoptx
|
||
instead of panicking (harmless for an emulated NIC; pcap/host frames+CRCs).
|
||
Committed copy: `vpx-device/ne2000.cpp`.
|
||
|
||
3. **Pod persistence via GO.BAT loop.** When the console has no mission
|
||
queued, the pod connects, sees nothing, and cleanly exits (not a crash) —
|
||
the real pod's GO.BAT immediately relaunches netnub. `net_loop.conf` +
|
||
`net-boot/loop.bat` replicate that so the pod stays connected and ready.
|
||
|
||
4. **HEADLINE FIX — blank cockpit heads = NE2000/VDB I/O port collision.**
|
||
All 5 MFD + radar heads decoded to pure black during a live mission even
|
||
though the DOSBox SVGA gauge framebuffer was FULL (captured mid-mission:
|
||
SENSOR CLUSTER/MYOMERS/SRM 4/mech wireframes/pilot name). Root cause: the
|
||
VDB video splitter board is hardwired at **0x300-0x31A** (palettes
|
||
0x300/0x308/0x310; `VDB_BASE` in vpxlog.cpp) and we'd put the NE2000 at
|
||
`nicbase=300` — the NIC swallowed the game's VDB palette writes, so
|
||
`vdb_pal` stayed zero and `pal_draw` mapped every index to black. The VDB
|
||
spam into 0x300-0x31A also corrupted NIC registers, dropping the console's
|
||
EndMission -> **the mission overran its timer** (second symptom, same
|
||
cause). FIX (config-only): move the NIC to **0x340** in the DOSBox conf
|
||
(`nicbase=340`) AND the DOS `NET.CFG` (`PORT 340`) — both must agree; the
|
||
game uses odipkt so it's base-agnostic. VDB keeps 0x300 (game hardwires
|
||
it; real pod's NIC lived elsewhere for this reason). Non-networked gauge
|
||
runs never hit this (no NIC). Verified: heads render perfectly (radar with
|
||
contact blips + SPEED/HEADING/ARMOR + mission clock; MFDs full) AND the
|
||
mission ends cleanly on the timer (`Sending EndMission`, score 1000).
|
||
Diagnostic recipe: `New-Item <VPX_DUMPDIR>\DUMP` mid-mission dumps
|
||
win0/3/4.bmp; PrintWindow (flag 2, GL) the DOSBox SVGA window to prove the
|
||
framebuffer has content; framebuffer-full + heads-black => palette/port.
|
||
|
||
Committed: `net_full.conf`, `net_loop.conf`, `net-boot/` (drivers, NET.CFG
|
||
@340, loop.bat, README), `vpx-device/ne2000.cpp`, `vpx-device/ethernet_pcap.cpp`.
|
||
|
||
## Post-reboot outage forensics + IRQ 3 conflict (2026-07-08/09)
|
||
|
||
A host reboot took the console<->pod link down for hours; every layer got
|
||
blamed before the real bug fell out. Findings, in the order they mattered:
|
||
|
||
- **TAP-Windows V9 "Bytes received" is ALWAYS 0** -- the driver never counts
|
||
frames its user-mode app (SheepShaver) injects. Do not diagnose from the
|
||
adapter-status dialogs: read the bridge-miniport counters (they do count)
|
||
or sniff the wire (Npcap + ctypes; see render-bridge/probes/ for the
|
||
pattern). Bridge/TAPs/SheepShaver prefs were all healthy the whole time.
|
||
- **`NO PACKET DRIVER FOUND` at netnub = wpcap.dll not on PATH** of the shell
|
||
that launched DOSBox. launch_pod.ps1 now prepends
|
||
`C:\Windows\System32\Npcap` itself so no shell can reproduce this.
|
||
- **THE REAL BUG -- IRQ 3 double-booking:** the plasma readout (serial2 =
|
||
host COM2, added 2026-07-07) sits on IRQ 3, and the NE2000 was also on
|
||
`nicirq=3`. The netnub phase works (COM2 not yet open: ARP answered, egg
|
||
flows) -- then BTL4OPT opens the plasma port (`arg4=p`) and NIC RX dies
|
||
for good. Looks exactly like a mid-boot wedge. Fix: **NIC on IRQ 10** in
|
||
net_full.conf / net_diag.conf / net_loop.conf AND net-boot/NET.CFG
|
||
(`INT 10`) -- DOSBox device and ODI driver must agree. Verified: pod
|
||
stayed ARP-responsive through two full missions with plasma live.
|
||
- **Console's live IP is 200.0.0.10** (not .1 as assumed from the gateway
|
||
entry); it ARP-scans the cockpit roster slots (.11, .112-.120)
|
||
continuously while looking for pods.
|
||
- **Zombie console session between missions:** netnub exits after each
|
||
mission without closing TCP (no FIN -- the DOS stack just vanishes).
|
||
Console 4.10 keeps waiting on the dead socket; with the pod sitting at a
|
||
DOS pause for minutes, classic Mac TCP retransmit backoff grows to
|
||
multi-minute intervals and the console looks permanently wedged (silent
|
||
on the wire). Operator fix: relaunch the Console app (app only). Real
|
||
fix: behave like retail -- GO.BAT relaunched netnub within seconds, so
|
||
the console's early retransmissions hit the fresh stack and recover.
|
||
net_loop.conf (now FULL production parity: sound + plasma + IRQ 10) is
|
||
the conf to use for multi-mission sessions; verify console self-recovery
|
||
with it next time.
|
||
- **SoundFont upload measured (smldW device counters):** the game blindly
|
||
re-uploads the full SBK on EVERY netnub->game launch -- counter goes
|
||
3,513,581 -> exactly 7,027,162 per card, no validation read-back, so
|
||
pre-loading the cards cannot short-circuit it. BUT the re-upload itself
|
||
is only ~20-30s; the slow first boot is dominated by something else
|
||
(pacer question still open). Loading a new egg into the already-running
|
||
game does NOT re-upload.
|
||
- Manual mission-N start in the same DOSBox: `32rtm.exe -x` (saying
|
||
"resident" is fine) then `netnub -p -f btl4opt > nn.log`; TSRs and SET
|
||
env persist from the autoexec.
|
||
|
||
## Console 4.10 between-missions behavior — characterized (2026-07-09 session 2)
|
||
|
||
Live A/B runs with the wire watcher (net_loop.conf pod, three missions):
|
||
|
||
- **net_loop.conf VERIFIED: the pod is fully retail-faithful.** netnub
|
||
auto-relaunches after mission end AND after a console disconnect (both
|
||
observed); full cycle back to ready ~60s including the ~30s SBK
|
||
re-upload. Four unattended relaunches in one evening, zero touch.
|
||
- **Console 4.10 endpoint dead-end — precisely characterized:** after a
|
||
mission ends, the console's TCP retries its old session, the fresh
|
||
netnub answers FIN then RST (so the app's stack KNOWS the session died
|
||
and a live pod is at ready) -- and the app never issues another query.
|
||
Clicking around the UI (new mission, network toggles) does NOT re-arm
|
||
it; only relaunching the app does (endpoints rebuild from the INI /
|
||
stationery at launch). Stationary.ini = the master stationery: full
|
||
26-cockpit roster with per-endpoint params (openTimeout=10,
|
||
queryTimeout=60, loadQueryInterval=1, inProgressQueryInterval=10).
|
||
- **PRINTER: select one.** With no Chooser selection the app stalls on
|
||
Printing Manager calls (much slower launch; the old 'nilP' throw at
|
||
BTFAConsoleDoc.cp:117 is doc-init printing). A serial printer spooled
|
||
to file makes the app launch much faster. It does NOT fix the
|
||
mission-end dead-end (no print job is even attempted at mission end --
|
||
spool stays empty).
|
||
- **Operational rhythm that works:** fly mission -> pod recycles itself
|
||
-> relaunch the Console app (fast with a printer selected) -> select
|
||
pod -> ready. Under two minutes of operator time per turnaround.
|
||
Deep fix would be PPC reverse-engineering of the endpoint state
|
||
machine; deferred -- the modernized .NET TeslaConsole is the long-term
|
||
driver.
|
||
- Cosmetic: VDB head windows (pentapus MFDs/radar) go choppy while
|
||
DOSBox is unfocused and smooth when focused; total CPU was 14% so it
|
||
is not starvation -- DWM background-window throttling suspected.
|
||
Backlog.
|
||
|
||
## Modern console on the HOST — WORKING (2026-07-09, verified TCP connect)
|
||
|
||
Host (TeslaSuite .NET console) <-> pod on the same machine. Verified:
|
||
`Test-NetConnection 200.0.0.113 -Port 1501` connects with source
|
||
200.0.0.1. Recipe (both steps elevated):
|
||
|
||
1. Host IP on the bridge (no gateway -- only 200.0.0.x routing changes):
|
||
`New-NetIPAddress -InterfaceAlias 'Network Bridge' -IPAddress
|
||
200.0.0.1 -PrefixLength 24`
|
||
2. Npcap SendToRx so pod (pcap-injected) frames reach the host stack --
|
||
without it the host's ARP gets no answer (neighbor stays Incomplete;
|
||
hairpin-tested: injected frames provably never reach the local
|
||
stack). The per-handle MODE_SENDTORX API needs Npcap >= 1.83
|
||
user-mode DLLs (our wpcap.dll/Packet.dll predate it; npcap.sys is
|
||
1.88 and has the registry side), so use the driver-level value:
|
||
```
|
||
Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\npcap\Parameters' `
|
||
-Name SendToRxAdapters -Value '\Device\{5DB5521D-2D56-40E8-9E3D-3B36C9EE7C8F}' -Type String
|
||
Stop-Service npcap -Force; Start-Service npcap
|
||
```
|
||
Value = semicolon-separated `\Device\{bridge GUID}` entries (parser:
|
||
NPF_GetRegistryOption_AdapterName, npcap Packet.c); read ONCE at
|
||
driver start (hence the service bounce); relaunch the pod after (its
|
||
pcap handle dies with the driver).
|
||
|
||
**THE TRADE: while SendToRxAdapters covers the bridge, ALL Npcap
|
||
injections on it are indicated as receives INSTEAD of transmits** --
|
||
pod frames reach the host stack but no longer reach the TAP members, so
|
||
**the SheepShaver Mac console is DEAF while this is set** (and wire-
|
||
injection diagnostics can't reach the pod either; pod RX is unaffected).
|
||
Revert: `Remove-ItemProperty ... -Name SendToRxAdapters` + service
|
||
bounce + pod relaunch. Host connect/disconnect churns netnub (exit +
|
||
~60s relaunch) -- expected.
|
||
|
||
**BOTH CONSOLES AT ONCE — WORKING (2026-07-10, `net-tools/tap2_mirror.py`):**
|
||
keep SendToRxAdapters set (host console works) and run the mirror
|
||
daemon, which turns TAP2's free application side into a userspace
|
||
bridge port for the Mac: (a) pod-sourced frames destined for the Mac
|
||
MAC or broadcast are written into TAP2 -> Windows bridge forwards
|
||
member->member to TAP1 -> SheepShaver hears; (b) REQUIRED second
|
||
direction: writing pod-src frames into TAP2 teaches the bridge's MAC
|
||
table that the pod lives on TAP2, so the Mac's unicasts to the pod get
|
||
steered INTO TAP2 -- a reader thread drains them and re-injects on the
|
||
bridge (RX-indicated up under SendToRx = exactly where the pod's pcap
|
||
listens). Loop guards: pod-src frames never re-injected; 0.2s
|
||
frame-hash dedup on the mirror side. Verified live: Console 4.10
|
||
readied the pod through it (counters: mirrored=24 pod->Mac,
|
||
reinjected=105 Mac->pod) with the .NET console path intact. Run
|
||
detached: `pythonw net-tools/tap2_mirror.py` (log/pid in the vwe-pod
|
||
work dir; pod MAC + TAP2 GUID are constants at the top). Operate
|
||
ALTERNATING (one console readied at a time -- two masters vs one netnub
|
||
is untested). Cleaner future fix stands: Npcap >=1.83 user-mode DLLs +
|
||
dual-handle MODE_SENDTORX in the fork's pcap backend, which retires
|
||
both the registry setting and the mirror.
|
||
|
||
## MODERN CONSOLE MILESTONE (2026-07-09 late): TeslaSuite .NET console runs the pod, fully hands-free
|
||
|
||
With the host bridging recipe above, the modern .NET console connected
|
||
"near instantly", queued and ran a real 2-minute BT mission on the
|
||
DOSBox pod, and the mission exited ON TIME. Recycle test (wire-logged):
|
||
mission-end teardown (netnub FINs then RSTs the session -- a clean
|
||
close) -> console starts SYN-retrying 11s later -> rides through the
|
||
dead-air phase AND a connection-refused phase (netnub's stack answers
|
||
RST before BTL4OPT issues its TCP_LISTEN; the SBK upload sits in this
|
||
window) -> SYN-ACK the moment the listener opens. **54 seconds from
|
||
teardown to re-established session, zero operator interaction.**
|
||
Modern console + net_loop.conf pod = a fully autonomous mission loop;
|
||
the only operator action is queueing the next mission. (Console 4.10
|
||
by contrast needs an app relaunch per mission.)
|
||
|
||
## Post-mission egress hold — decoded live + from source (2026-07-09 late)
|
||
|
||
The period flow after a mission timer expires (user memory: "lights come
|
||
on so the customer can leave the cockpit, then the pod closes out"),
|
||
confirmed on the wire and in CODE/:
|
||
|
||
1. Game fades the view to the black hold (fog sweep to 0.01/0.05 rgb 0)
|
||
and sends the wrap-up to the console (ConsolePlayerMechScoreUpdate /
|
||
team scores / RankAndScore -- BTCNSL.CPP message set).
|
||
2. **Game commands the RIO egress lamps**: `T 84` = LampRequest on the
|
||
serial tap right at mission end. RIO SetLamp(lamp, state), states =
|
||
solid/flashSlow/flashMed/flashFast + two brightness channels
|
||
(L4RIO.HPP RIOCommand/LampState enums).
|
||
3. Game HOLDS (RIO polling continues, view stays black). Its 1/s
|
||
StateQuery->StateResponse exchange with the console flips a state
|
||
field to 2 (= mission-over/hold; it reads 0 in the ready phase).
|
||
Console polls per Stationary.ini loadQueryInterval=1.
|
||
4. **The console ends the hold: Application__StopMissionMessage carrying
|
||
an ExitCodeID** (APPMSG.HPP: console msg set = StateQuery/CheckLoad/
|
||
RunMission/StopMission/KeyCommand/Suspend/Resume/LoadMission/Abort).
|
||
ExitCodeID is a remote-ops menu: Abort, RunRedPlanet/BattleTech (+
|
||
SinglePlayer variants), test patterns, TestPlasmaDisplay, ResetRIO,
|
||
RunAudioTest, RunNortonDiskDoctor, CheckDiskUsage, Refresh*,
|
||
ChangeScreenMode, SoftwareReset, ClearCrashlog, KillSpoolFile,
|
||
RunCamera/MissionReview -- the game exits through netnub with that
|
||
code and the pod's GO.BAT-era loop dispatches it. Handler impl is
|
||
compiled-only (StopMissionMessageHandler declared in BTL4APP.HPP;
|
||
body not in the archive).
|
||
Observed: Console 4.10 closed the hold within seconds; the .NET
|
||
console left the pod holding for minutes until it sent its close.
|
||
The hold length is entirely the console's call = the customer-egress
|
||
window.
|
||
|
||
Also caught live: sliding the bridge seat trim far back (forward -10)
|
||
puts the eye inside the mech's hull meshes -- z-fighting reads as a
|
||
"smeared" render and the canopy cage disappears (you are behind it).
|
||
Not a render bug; trim back to the per-mech eye position (Mad Cat ~
|
||
-1.8) restores the view.
|
||
|
||
**MEASURED lamp choreography (2026-07-09 late, RIO tap, TeslaConsole
|
||
close):** `LampFloor = 0x3E` ("floor lamp (entry/exit)", L4CTRL.HPP;
|
||
lamps 0x16/0x17/0x1e form the entry cluster with it; 0x3D = Panic).
|
||
Timeline: game boot 98.9s -> floor+entry BRIGHT, all panel lamps dim
|
||
(ENTRY lighting -- pod lit while the customer climbs in); mission drop
|
||
120.7s -> entry lamps OFF; mission end 151.5s -> floor+entry BRIGHT
|
||
(the egress hold, StateResponse state=2); StopMission arrives 154.9s ->
|
||
full lamp sweep off, floor last, game exits. **Egress window = the gap
|
||
between mission end and the console's StopMission send** -- it is NOT
|
||
a game-side timer and does not appear to depend on the exit code.
|
||
MEASURED BOTH CONSOLES: TeslaConsole = 3.4s AND Console 4.10 = 3.4s
|
||
(lamp-ON to full-sweep-off). No egress-delay key exists in
|
||
Console.ini / venue INIs (checked).
|
||
|
||
**EGRESS DELAY FOUND IN THE BINARY (2026-07-10 disasm):** it is a
|
||
HARDCODED ~30s constant in BTL4OPT.EXE's `StopMissionMessageHandler`
|
||
@ 0x47b864 -- NOT an INI/env value. The handler: (1) calls
|
||
mission-shutdown/EndMission (0x44eeb4); (2) calls the lamp routine
|
||
(0x47bba8, named by the "LightsOut" string @0x4fd5ac) with flag=1 ->
|
||
egress lamps 0x16/0x17/0x1e ON; (3) schedules a delayed state block
|
||
(0xc,0xc,1) for `now + 30.0*timebase + 0.5` (30.0f @0x47b8e4, 0.5f
|
||
@0x47b8e8, timebase global @0x52140c read across the whole sim) -- at
|
||
which the lamps go off and the game exits into GO.BAT. Matches the
|
||
operator's memory exactly: StopMission -> floor lights on -> ~30s hold
|
||
-> lights off -> exit -> BAT restart. The LightsOut lamp-off helper
|
||
(0x47bb34, flag=0 branch) sweeps 0x16/0x17/0x1e off. RECONCILE: this
|
||
30s vs the measured 3.4s -- the 154.9s full-lamp SWEEP we tapped was
|
||
likely the teardown GeneralReset (a different event), with the true 30s
|
||
timer firing past our tap window; timebase multiplier is runtime-set so
|
||
wall-time can't be proven statically. TEST TO SETTLE: one mission with
|
||
a 90s+ RIO tap window past mission end. Disasm tool: scratchpad
|
||
btdis2.py (pefile+capstone, py -3.13). This is the authentic egress
|
||
window; the earlier "print time" theory is superseded. Lamp
|
||
state byte: flash bits0-1 (solid/slow/med/fast), ch1 bits2-3
|
||
(off/dim/bright), ch2 bits4-5. Extraction tool: scratchpad
|
||
lamp_read.py pattern (tap lines are one byte each).
|
||
|
||
## Open questions / notes
|
||
- Exact TCP listen port(s) — not in the source grep; get from NETNUB.EXE
|
||
or a capture at milestone 3.
|
||
- Does WATTCP need a real ARP peer for the gateway at boot, or does it
|
||
proceed with a static IP and only ARP on connect? Affects whether the
|
||
stand-in console must answer ARP for 200.0.0.1.
|
||
- `NETCLIENT=PNW` (PARAMETR.bat) selects Personal NetWare — file-server
|
||
side, not the game's TCP path; likely irrelevant to egg delivery and
|
||
can stay unloaded under emulation.
|
||
- RP uses the identical MUNGA net brick + its own WATTCP.CFG — everything
|
||
here carries over to Red Planet.
|