Merge remote-tracking branch 'origin/master' into glass-cockpit

This commit is contained in:
Cyd
2026-07-18 18:58:56 -05:00
6 changed files with 113 additions and 14 deletions
+28 -5
View File
@@ -97,10 +97,31 @@ The authentic stack (gait, collision, real controls) is **default-on**; set `=0`
Debug/harness flags: `BT_FORCE_THROTTLE=1` (auto-walk), `BT_SPAWN_ENEMY=1` (spawn a target dummy),
`BT_FORCE_FIRE=1` (auto-fire), `BT_HEAPCHECK=1` (whole-heap validation — slow), `BT_BSL=0` (legacy
texture decode), `BT_DEV_GAUGES=1` (render the cockpit MFDs in a dev window), `BT_LOG=<file>`.
Interactive: **WASD** drive, **Space/Ctrl** fire, **X** all-stop. The complete env-gate table is in
`context/decomp-reference.md` §6 (routed from `CLAUDE.md`).
Interactive: **WASD** drive, **A/D** turn, **Q/E** torso twist, **R/F** torso pitch aim,
**Space / 1-4** fire, **X** all-stop, **V** view, **M** control mode. An Xbox-type controller
works out of the box. All bindings are user-editable in **`content/CONTROLS.MAP`** (delete it to
restore the compiled-in WASD default; `content/CONTROLS_NUMPAD.MAP` is an alternate profile). The
complete env-gate table is in `context/decomp-reference.md` §6 (routed from `CLAUDE.md`);
controls/pad details in `context/pod-hardware.md`.
## Multiplayer (two instances, one box)
## Multiplayer
**Modern path (relay + operator console).** Pods make ONE outbound connection to a relay/console,
so internet play needs no per-player port forwarding and CGNAT-safe LAN discovery just works. Run
the operator station:
```
python tools/btoperator.py # PySide6 GUI: build the mission egg (validated dropdowns),
# start the relay, watch pods arrive, assign seats, LAUNCH,
# end the timed mission, export player join.bat scripts
```
Players run one universal `join.bat` (internet, seat assigned by the relay) or `join_lan.bat`
(same LAN, auto-discovers the console) or `play_solo.bat` (offline practice) — see `players/`.
The pod waits patiently if the session isn't up yet. Full architecture, wire format, and the
D1 relay/UDP design: `context/multiplayer.md`.
**Legacy mesh (two instances, one box), still supported:**
```
instance A: btl4.exe -egg MP.EGG -net 1501 (BT_LOG=mp_a.log)
@@ -108,8 +129,10 @@ instance B: btl4.exe -net 1601 (BT_LOG=mp_b.log)
console: python tools/btconsole.py MP.EGG 127.0.0.1:1501 127.0.0.1:1601
```
`-net <port>` enables networked mode; the console emulator delivers the mission egg and the launch
command. Entity + movement replication works; cross-pod combat is in progress.
`-net <port>` enables networked mode. Verified end-to-end: full entity/movement replication,
cross-pod combat + kills, per-pilot paint + callsigns, timed missions, 4-pod live sessions, and a
spectator/broadcast **camera seat** (`hostType=1 vehicle=camera`) with auto-directed coverage and
a live ranking window.
## Status & continuing the work
+3 -1
View File
@@ -268,7 +268,9 @@ default-ON (`'0'` disables).
| `BT_RELAY=<host:port\|auto>` | **D1 relay mode** — dial the relay/operator console instead of the P2P mesh; `auto` = LAN UDP-broadcast discovery on udp/15999 ([[multiplayer]] D1) |
| `BT_SELF=<[pilots] tag>` | claim a specific roster seat in relay mode; absent/`auto` = the relay assigns one (SEAT_REQUEST) |
| `BT_RELAY_TCP_ONLY=1` | disable the relay UDP channel (all unreliable traffic rides the TCP relay conn) |
| `BT_INPUT_LOG` | binding-engine trace: CONTROLS.MAP load, button/keypad emissions, pad connect ([[pod-hardware]] §Desktop input remap) |
| `BT_INPUT_LOG` | binding-engine trace: CONTROLS.MAP load, button/keypad emissions, pad connect, torso twist/elev axis ([[pod-hardware]] §Desktop input remap) |
| `BT_CAM_LOG` | camera-seat/broadcast trace: streamed camera network count, director pick + Players-group census, ship follow state, PlayerLink dispatch/receive, ranking-window render ([[multiplayer]] camera seat) |
| `BT_SPEC_LOG` | manual spec-audit dump at subsystem ctor: torso speed/limits, heat-sink count, reservoir capacity, per-subsystem coolant loop ([[pod-hardware]] §Spec cross-check) |
Full render/locomotion gates (BT_RAMP, BT_MATPRI, BT_CULL, BT_SHADOW_*, BT_LODSEL, BT_ADDLOD,
BT_PUNCH, …) are catalogued in [[rendering]]. Warp visuals: [[translocation-warp]].
+16
View File
@@ -326,6 +326,22 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
SEAT_REQUEST branch (btconsole.py). Verified: 8/8 stub tests (distinct seats, FULL on
exhaustion, claim clears reserve, duplicate-HELLO refused) + 2-node localhost e2e with NO
BT_SELF on either pod → both seated, full ladder, launch, UDP flowing.
- **PATIENT WALK-UP (2026-07-18) [T2]**: a pod dialed at a relay that isn't up yet no longer
aborts (release `Fail()` is a bare `abort()` = looked like a "crash", field report). Both the
seat request (`RelayRequestSeat`) and LAN discovery (`BT_RELAY=auto`) now RETRY (2s dials, 30
min cap) with live status into the join.bat console window (`BTRelayWaitStatus` attaches to the
parent cmd via `AttachConsole(ATTACH_PARENT_PROCESS)`): a banner + progress dots, a distinct
"game is FULL, waiting for a free seat" state, "seat assigned — joining!" on success. The pod
joins automatically the moment the operator starts the session; the unreachable MessageBox only
fires at the 30-min give-up. Removes the session-before-join ordering requirement. Verified:
pod launched at a dead relay, waited 20+s, relay started, pod seated + reached RunningMission
with no user action.
- **MISSION CLOCK (2026-07-18) [T2]**: the egg `[mission] length=` (600 = the 10-min pod mission)
drives the pod countdown + the final-30s ranking window; the relay now SENDS
`Application::StopMissionMessage` (clientID 4, msgID 6, exitCode 0) at length+2s grace, or on
the operator's ⏹ END MISSION button (stdin `stop`). Was half-implemented — nothing fired it in
1995 the console owned the stop. Pod runs the authentic fade → mission-loop exit → clean
process exit. Verified live (40s test mission ended on the clock).
- **OPERATOR CONSOLE (2026-07-18)**: `tools/btoperator.py` (PySide6) — the lost 1995 operator
station recreated: mission editor with ALL values validated live from BTL4.RES
(`tools/eggmodel.py`: maps=type14∩26, colors/badges/patches from the type-25 vehicletable,
+10 -1
View File
@@ -47,7 +47,16 @@ Axes: Throttle (lever, rate-walks + sticks), LeftPedal/RightPedal/Pedals (turn,
JoystickX (torso twist), **JoystickY (torso ELEVATION — pitch aim; every 1995 control mode
routes it to Torso::SetAnalogElevationAxis; keys R/F + pad LeftStickY in the default map;
verified to the authentic VerticalLimitTop clamp, 20° on the Blackhawk; aims the GUNS — reads
on the HUD elevation tape, the eyepoint stays level)**. Emissions mirror the RIO conventions above exactly; the four fire
on the HUD elevation tape, the eyepoint stays level)**.
**AXIS SIGN CONVENTION (2026-07-18, user-verified) [T1]:** the sim uses MATH convention —
positive turnDemand / twist / free-aim slew is **CCW (LEFT)**. On the pod the RIO Ranger owned
the hardware sign; the desktop key bridge negates ONCE per channel (`key_turn = -gBTDrive.turn`;
`stickPosition.x = -gBTTwistAxis`; the SetFreeAimSlew call-sites pass the bridge value un-negated)
so input-right = turn/twist RIGHT (manual p8: "pulling your joystick to the right torso twists
your 'Mech to the right"). Forced/BT_GOTO harness demands are already sim-frame and stay
un-negated. Lesson: large-rotation screenshot comparisons are AMBIGUOUS — trust tracked
landmarks, numeric yaw telemetry with an established convention, or the user's live observation.
Emissions mirror the RIO conventions above exactly; the four fire
buttons (0x40/45/46/47) publish as levels to the bring-up fire channels instead of buttonGroup
(avoids double-fire once the streamed ChooseButton mappings are exercised). XInput loads
dynamically (xinput1_4 → 9_1_0; disconnected-pad probing rate-limited to 1 Hz). **Keys claimed by
+25 -4
View File
@@ -276,12 +276,13 @@ class RelayConsoleConn:
class Relay:
def __init__(self, console_port, egg_path, bind_addr, udp_drop_pct,
manual_launch=False):
manual_launch=False, reserved_tags=None):
self.console_port = console_port
self.game_port = console_port + 1
self.bind_addr = bind_addr
self.udp_drop_pct = udp_drop_pct
self.manual_launch = manual_launch
self._reserved_tags = set(reserved_tags or ())
self.launch_requested = False # set by the operator (stdin)
self.stop_requested = False # operator 'stop' (End Mission)
self.mission_length = parse_egg_mission_length(egg_path)
@@ -292,6 +293,19 @@ class Relay:
self.roster = parse_egg_roster(egg_path)
self.expected_ids = set(range(FIRST_GAME_HOST_ID,
FIRST_GAME_HOST_ID + len(self.roster)))
# OPERATOR-RESERVED SEATS: roster seats the operator flies LOCALLY (with
# an explicit BT_SELF). Those instances take ~15s to boot, so a remote
# join.bat (no BT_SELF -> SEAT_REQUEST) would otherwise be assigned the
# operator's seat first, and the operator's later HELLO would collide
# ("already registered" -> dropped -> game never launches). Seat
# ASSIGNMENT skips these host_ids; the explicit HELLO still claims them.
self.reserved_host_ids = set()
for i, tag in enumerate(self.roster):
if tag in self._reserved_tags:
self.reserved_host_ids.add(FIRST_GAME_HOST_ID + i)
if self.reserved_host_ids:
print(f"[relay] operator-reserved seats (no auto-assign): "
f"{sorted(self.reserved_host_ids)}", flush=True)
self.sel = selectors.DefaultSelector()
self.console_conns = []
self.game_conns = [] # RelayGameConn (incl. unregistered)
@@ -556,8 +570,9 @@ class Relay:
assigned = None
for i, tag in enumerate(self.roster):
host_id = FIRST_GAME_HOST_ID + i
if host_id in self.by_host or host_id in self.seat_reservations:
continue
if (host_id in self.by_host or host_id in self.seat_reservations
or host_id in self.reserved_host_ids):
continue # claimed / racing / operator-local
assigned = (host_id, tag)
break
if assigned is None:
@@ -753,12 +768,18 @@ def relay_main(argv):
if "--manual-launch" in args:
manual = True
args.remove("--manual-launch")
reserved_tags = ()
if "--reserve" in args:
i = args.index("--reserve")
reserved_tags = tuple(t for t in args[i + 1].split(",") if t)
del args[i:i + 2]
if len(args) != 2:
print(__doc__)
return 2
console_port = int(args[0])
egg_path = args[1]
relay = Relay(console_port, egg_path, bind_addr, udp_drop, manual)
relay = Relay(console_port, egg_path, bind_addr, udp_drop, manual,
reserved_tags=reserved_tags)
if not relay.roster:
print(f"[relay] ERROR: no [pilots] entries in {egg_path}")
return 2
+31 -3
View File
@@ -418,7 +418,10 @@ class Operator(QMainWindow):
p = self._pilot_template()
p["address"] = address
p.pop("name", None) # new row gets the PLAYERn default callsign
self._add_pilot_row(p, local=(self.mode.currentIndex() == 0))
# a NEWLY-added pilot is a remote joiner by default (Local only on the
# operator's own seat, normally row 0 which the egg load already sets)
self._add_pilot_row(p, local=(self.mode.currentIndex() == 0
and self.table.rowCount() == 0))
self._renumber_relay_tags()
def _remove_pilot(self):
@@ -458,8 +461,12 @@ class Operator(QMainWindow):
self.f_length.setText(m.get("length") or "")
self.table.setRowCount(0)
relay = self.mode.currentIndex() == 0
for p in self.egg.pilots():
self._add_pilot_row(p, local=relay)
# LOCAL default: ONLY the operator's own seat (row 0) -- NOT every row.
# (Checking Local on all rows made "Launch local instances" spawn one
# full game client PER seat on this machine, which collided and
# crashed -- field report 2026-07-18.) Remote joiners stay unchecked.
for i, p in enumerate(self.egg.pilots()):
self._add_pilot_row(p, local=(relay and i == 0))
self._renumber_relay_tags()
self.egg_label.setText(os.path.basename(self.egg_path))
@@ -545,12 +552,24 @@ class Operator(QMainWindow):
args += ["--relay", str(self.f_port.value()), self.egg_path]
if self.f_manual.isChecked():
args += ["--manual-launch"]
# RESERVE the operator's LOCAL seats so a remote join.bat can't be
# assigned one before the (slow-to-boot) local instance claims it
# -- the seat-collision that dropped the operator's pod + stalled
# the launch (field report 2026-07-18).
local_tags = [tags[r] for r in range(self.table.rowCount())
if self._row_local(r) and r < len(tags)]
if local_tags:
args += ["--reserve", ",".join(local_tags)]
else:
# mesh: console dials each pod's CONSOLE port (= game port - 1)
args += [self.egg_path]
for tag in tags:
host, _, port = tag.rpartition(":")
args.append("%s:%d" % (host, int(port) - 1))
try: # fresh relay log per session
open(os.path.join(CONTENT, "operator_relay.log"), "w").close()
except OSError:
pass
self.console_proc = QProcess(self)
self.console_proc.setProcessChannelMode(QProcess.MergedChannels)
self.console_proc.readyReadStandardOutput.connect(self._console_output)
@@ -615,6 +634,15 @@ class Operator(QMainWindow):
return
data = bytes(self.console_proc.readAllStandardOutput()).decode(
"utf-8", "replace")
# ALSO tee the relay/console output to a file -- the GUI log pane is
# not readable post-mortem, and every field bug so far needed the
# relay's own view to diagnose (2026-07-18).
try:
with open(os.path.join(CONTENT, "operator_relay.log"), "a",
encoding="utf-8") as f:
f.write(data)
except OSError:
pass
for line in data.splitlines():
if line.strip():
self.log.appendPlainText(line)