diff --git a/Console/TeslaConsole.BattleTech/BTGame.cs b/Console/TeslaConsole.BattleTech/BTGame.cs
index 331e7ed..a68ff05 100644
--- a/Console/TeslaConsole.BattleTech/BTGame.cs
+++ b/Console/TeslaConsole.BattleTech/BTGame.cs
@@ -184,6 +184,18 @@ internal class BTGame : DockContent
private readonly bool mDosBoxAddressShift = BTDefaults.DosBoxAddressShift;
+ private Panel mSessionStrip;
+
+ private Label mSessionBanner;
+
+ private Button mSessionApply;
+
+ private Button mSessionReset;
+
+ private DateTime mNextRosterPoll = DateTime.MinValue;
+
+ private DateTime mStateChangePendingSince = DateTime.MinValue;
+
private string GameStatusText
{
set
@@ -300,10 +312,117 @@ internal class BTGame : DockContent
{
BuildPodRow(item5.A, item5.B);
}
+ BuildSessionStrip();
+ SessionRoster.Poll();
+ UpdateSessionStrip();
CheckAllValues();
ResumeLayout();
}
+ ///
+ /// The internet-session strip: roster banner, Apply, and the Reset that has
+ /// always existed as a right-click on the (disabled) Go button.
+ ///
+ /// Built here and not in InitializeComponent on purpose. That block is the
+ /// decompiled 1995 designer output and the differential tests compare it
+ /// literally, so anything new has to be assembled at runtime instead.
+ /// Docked last, which is docked first, so the strip sits above Mission
+ /// Properties rather than between it and the pilot grid.
+ ///
+ private void BuildSessionStrip()
+ {
+ mSessionStrip = new Panel();
+ mSessionStrip.Dock = DockStyle.Top;
+ mSessionStrip.Height = 28;
+ mSessionStrip.Visible = false;
+ mSessionBanner = new Label();
+ mSessionBanner.Dock = DockStyle.Fill;
+ mSessionBanner.TextAlign = ContentAlignment.MiddleLeft;
+ mSessionBanner.Padding = new Padding(6, 0, 6, 0);
+ mSessionApply = new Button();
+ mSessionApply.Dock = DockStyle.Left;
+ mSessionApply.Width = 110;
+ mSessionApply.Text = "Apply Session";
+ mSessionApply.Visible = false;
+ mSessionApply.UseVisualStyleBackColor = true;
+ mSessionApply.Click += new EventHandler(mSessionApply_Click);
+ mSessionReset = new Button();
+ mSessionReset.Dock = DockStyle.Right;
+ mSessionReset.Width = 110;
+ mSessionReset.Text = "Reset Pods";
+ mSessionReset.Visible = false;
+ mSessionReset.UseVisualStyleBackColor = true;
+ // The same action as the hidden context-menu item, which no operator has
+ // ever found: it lives on a button that is disabled exactly when the reset
+ // is wanted.
+ mSessionReset.Click += new EventHandler(resetToolStripMenuItem_Click);
+ mSessionStrip.Controls.Add(mSessionBanner);
+ mSessionStrip.Controls.Add(mSessionApply);
+ mSessionStrip.Controls.Add(mSessionReset);
+ base.Controls.Add(mSessionStrip);
+ }
+
+ private void UpdateSessionStrip()
+ {
+ if (mRequestedState == mCurrentState)
+ {
+ mStateChangePendingSince = DateTime.MinValue;
+ mSessionReset.Visible = false;
+ }
+ else
+ {
+ if (mStateChangePendingSince == DateTime.MinValue)
+ {
+ mStateChangePendingSince = DateTime.Now;
+ }
+ // Ten seconds, so a healthy Load/Launch never makes the button flicker
+ // past. Past that the pane is not slow, it is stuck: the state barrier
+ // in NetworkScan has no timeout of its own.
+ mSessionReset.Visible = mStateChangePendingSince.AddSeconds(10.0) < DateTime.Now;
+ }
+ string text = SessionBannerText();
+ mSessionBanner.Text = text;
+ mSessionBanner.ForeColor = (SessionRoster.Active ? SystemColors.ControlText : Color.Red);
+ mSessionApply.Visible = SessionRoster.Active;
+ // Nothing claimed, nothing wrong and nothing stuck: no roster file at all
+ // leaves the pane looking exactly as it does today. Museums run this.
+ mSessionStrip.Visible = text.Length > 0 || mSessionReset.Visible;
+ }
+
+ private static string SessionBannerText()
+ {
+ if (!SessionRoster.Active)
+ {
+ // Empty with no file; when a file was refused this is why, and the
+ // operator needs to read it before hand-enabling eight rows.
+ return SessionRoster.LoadError;
+ }
+ string text = SessionRoster.SessionKey;
+ if (text.Length > 8)
+ {
+ text = text.Substring(0, 8);
+ }
+ // Local time: the lobby writes the file on this machine, and the operator
+ // is comparing the stamp against the clock on the wall.
+ return $"SESSION {text} - {SessionRoster.Game} - {SessionRoster.Claims.Count} slots claimed - {SessionRoster.WaitingCount} waiting - written {SessionRoster.WrittenUtc.ToLocalTime():HH:mm:ss}";
+ }
+
+ private void mSessionApply_Click(object sender, EventArgs e)
+ {
+ string text = SessionRoster.Validate("bt", mDosBoxAddressShift, mPilotsDataGrid, mEnabledColumn.Index, mPilotColumn.Index);
+ if (text.Length > 0)
+ {
+ // Named offender, never a silent fix: the pilot name is about to become
+ // an INI section name in the egg, so the operator must see the name that
+ // will be written.
+ MessageBox.Show(text, "Can Not Apply Session Roster");
+ return;
+ }
+ SessionRoster.Apply(mPilotsDataGrid, mEnabledColumn.Index, mPilotColumn.Index, this, mDosBoxAddressShift);
+ CheckAllValues();
+ mPilotsDataGrid.Refresh();
+ }
+
private static void SetupKeyValueColumn(DataGridViewComboBoxColumn column, Dictionary options, string defaultKey)
{
int num = 0;
@@ -403,6 +522,21 @@ internal class BTGame : DockContent
private void NetworkScan(object sender, EventArgs e)
{
+ // Own deadline rather than a count of ticks: mNetworkTimer's interval is
+ // the designer default and this must stay ~1Hz whatever that becomes. Same
+ // shape as MungaGame.QueryStateIfNeeded (MungaGame.cs:159-167).
+ DateTime now = DateTime.Now;
+ if (mNextRosterPoll < now)
+ {
+ mNextRosterPoll = now.AddSeconds(1.0);
+ if (SessionRoster.Poll() && mCurrentState == BTGameState.Idle && mRequestedState == BTGameState.Idle)
+ {
+ // Guarded like SetPodStatus: CheckAllValues owns mGoButton.Enabled,
+ // and mid-mission that button is Stop Mission.
+ CheckAllValues();
+ }
+ }
+ UpdateSessionStrip();
if (mCurrentState == BTGameState.Run && mRequestedState == BTGameState.Run && !mProcessingMissionEndStateChangeReq)
{
mProcessingMissionEndStateChangeReq = true;
@@ -735,6 +869,21 @@ internal class BTGame : DockContent
{
case BTGameState.Load:
{
+ // Re-check at the click, not at the last status tick. Nothing between
+ // here and the egg validates anything -- mGoButton.Enabled is a UI flag
+ // CheckAllValues set at some earlier moment -- so a pod that died in
+ // that gap goes straight into the mission, and the post-Load barrier in
+ // NetworkScan then waits forever for a WaitingForLaunch that will never
+ // arrive (that loop has no timeout). Safe only at the very top of this
+ // case: mMissionLength, mMissionRecorder, mMissionPlayers,
+ // mRequestedState and SwitchControlsMode are all still untouched below,
+ // so returning here leaves the pane exactly as the operator left it.
+ CheckAllValues();
+ if (!mGoButton.Enabled)
+ {
+ MessageBox.Show(mIssuesLabel.Text, "Can Not Load Mission");
+ return;
+ }
mMissionLength = ParseMissionLength();
string key = ((BTMap)mMap.SelectedItem).Key;
string key2 = ((KeyValuePair)mTimeOfDay.SelectedItem).Key;
@@ -854,6 +1003,9 @@ internal class BTGame : DockContent
private void SwitchControlsMode(bool editMode)
{
+ // Applying a roster rewrites pilot names and enabled rows, so it is an edit
+ // like any other and rides the same gate as the rest of the pane.
+ mSessionApply.Enabled = editMode;
mMap.Enabled = editMode;
mWeather.Enabled = editMode;
mTimeOfDay.Enabled = editMode;
@@ -1004,6 +1156,10 @@ internal class BTGame : DockContent
{
stringBuilder.AppendLine("The mission length must be at least 10 seconds.");
}
+ // Enabled implies claimed: an enabled row nobody took in the lobby puts a
+ // dead IP in the egg and every pod then waits on a peer that never boots.
+ // Empty with no session roster, which is what keeps the arcade path intact.
+ stringBuilder.Append(SessionRoster.Issues(mPilotsDataGrid, mEnabledColumn.Index, mDosBoxAddressShift));
mGoButton.Enabled = stringBuilder.Length <= 0;
mIssuesLabel.Text = stringBuilder.ToString();
}
diff --git a/Console/TeslaConsole.RedPlanet/RPGame.cs b/Console/TeslaConsole.RedPlanet/RPGame.cs
index 51e6e6a..3727929 100644
--- a/Console/TeslaConsole.RedPlanet/RPGame.cs
+++ b/Console/TeslaConsole.RedPlanet/RPGame.cs
@@ -178,6 +178,18 @@ public class RPGame : DockContent
private readonly bool mDosBoxAddressShift = RPDefaults.DosBoxAddressShift;
+ private Panel mSessionStrip;
+
+ private Label mSessionBanner;
+
+ private Button mSessionApply;
+
+ private Button mSessionReset;
+
+ private DateTime mNextRosterPoll = DateTime.MinValue;
+
+ private DateTime mStateChangePendingSince = DateTime.MinValue;
+
private string GameStatusText
{
set
@@ -335,10 +347,119 @@ public class RPGame : DockContent
{
BuildPodRow(item6.A, item6.B);
}
+ BuildSessionStrip();
+ SessionRoster.Poll();
+ UpdateSessionStrip();
CheckAllValues();
ResumeLayout();
}
+ ///
+ /// The internet-session strip: roster banner, Apply, and the Reset that has
+ /// always existed as a right-click on the (disabled) Go button.
+ ///
+ /// Built here and not in InitializeComponent on purpose. That block is the
+ /// decompiled 1995 designer output and the differential tests compare it
+ /// literally, so anything new has to be assembled at runtime instead.
+ /// Docked last, which is docked first, so the strip sits above Mission
+ /// Properties rather than between it and the pilot grid.
+ ///
+ private void BuildSessionStrip()
+ {
+ mSessionStrip = new Panel();
+ mSessionStrip.Dock = DockStyle.Top;
+ mSessionStrip.Height = 28;
+ mSessionStrip.Visible = false;
+ mSessionBanner = new Label();
+ mSessionBanner.Dock = DockStyle.Fill;
+ mSessionBanner.TextAlign = ContentAlignment.MiddleLeft;
+ mSessionBanner.Padding = new Padding(6, 0, 6, 0);
+ mSessionApply = new Button();
+ mSessionApply.Dock = DockStyle.Left;
+ mSessionApply.Width = 110;
+ mSessionApply.Text = "Apply Session";
+ mSessionApply.Visible = false;
+ mSessionApply.UseVisualStyleBackColor = true;
+ mSessionApply.Click += new EventHandler(mSessionApply_Click);
+ mSessionReset = new Button();
+ mSessionReset.Dock = DockStyle.Right;
+ mSessionReset.Width = 110;
+ mSessionReset.Text = "Reset Pods";
+ mSessionReset.Visible = false;
+ mSessionReset.UseVisualStyleBackColor = true;
+ // The same action as the hidden context-menu item, which no operator has
+ // ever found: it lives on a button that is disabled exactly when the reset
+ // is wanted.
+ mSessionReset.Click += new EventHandler(resetToolStripMenuItem_Click);
+ mSessionStrip.Controls.Add(mSessionBanner);
+ mSessionStrip.Controls.Add(mSessionApply);
+ mSessionStrip.Controls.Add(mSessionReset);
+ base.Controls.Add(mSessionStrip);
+ }
+
+ private void UpdateSessionStrip()
+ {
+ if (mRequestedState == mCurrentState)
+ {
+ mStateChangePendingSince = DateTime.MinValue;
+ mSessionReset.Visible = false;
+ }
+ else
+ {
+ if (mStateChangePendingSince == DateTime.MinValue)
+ {
+ mStateChangePendingSince = DateTime.Now;
+ }
+ // Ten seconds, so a healthy Load/Launch never makes the button flicker
+ // past. Past that the pane is not slow, it is stuck: the state barrier
+ // in NetworkScan has no timeout of its own.
+ mSessionReset.Visible = mStateChangePendingSince.AddSeconds(10.0) < DateTime.Now;
+ }
+ string text = SessionBannerText();
+ mSessionBanner.Text = text;
+ mSessionBanner.ForeColor = (SessionRoster.Active ? SystemColors.ControlText : Color.Red);
+ mSessionApply.Visible = SessionRoster.Active;
+ // Nothing claimed, nothing wrong and nothing stuck: no roster file at all
+ // leaves the pane looking exactly as it does today. Museums run this.
+ mSessionStrip.Visible = text.Length > 0 || mSessionReset.Visible;
+ }
+
+ private static string SessionBannerText()
+ {
+ if (!SessionRoster.Active)
+ {
+ // Empty with no file; when a file was refused this is why, and the
+ // operator needs to read it before hand-enabling eight rows.
+ return SessionRoster.LoadError;
+ }
+ string text = SessionRoster.SessionKey;
+ if (text.Length > 8)
+ {
+ text = text.Substring(0, 8);
+ }
+ // Local time: the lobby writes the file on this machine, and the operator
+ // is comparing the stamp against the clock on the wall.
+ return $"SESSION {text} - {SessionRoster.Game} - {SessionRoster.Claims.Count} slots claimed - {SessionRoster.WaitingCount} waiting - written {SessionRoster.WrittenUtc.ToLocalTime():HH:mm:ss}";
+ }
+
+ private void mSessionApply_Click(object sender, EventArgs e)
+ {
+ // "rp": both Red Planet modes -- Death Race and Martian Football -- are
+ // one game to the lobby, which knows only which title the pods will boot.
+ string text = SessionRoster.Validate("rp", mDosBoxAddressShift, mPilotsDataGrid, mEnabledColumn.Index, mPilotColumn.Index);
+ if (text.Length > 0)
+ {
+ // Named offender, never a silent fix: the pilot name is about to become
+ // an INI section name in the egg, so the operator must see the name that
+ // will be written.
+ MessageBox.Show(text, "Can Not Apply Session Roster");
+ return;
+ }
+ SessionRoster.Apply(mPilotsDataGrid, mEnabledColumn.Index, mPilotColumn.Index, this, mDosBoxAddressShift);
+ CheckAllValues();
+ mPilotsDataGrid.Refresh();
+ }
+
private void BuildPodRow(Squad squad, Pod pod)
{
int index = mPilotsDataGrid.Rows.Add();
@@ -430,6 +551,21 @@ public class RPGame : DockContent
private void NetworkScan(object sender, EventArgs e)
{
+ // Own deadline rather than a count of ticks: mNetworkTimer's interval is
+ // the designer default and this must stay ~1Hz whatever that becomes. Same
+ // shape as MungaGame.QueryStateIfNeeded (MungaGame.cs:159-167).
+ DateTime now = DateTime.Now;
+ if (mNextRosterPoll < now)
+ {
+ mNextRosterPoll = now.AddSeconds(1.0);
+ if (SessionRoster.Poll() && mCurrentState == RPGameState.Idle && mRequestedState == RPGameState.Idle)
+ {
+ // Guarded like SetPodStatus: CheckAllValues owns mGoButton.Enabled,
+ // and mid-mission that button is Stop Mission.
+ CheckAllValues();
+ }
+ }
+ UpdateSessionStrip();
if (mCurrentState == RPGameState.Run && mRequestedState == RPGameState.Run && !mProcessingMissionEndStateChangeReq)
{
mProcessingMissionEndStateChangeReq = true;
@@ -775,6 +911,21 @@ public class RPGame : DockContent
{
case RPGameState.Load:
{
+ // Re-check at the click, not at the last status tick. Nothing between
+ // here and the egg validates anything -- mGoButton.Enabled is a UI flag
+ // CheckAllValues set at some earlier moment -- so a pod that died in
+ // that gap goes straight into the mission, and the post-Load barrier in
+ // NetworkScan then waits forever for a WaitingForLaunch that will never
+ // arrive (that loop has no timeout). Safe only at the very top of this
+ // case: mMissionLength, mMissionRecorder, mMissionPlayers,
+ // mRequestedState and SwitchControlsMode are all still untouched below,
+ // so returning here leaves the pane exactly as the operator left it.
+ CheckAllValues();
+ if (!mGoButton.Enabled)
+ {
+ MessageBox.Show(mIssuesLabel.Text, "Can Not Load Mission");
+ return;
+ }
mMissionLength = ParseMissionLength();
string key = ((RPMap)mMap.SelectedItem).Key;
string key2 = ((KeyValuePair)mTimeOfDay.SelectedItem).Key;
@@ -914,6 +1065,9 @@ public class RPGame : DockContent
private void SwitchControlsMode(bool editMode)
{
+ // Applying a roster rewrites pilot names and enabled rows, so it is an edit
+ // like any other and rides the same gate as the rest of the pane.
+ mSessionApply.Enabled = editMode;
ComboBox comboBox = mMap;
ComboBox comboBox2 = mWeather;
ComboBox comboBox3 = mTimeOfDay;
@@ -1111,6 +1265,10 @@ public class RPGame : DockContent
{
stringBuilder.AppendLine("The mission length must be at least 10 seconds.");
}
+ // Enabled implies claimed: an enabled row nobody took in the lobby puts a
+ // dead IP in the egg and every pod then waits on a peer that never boots.
+ // Empty with no session roster, which is what keeps the arcade path intact.
+ stringBuilder.Append(SessionRoster.Issues(mPilotsDataGrid, mEnabledColumn.Index, mDosBoxAddressShift));
mGoButton.Enabled = stringBuilder.Length <= 0;
mIssuesLabel.Text = stringBuilder.ToString();
}
diff --git a/Console/TeslaConsole/SessionRoster.cs b/Console/TeslaConsole/SessionRoster.cs
new file mode 100644
index 0000000..5c29894
--- /dev/null
+++ b/Console/TeslaConsole/SessionRoster.cs
@@ -0,0 +1,857 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.IO;
+using System.Net;
+using System.Text;
+using System.Windows.Forms;
+using Newtonsoft.Json.Linq;
+
+namespace TeslaConsole;
+
+///
+/// Reads the session roster TeslaLobby writes when an internet session
+/// launches: which of the eight internet slots a human actually claimed, and
+/// what that human is called. The slot-to-IP map is frozen
+/// (emulator\steam\SESSION-CONTRACT.md section 1), so the eight internet
+/// pod rows are furniture the operator builds once in Manage Site; only the
+/// claims change from session to session.
+///
+/// Two properties are load-bearing and everything here is shaped around them:
+///
+/// 1. ONLY CLAIMED SLOTS APPEAR IN THE FILE. Absence is the unclaimed signal,
+/// so a truncated, stale, unreadable or refused file yields FEWER
+/// participants, never more. Every failure path below therefore degrades to
+/// "no roster", which is byte-for-byte today's arcade behaviour. Museums run
+/// this software; a bad JSON file must never be able to stop a mission that
+/// would otherwise run by hand.
+/// 2. ENABLED IMPLIES CLAIMED, not the reverse. The operator may always
+/// subtract from an applied roster (sit a pilot out); the operator may never
+/// add, because an enabled row nobody claimed puts a dead IP in the mission
+/// egg and the pods sit waiting on a peer that will never boot.
+///
+/// The file is ONE PER LAUNCH GENERATION -- written at the state=launching flip
+/// and not rewritten as the lobby churns -- because pod peer tables are
+/// boot-static: a player whose TeslaLobby crashes is still in every pod's peer
+/// table and still perfectly playable, and a live-tracking roster would evict
+/// that working pod from the mission. Do not "fix" this into a live view.
+///
+/// UI thread only. is cheap enough to call at ~1Hz from the
+/// existing mission timers.
+///
+internal static class SessionRoster
+{
+ /// One claimed slot. Absence of a slot from means nobody claimed it.
+ internal sealed class Claim
+ {
+ internal Claim(int slot, IPAddress address, string pilot, string steamId, bool host)
+ {
+ Slot = slot;
+ Address = address;
+ Pilot = pilot;
+ SteamId = steamId;
+ Host = host;
+ }
+
+ internal int Slot { get; private set; }
+
+ /// The game IP for the slot: 200.0.0.(111 + slot). Frozen, see SESSION-CONTRACT.md section 1.
+ internal IPAddress Address { get; private set; }
+
+ internal string Pilot { get; private set; }
+
+ internal string SteamId { get; private set; }
+
+ /// True for the session host (slot 0). Informational -- the console drives every claim the same way.
+ internal bool Host { get; private set; }
+ }
+
+ private const int SupportedSchema = 1;
+
+ private const int MaxSlots = 8;
+
+ // Slot 0 -> 200.0.0.111 ... slot 7 -> 200.0.0.118. Frozen in
+ // SESSION-CONTRACT.md section 1 and mirrored by the lobby's SlotPlan.cs;
+ // .119/.120 are reserved for the live-review / camera roles, which is why
+ // the block stops at eight.
+ private const byte SlotNetA = 200;
+
+ private const byte SlotNetB = 0;
+
+ private const byte SlotNetC = 0;
+
+ private const int SlotZeroOctet = 111;
+
+ // Pilot-name rules, decided by the operator 2026-07-25. The name becomes an
+ // INI section name in the egg (BTMission builds [BitMap::Large::]) and
+ // the egg is ASCII, so '[', ']' and '=' would corrupt the egg's structure
+ // silently. The lobby sanitizes; the console only re-validates and refuses,
+ // because a name the operator never saw must not be quietly rewritten on its
+ // way into a mission file.
+ private const int MaxPilotNameLength = 12;
+
+ private const string BarredCharacters = "[]=";
+
+ // A roster older than one evening is a leftover from a previous session.
+ private const double MaxAgeHours = 12.0;
+
+ // Sanity bound. The real file is well under 2 KB; anything larger is not ours.
+ private const long MaxFileBytes = 64L * 1024L;
+
+ private static readonly IList sNoClaims = new ReadOnlyCollection(new List());
+
+ private static bool sSeenExists;
+
+ private static DateTime sSeenStamp = DateTime.MinValue;
+
+ private static long sSeenLength = -1L;
+
+ private static bool sParsed;
+
+ private static bool sActive;
+
+ private static int sSchema;
+
+ private static string sSessionId = "";
+
+ private static string sSessionKey = "";
+
+ private static string sGame = "";
+
+ private static bool sAddressShift;
+
+ private static DateTime sWrittenUtc = DateTime.MinValue;
+
+ private static int sWaiting;
+
+ private static string sLoadError = "";
+
+ private static IList sClaims = sNoClaims;
+
+ private static string sLoggedError = "";
+
+ ///
+ /// A roster file is present, parses, and is fresh. NOT "usable": call
+ /// before applying, which is what catches a roster
+ /// for the wrong game, the wrong schema or the wrong addressing mode.
+ ///
+ internal static bool Active => sActive;
+
+ /// Matches steam_session.json. A change means a NEW launch generation, so re-apply.
+ internal static string SessionKey => sSessionKey;
+
+ internal static string SessionId => sSessionId;
+
+ /// "bt" or "rp", as written. Compare case-insensitively.
+ internal static string Game => sGame;
+
+ /// The addressing the session REQUIRES; internet sessions are flat, so this is false.
+ internal static bool AddressShift => sAddressShift;
+
+ internal static DateTime WrittenUtc => sWrittenUtc;
+
+ /// Lobby members holding no slot, for the banner. Not a blocker -- spectators are legal.
+ internal static int WaitingCount => sWaiting;
+
+ /// Empty when nothing is wrong. Operator-facing; show it in the banner, it is why there is no roster.
+ internal static string LoadError => sLoadError;
+
+ // IList, not IReadOnlyList: the read-only interfaces are net45+ and the
+ // XP11 console targets net40. The instance is already immutable.
+ internal static IList Claims => sClaims;
+
+ internal static string FilePath =>
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "Tesla Console", "session_roster.json");
+
+ ///
+ /// Re-reads the roster if the file changed. Returns true when the roster
+ /// state changed and the caller should repaint. Never throws.
+ ///
+ internal static bool Poll()
+ {
+ string before = StateStamp();
+ try
+ {
+ PollCore();
+ }
+ catch (Exception ex)
+ {
+ // Belt and braces: PollCore already catches everything it expects to
+ // fail. Anything reaching here is a surprise, and the answer to a
+ // surprise is still "there is no roster".
+ Unsee();
+ Clear("The session roster could not be read: " + ex.Message);
+ }
+ return StateStamp() != before;
+ }
+
+ /// The claim on an address, or null. The address is the game IP, i.e. what the egg will carry.
+ internal static Claim Find(IPAddress ip)
+ {
+ if (!sActive || ip == null)
+ {
+ return null;
+ }
+ for (int i = 0; i < sClaims.Count; i++)
+ {
+ if (ip.Equals(sClaims[i].Address))
+ {
+ return sClaims[i];
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Gate A. Returns "" when the roster may be applied to this mission
+ /// window, else operator-facing text explaining what to fix.
+ ///
+ /// Returns "" when no roster is active: with no roster the console is the
+ /// 1995 console and must not grow a new way to refuse a mission.
+ /// and are taken
+ /// for call-shape symmetry with ; the checks here need
+ /// only each row's pod (row.Tag).
+ ///
+ internal static string Validate(string game, bool paneShift, DataGridView grid, int enabledCol, int pilotCol)
+ {
+ if (!sActive)
+ {
+ return "";
+ }
+ try
+ {
+ if (sSchema != SupportedSchema)
+ {
+ return $"This session roster is format version {sSchema}; this console reads version {SupportedSchema}. "
+ + "Install matching TeslaLobby and TeslaConsole builds, then start the session again.";
+ }
+ if (!string.IsNullOrEmpty(game) && !string.Equals(game, sGame, StringComparison.OrdinalIgnoreCase))
+ {
+ return $"This session roster is for {GameName(sGame)}. This is a {GameName(game)} mission. "
+ + $"Start a {GameName(game)} session in TeslaLobby, or close this window and open the {GameName(sGame)} one.";
+ }
+ if (paneShift != sAddressShift)
+ {
+ return AddressShiftMessage(paneShift);
+ }
+ if (sClaims.Count == 0)
+ {
+ return "This session roster has no claimed slots. Nobody took a pod in TeslaLobby, so there is nothing to launch.";
+ }
+ StringBuilder problems = new StringBuilder();
+ for (int i = 0; i < sClaims.Count; i++)
+ {
+ Claim claim = sClaims[i];
+ string problem = PilotNameProblem(claim.Pilot);
+ if (problem != null)
+ {
+ problems.AppendLine(string.IsNullOrEmpty(claim.Pilot)
+ ? $"Slot {claim.Slot} has no pilot name. Set one in TeslaLobby and start the session again."
+ : $"Pilot name \"{claim.Pilot}\" (slot {claim.Slot}) {problem}. Fix it in TeslaLobby and start the "
+ + "session again -- the console will not rewrite a name that is about to be written into the mission egg.");
+ continue;
+ }
+ // Ordinal, matching the panes' own duplicate check in
+ // CheckAllValues (BTGame.cs:965), so the two agree on what a
+ // duplicate is.
+ for (int j = i + 1; j < sClaims.Count; j++)
+ {
+ if (string.Equals(claim.Pilot, sClaims[j].Pilot, StringComparison.Ordinal))
+ {
+ problems.AppendLine($"Pilot name \"{claim.Pilot}\" is claimed by slot {claim.Slot} and slot {sClaims[j].Slot}. "
+ + "Pilot names must be unique. Rename one in TeslaLobby and start the session again.");
+ }
+ }
+ }
+ if (grid != null)
+ {
+ for (int i = 0; i < sClaims.Count; i++)
+ {
+ Claim claim = sClaims[i];
+ Pod pod = FindPod(grid, claim.Address, paneShift);
+ if (pod == null)
+ {
+ problems.AppendLine($"No pod is configured at {claim.Address} (slot {claim.Slot}). "
+ + "Add the Internet squad in Manage Site.");
+ }
+ else if (pod.HostType != HostType.GameMachineHostType)
+ {
+ problems.AppendLine($"The pod at {claim.Address} (slot {claim.Slot}) is not a game machine. "
+ + "Internet slots must be Game Machine pods in Manage Site.");
+ }
+ }
+ }
+ return problems.ToString();
+ }
+ catch (Exception ex)
+ {
+ LogOnce("SessionRoster.Validate failed: " + ex);
+ // Fail closed: an active roster we cannot check is not a roster the
+ // operator should be allowed to apply.
+ return "The session roster could not be checked. Enable pods by hand, or restart the console.";
+ }
+ }
+
+ ///
+ /// Fills the mission grid in from the roster: every claimed pod row gets its
+ /// pilot name, is enabled and is connected; every other player row is
+ /// cleared, disabled and released.
+ ///
+ /// Callers gate on first and repaint their own issue
+ /// list afterwards (CheckAllValues) -- this method deliberately knows
+ /// nothing about either pane's UI. Never throws.
+ ///
+ internal static void Apply(DataGridView grid, int enabledCol, int pilotCol, object requestor, bool paneShift)
+ {
+ if (!sActive || grid == null || requestor == null)
+ {
+ return;
+ }
+ try
+ {
+ foreach (DataGridViewRow row in grid.Rows)
+ {
+ Pod pod = row.Tag as Pod;
+ if (pod == null || pod.HostType != HostType.GameMachineHostType)
+ {
+ // Camera and mission-review rows are site furniture, never
+ // lobby slots. Leave them exactly as the operator set them:
+ // the roster describes players, and turning off a recording
+ // host the operator armed would be an edit nobody asked for.
+ continue;
+ }
+ if (enabledCol < 0 || enabledCol >= row.Cells.Count || pilotCol < 0 || pilotCol >= row.Cells.Count)
+ {
+ continue;
+ }
+ Claim claim = MatchClaim(pod, paneShift);
+ if (claim != null)
+ {
+ row.Cells[pilotCol].Value = claim.Pilot;
+ row.Cells[enabledCol].Value = true;
+ // Setting cell values connects NOTHING -- programmatic writes
+ // raise no CellEndEdit. The only thing that opens a pod
+ // connection is MungaGame.MakeRequested, so reproduce the
+ // hand-edit sequence exactly, shift first: assigning
+ // DosBoxAddressShift while connected drops the socket
+ // (MungaGame.cs:120-143), so it must be settled before the
+ // request that dials. Modelled on
+ // mPilotsDataGrid_CellEndEdit, BTGame.cs:1085-1089 (and the
+ // identical RPGame.cs:1195-1199).
+ pod.MungaGame.DosBoxAddressShift = paneShift;
+ pod.MungaGame.MakeRequested(requestor);
+ }
+ else
+ {
+ // The clear-and-release the Delete/Backspace path uses,
+ // BTGame.cs:1102-1105: "" rather than null, so the pane's
+ // blank-name check sees what a hand-cleared cell leaves.
+ row.Cells[pilotCol].Value = "";
+ row.Cells[enabledCol].Value = false;
+ pod.MungaGame.ReleaseRequest(requestor);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ // Rows already processed keep their state; a half-applied roster is
+ // still enabled-implies-claimed, and Issues() is the net under it.
+ LogOnce("SessionRoster.Apply failed: " + ex);
+ }
+ }
+
+ ///
+ /// Gate B. One line per enabled player row that nobody claimed. Empty when
+ /// there is nothing to say -- and always empty with no active roster, which
+ /// is what keeps the arcade path untouched.
+ ///
+ internal static string Issues(DataGridView grid, int enabledCol, bool paneShift)
+ {
+ if (!sActive || grid == null)
+ {
+ return "";
+ }
+ try
+ {
+ StringBuilder issues = new StringBuilder();
+ foreach (DataGridViewRow row in grid.Rows)
+ {
+ Pod pod = row.Tag as Pod;
+ if (pod == null || pod.HostType != HostType.GameMachineHostType || !IsEnabled(row, enabledCol))
+ {
+ continue;
+ }
+ // The pane's shift, matching Apply's signature. It equals
+ // sAddressShift by the time this can matter (Validate refuses a
+ // disagreeing pane before Apply can run), but the join from a row
+ // to a claim is now spelled the same way in both methods -- two
+ // spellings of one rule is how they drift apart later.
+ if (MatchClaim(pod, paneShift) != null)
+ {
+ continue;
+ }
+ IPAddress address = MissionAddress(pod, paneShift);
+ int slot = SlotForAddress(address);
+ string who = (slot >= 0)
+ ? $"Slot {slot} ({address})"
+ : (string.IsNullOrEmpty(pod.Name) ? address.ToString() : $"{pod.Name} ({address})");
+ issues.AppendLine($"{who} is enabled but nobody claimed it in this session.");
+ }
+ return issues.ToString();
+ }
+ catch (Exception ex)
+ {
+ LogOnce("SessionRoster.Issues failed: " + ex);
+ // Fail closed, as in Validate: an active roster we cannot check
+ // against must not be allowed to launch.
+ return "The session roster could not be checked against the enabled pods. Restart the console.";
+ }
+ }
+
+ private static void PollCore()
+ {
+ string path = FilePath;
+ FileInfo info = new FileInfo(path);
+ bool exists = info.Exists;
+ DateTime stamp = exists ? info.LastWriteTimeUtc : DateTime.MinValue;
+ long length = exists ? info.Length : -1L;
+ if (exists == sSeenExists && stamp == sSeenStamp && length == sSeenLength)
+ {
+ // Freshness is time-dependent, not file-dependent: a console left
+ // open overnight must drop a roster that ages out of the window even
+ // though nothing on disk moved.
+ RefreshActive();
+ return;
+ }
+ if (!exists)
+ {
+ See(false, stamp, length);
+ // No file is not an error. This is the arcade path.
+ Clear("");
+ return;
+ }
+ if (length > MaxFileBytes || length <= 0L)
+ {
+ See(true, stamp, length);
+ Clear($"The session roster at {path} is {length} bytes, which is not a roster file. It was ignored.");
+ LogOnce(sLoadError);
+ return;
+ }
+ JObject root;
+ try
+ {
+ root = JObject.Parse(ReadAllTextShared(path));
+ }
+ catch (Exception ex)
+ {
+ // Leave the stat uncommitted so the next tick retries: the usual
+ // cause is a read that raced the lobby's write, and latching that
+ // failure until something touches the file again would strand a
+ // perfectly good roster.
+ Unsee();
+ Clear("The session roster could not be read: " + ex.Message);
+ LogOnce(sLoadError + " (" + path + ")");
+ return;
+ }
+ See(true, stamp, length);
+ Parse(root, path);
+ }
+
+ private static void Parse(JObject root, string path)
+ {
+ int schema = ReadInt(root["schema"], 0);
+ string sessionId = ReadString(root["sessionId"]);
+ string sessionKey = ReadString(root["sessionKey"]);
+ string game = ReadString(root["game"]).Trim();
+ // Missing means flat addressing: that is what an internet session
+ // requires (SESSION-CONTRACT.md section 1) and it is the safe default,
+ // since a wrong "false" is caught by Validate against the pane's flag.
+ bool addressShift = ReadBool(root["addressShift"], defaultValue: false);
+ int waiting = ReadInt(root["waiting"], 0);
+
+ DateTime writtenUtc;
+ if (!ReadUtc(root["writtenUtc"], out writtenUtc))
+ {
+ Clear("The session roster has no usable writtenUtc timestamp, so its age cannot be checked. It was ignored.");
+ LogOnce(sLoadError + " (" + path + ")");
+ return;
+ }
+
+ JArray slots = root["slots"] as JArray;
+ if (slots == null)
+ {
+ Clear("The session roster has no slots list. It was ignored.");
+ LogOnce(sLoadError + " (" + path + ")");
+ return;
+ }
+
+ List claims = new List();
+ bool[] seen = new bool[MaxSlots];
+ foreach (JToken token in slots)
+ {
+ JObject entry = token as JObject;
+ if (entry == null)
+ {
+ Refuse("a slot entry is not an object", path);
+ return;
+ }
+ int slot;
+ if (!ReadIntStrict(entry["slot"], out slot) || slot < 0 || slot >= MaxSlots)
+ {
+ Refuse($"a slot entry has an out-of-range or unreadable slot number ({ReadString(entry["slot"])})", path);
+ return;
+ }
+ if (seen[slot])
+ {
+ Refuse($"slot {slot} is claimed twice", path);
+ return;
+ }
+ seen[slot] = true;
+ IPAddress address;
+ if (!IPAddress.TryParse(ReadString(entry["ip"]), out address))
+ {
+ Refuse($"slot {slot} has an unreadable ip", path);
+ return;
+ }
+ // The mapping is frozen. A disagreement here means the lobby and the
+ // console do not share an addressing plan, and every downstream
+ // decision -- which row to enable, which slot to name in an issue --
+ // would be built on a guess. Refuse the file and let the operator
+ // enable pods by hand instead.
+ IPAddress expected = AddressForSlot(slot);
+ if (!expected.Equals(address))
+ {
+ Refuse($"slot {slot} claims {address}, but slot {slot} is {expected}", path);
+ return;
+ }
+ claims.Add(new Claim(slot, address, ReadString(entry["pilot"]), ReadString(entry["steamId"]),
+ ReadBool(entry["host"], defaultValue: false)));
+ }
+
+ sParsed = true;
+ sSchema = schema;
+ sSessionId = sessionId;
+ sSessionKey = sessionKey;
+ sGame = game;
+ sAddressShift = addressShift;
+ sWrittenUtc = writtenUtc;
+ sWaiting = waiting;
+ sClaims = new ReadOnlyCollection(claims);
+ sLoadError = "";
+ RefreshActive();
+ }
+
+ /// Structural refusals: the file contradicts itself, so none of it is trusted.
+ private static void Refuse(string reason, string path)
+ {
+ Clear($"The session roster was ignored: {reason}. Enable pods by hand for this mission.");
+ LogOnce(sLoadError + " (" + path + ")");
+ }
+
+ ///
+ /// Applies the freshness window to already-parsed data. Split out of
+ /// because age changes with the clock, not with the file.
+ ///
+ private static void RefreshActive()
+ {
+ if (!sParsed)
+ {
+ return;
+ }
+ // Absolute difference: a timestamp far in the future is clock skew
+ // between the lobby machine and this one, which is no more trustworthy
+ // than one from last night.
+ double hours = Math.Abs((DateTime.UtcNow - sWrittenUtc).TotalHours);
+ if (hours > MaxAgeHours)
+ {
+ string stamp = sWrittenUtc.ToString("u", CultureInfo.InvariantCulture);
+ Clear($"The session roster was written {stamp} and is more than {(int)MaxAgeHours} hours old. "
+ + "It is left over from an earlier session and was ignored.");
+ return;
+ }
+ sActive = true;
+ sLoadError = "";
+ }
+
+ private static void Clear(string error)
+ {
+ sParsed = false;
+ sActive = false;
+ sSchema = 0;
+ sSessionId = "";
+ sSessionKey = "";
+ sGame = "";
+ sAddressShift = false;
+ sWrittenUtc = DateTime.MinValue;
+ sWaiting = 0;
+ sClaims = sNoClaims;
+ sLoadError = error;
+ }
+
+ private static void See(bool exists, DateTime stamp, long length)
+ {
+ sSeenExists = exists;
+ sSeenStamp = stamp;
+ sSeenLength = length;
+ }
+
+ /// Forgets the stat so the next Poll re-reads the same file.
+ private static void Unsee()
+ {
+ sSeenExists = false;
+ sSeenStamp = DateTime.MinValue;
+ sSeenLength = -1L;
+ }
+
+ private static string ReadAllTextShared(string path)
+ {
+ // FileShare.ReadWrite | Delete: the lobby may still hold the file open,
+ // and a sharing violation here would read as "no roster" for a whole
+ // second. UTF-8 without BOM per the contract; BOM detection costs
+ // nothing and forgives a writer that adds one.
+ using (FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
+ {
+ using (StreamReader reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
+ {
+ return reader.ReadToEnd();
+ }
+ }
+ }
+
+ private static string StateStamp()
+ {
+ return string.Concat(sActive ? "1|" : "0|", sSessionKey, "|", sGame, "|",
+ sWrittenUtc.ToString("u", CultureInfo.InvariantCulture), "|",
+ sClaims.Count.ToString(CultureInfo.InvariantCulture), "|",
+ sWaiting.ToString(CultureInfo.InvariantCulture), "|", sLoadError);
+ }
+
+ private static void LogOnce(string message)
+ {
+ // Once per distinct message: Poll runs at 1Hz and a permanently broken
+ // file would otherwise fill ExceptionLog.txt overnight.
+ if (sLoggedError == message)
+ {
+ return;
+ }
+ sLoggedError = message;
+ Program.LogMessage(message);
+ }
+
+ /// The address this pod will carry into the mission -- MissionAddress() in both game panes.
+ private static IPAddress MissionAddress(Pod pod, bool shift)
+ {
+ return shift ? DosBox.ShiftAddress(pod.IPAddress) : pod.IPAddress;
+ }
+
+ private static Claim MatchClaim(Pod pod, bool shift)
+ {
+ if (pod == null || pod.IPAddress == null)
+ {
+ return null;
+ }
+ // Join on the mission address, not the raw site-config address: the
+ // claim's IP is the game IP, which is what the egg gets. With flat
+ // addressing (every internet session) the two are the same value.
+ return Find(MissionAddress(pod, shift));
+ }
+
+ private static Pod FindPod(DataGridView grid, IPAddress address, bool shift)
+ {
+ foreach (DataGridViewRow row in grid.Rows)
+ {
+ Pod pod = row.Tag as Pod;
+ if (pod != null && pod.IPAddress != null && address.Equals(MissionAddress(pod, shift)))
+ {
+ return pod;
+ }
+ }
+ return null;
+ }
+
+ private static bool IsEnabled(DataGridViewRow row, int enabledCol)
+ {
+ if (enabledCol < 0 || enabledCol >= row.Cells.Count)
+ {
+ return false;
+ }
+ object value = row.Cells[enabledCol].Value;
+ return value is bool && (bool)value;
+ }
+
+ private static IPAddress AddressForSlot(int slot)
+ {
+ return new IPAddress(new byte[4] { SlotNetA, SlotNetB, SlotNetC, (byte)(SlotZeroOctet + slot) });
+ }
+
+ /// The slot an address belongs to, or -1 when it is outside the internet block.
+ private static int SlotForAddress(IPAddress address)
+ {
+ if (address == null)
+ {
+ return -1;
+ }
+ byte[] octets = address.GetAddressBytes();
+ if (octets.Length != 4 || octets[0] != SlotNetA || octets[1] != SlotNetB || octets[2] != SlotNetC)
+ {
+ return -1;
+ }
+ int slot = octets[3] - SlotZeroOctet;
+ return (slot >= 0 && slot < MaxSlots) ? slot : -1;
+ }
+
+ private static string GameName(string game)
+ {
+ if (string.Equals(game, "bt", StringComparison.OrdinalIgnoreCase))
+ {
+ return "BattleTech";
+ }
+ if (string.Equals(game, "rp", StringComparison.OrdinalIgnoreCase))
+ {
+ return "Red Planet";
+ }
+ return string.IsNullOrEmpty(game) ? "an unnamed game" : "\"" + game + "\"";
+ }
+
+ private static string AddressShiftMessage(bool paneShift)
+ {
+ // This mismatch is the silent hang this whole path exists to kill: the
+ // console dials pod+100, nothing answers, and the mission never starts.
+ // The part operators get wrong is that the pane LATCHES the flag when
+ // the mission window opens (BTGame.cs:185 / RPGame.cs:179 read the
+ // default into a readonly field at construction), so changing the
+ // default with the window open does nothing at all. Say so.
+ string state = paneShift ? "ON" : "OFF";
+ string wanted = sAddressShift ? "ON" : "OFF";
+ return $"This session needs the +100 DOSBox address shift {wanted}, but this mission window has it {state}. "
+ + $"Turn \"Shift all pod IPs +100 (DOSBox-X preservation build)\" {wanted} in the Defaults dialog, "
+ + "then CLOSE AND REOPEN this mission window -- the shift is latched when the window opens, "
+ + "so changing the default alone will not take effect.";
+ }
+
+ ///
+ /// Why this pilot name is unusable, or null when it is clean. Mirrors the
+ /// lobby's sanitizer; the console refuses rather than rewrites, so the name
+ /// the player saw in the lobby is the name that reaches the egg.
+ ///
+ private static string PilotNameProblem(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ return "is blank";
+ }
+ if (name.Length > MaxPilotNameLength)
+ {
+ return $"is longer than {MaxPilotNameLength} characters";
+ }
+ for (int i = 0; i < name.Length; i++)
+ {
+ char c = name[i];
+ if (BarredCharacters.IndexOf(c) >= 0)
+ {
+ return $"contains '{c}', which the mission egg cannot carry -- the pilot name becomes an INI section "
+ + "name, and '[', ']' and '=' corrupt the egg's structure silently";
+ }
+ if (c < ' ' || c > '~')
+ {
+ return "contains a character that is not printable ASCII";
+ }
+ }
+ if (name != name.Trim())
+ {
+ return "has leading or trailing spaces";
+ }
+ if (name.IndexOf(" ", StringComparison.Ordinal) >= 0)
+ {
+ return "has a doubled space";
+ }
+ return null;
+ }
+
+ private static string ReadString(JToken token)
+ {
+ if (token == null || token.Type == JTokenType.Null || token.Type == JTokenType.Undefined)
+ {
+ return "";
+ }
+ if (token.Type == JTokenType.Object || token.Type == JTokenType.Array)
+ {
+ return "";
+ }
+ // SteamID64s are written as strings but survive being written as numbers.
+ return token.ToString();
+ }
+
+ private static int ReadInt(JToken token, int defaultValue)
+ {
+ int value;
+ return ReadIntStrict(token, out value) ? value : defaultValue;
+ }
+
+ private static bool ReadIntStrict(JToken token, out int value)
+ {
+ value = 0;
+ if (token == null)
+ {
+ return false;
+ }
+ if (token.Type == JTokenType.Integer)
+ {
+ try
+ {
+ value = token.Value();
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+ return token.Type == JTokenType.String
+ && int.TryParse(token.Value(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
+ }
+
+ private static bool ReadBool(JToken token, bool defaultValue)
+ {
+ if (token == null)
+ {
+ return defaultValue;
+ }
+ if (token.Type == JTokenType.Boolean)
+ {
+ return token.Value();
+ }
+ if (token.Type == JTokenType.String)
+ {
+ bool value;
+ return bool.TryParse(token.Value(), out value) ? value : defaultValue;
+ }
+ return defaultValue;
+ }
+
+ private static bool ReadUtc(JToken token, out DateTime value)
+ {
+ value = DateTime.MinValue;
+ if (token != null && token.Type == JTokenType.Date)
+ {
+ // Newtonsoft already parsed it; normalise the kind rather than trust it.
+ value = token.Value().ToUniversalTime();
+ return true;
+ }
+ string text = ReadString(token);
+ if (string.IsNullOrEmpty(text))
+ {
+ return false;
+ }
+ DateTime parsed;
+ if (!DateTime.TryParse(text, CultureInfo.InvariantCulture,
+ DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out parsed))
+ {
+ return false;
+ }
+ value = parsed;
+ return true;
+ }
+}
diff --git a/vPOD/LauncherRpcServer.cs b/vPOD/LauncherRpcServer.cs
index d3e005b..d00f8fa 100644
--- a/vPOD/LauncherRpcServer.cs
+++ b/vPOD/LauncherRpcServer.cs
@@ -40,6 +40,7 @@ internal sealed class LauncherRpcServer
private readonly VirtualLauncher mLauncher;
private readonly int mPort;
+ private readonly IPAddress mBind; // null = every interface (the default)
private byte[] mSessionKey;
private TcpListener mListener;
private Thread mAcceptThread;
@@ -53,10 +54,11 @@ internal sealed class LauncherRpcServer
public bool IsListening => mRunning;
- public LauncherRpcServer(VirtualLauncher launcher, int port = ManagePort)
+ public LauncherRpcServer(VirtualLauncher launcher, int port = ManagePort, IPAddress bind = null)
{
mLauncher = launcher;
mPort = port;
+ mBind = bind; // null keeps the historical every-interface behaviour
}
/// Starts listening with the given provisioned session key. Throws if
@@ -68,12 +70,14 @@ internal sealed class LauncherRpcServer
return;
}
mSessionKey = sessionKey;
- mListener = new TcpListener(IPAddress.Any, mPort);
+ mListener = new TcpListener(mBind ?? IPAddress.Any, mPort);
mListener.Start();
mRunning = true;
mAcceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "vPOD-launcher-accept" };
mAcceptThread.Start();
- Log?.Invoke($"Launcher RPC listening on TCP {mPort}.");
+ Log?.Invoke(mBind == null
+ ? $"Launcher RPC listening on TCP {mPort}."
+ : $"Launcher RPC listening on TCP {mPort} ({mBind} only).");
}
public void Stop()
diff --git a/vPOD/MungaPodServer.cs b/vPOD/MungaPodServer.cs
index 10dfb05..b21322f 100644
--- a/vPOD/MungaPodServer.cs
+++ b/vPOD/MungaPodServer.cs
@@ -28,6 +28,7 @@ internal sealed class MungaPodServer
}
private readonly int mPort;
+ private readonly IPAddress mBind; // null = every interface (the default)
private TcpListener mListener;
private Thread mAcceptThread;
private volatile bool mRunning;
@@ -54,8 +55,16 @@ internal sealed class MungaPodServer
}
public MungaPodServer(int port)
+ : this(port, null)
+ {
+ }
+
+ // bind == null keeps the historical every-interface behaviour, so existing
+ // callers and single-pod runs are unchanged.
+ public MungaPodServer(int port, IPAddress bind)
{
mPort = port;
+ mBind = bind;
}
public void Start()
@@ -64,12 +73,14 @@ internal sealed class MungaPodServer
{
return;
}
- mListener = new TcpListener(IPAddress.Any, mPort);
+ mListener = new TcpListener(mBind ?? IPAddress.Any, mPort);
mListener.Start();
mRunning = true;
mAcceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "vPOD-accept" };
mAcceptThread.Start();
- Log?.Invoke($"Listening on TCP {mPort} (all interfaces).");
+ Log?.Invoke(mBind == null
+ ? $"Listening on TCP {mPort} (all interfaces)."
+ : $"Listening on TCP {mPort} ({mBind} only).");
}
public void Stop()
diff --git a/vPOD/PodArguments.cs b/vPOD/PodArguments.cs
index 5335f5d..d46f382 100644
--- a/vPOD/PodArguments.cs
+++ b/vPOD/PodArguments.cs
@@ -1,4 +1,5 @@
using System;
+using System.Net;
using Munga.Net;
namespace VPod;
@@ -18,11 +19,35 @@ namespace VPod;
/// vPOD-only (not a real game-client option):
/// -nomanage disable the virtual launcher / site-management side
/// (no provisioning beacons, no TCP 53290 listener)
+/// -bind <ip> listen on ONE address instead of every interface.
+/// Both listeners default to IPAddress.Any, which means
+/// the second vPOD on a machine loses the port and the
+/// console can only ever see one fake pod. Binding each
+/// instance to its own address lets a whole roster run
+/// side by side -- 200.0.0.111..118 are the internet
+/// session's slot addresses, so an eight-pod session can
+/// be exercised end to end with no cockpits and nobody
+/// else in the room. Add the aliases first, e.g.
+/// netsh interface ipv4 add address "Loopback" 200.0.0.113 255.255.255.0
+///
+/// DO NOT leave an unbound vPOD running alongside a bound
+/// roster. Verified on this box 2026-07-25: Windows lets
+/// IPAddress.Any bind the SAME port while specific
+/// addresses already hold it (Linux would refuse). The
+/// specific listeners still win for their own addresses,
+/// but the unbound one silently swallows every address no
+/// one claimed -- so a slot you thought was absent answers
+/// anyway, which is precisely the stale-pod confusion the
+/// console's claim gate exists to catch. Bind all of them
+/// or none of them.
///
internal sealed class PodArguments
{
public int Port { get; private set; } = MungaSocket.ConsolePort; // 1501
+ /// Address both listeners bind to; null = every interface (default).
+ public IPAddress Bind { get; private set; }
+
public ApplicationID Application { get; private set; } = ApplicationID.RPL4;
public HostType HostType { get; private set; } = HostType.GameMachineHostType;
@@ -46,6 +71,16 @@ internal sealed class PodArguments
i++;
}
break;
+ case "-bind":
+ // Unparseable means "every interface" rather than a hard failure:
+ // vPOD is a test tool and a typo here should not stop the run, but
+ // it must be visible -- Program logs the resolved bind at startup.
+ if (i + 1 < args.Length && IPAddress.TryParse(args[i + 1], out IPAddress bind))
+ {
+ result.Bind = bind;
+ i++;
+ }
+ break;
case "-lc":
result.HostType = HostType.MissionReviewHostType;
break;
diff --git a/vPOD/VPodForm.cs b/vPOD/VPodForm.cs
index 516bcdb..b148678 100644
--- a/vPOD/VPodForm.cs
+++ b/vPOD/VPodForm.cs
@@ -72,10 +72,10 @@ internal sealed class VPodForm : Form
public VPodForm(PodArguments options)
{
mOptions = options;
- mServer = new MungaPodServer(options.Port);
+ mServer = new MungaPodServer(options.Port, options.Bind);
mSimulator = new PodSimulator(mServer, options.Application, options.HostId);
mLauncher = new VirtualLauncher();
- mRpcServer = new LauncherRpcServer(mLauncher);
+ mRpcServer = new LauncherRpcServer(mLauncher, LauncherRpcServer.ManagePort, options.Bind);
mProvisioning = new PodProvisioning(PodProvisioning.MacForHost(options.HostId));
BuildUi();