Console: internet-session roster from TeslaLobby; vPOD per-address bind

The eight internet pod rows are furniture the operator builds once in
Manage Site — the slot-to-IP map is frozen — but which of those slots a
human actually claimed changes every session. New SessionRoster reads the
roster TeslaLobby writes at the state=launching flip, and both game panes
grow a session strip above Mission Properties: a banner (session key, game,
slots claimed, waiting, written-at), Apply Session, and the Reset Pods that
until now existed only as a right-click on a Go button that is disabled
exactly when the reset is wanted.

Two properties shape all of it. Only claimed slots appear in the file, so
absence is the unclaimed signal and every failure path — truncated, stale,
unreadable, refused — degrades to "no roster", which is byte-for-byte
today's arcade behaviour; museums run this software and a bad JSON file
must never stop a mission that would otherwise run by hand. And enabled
implies claimed, not the reverse: the operator may always sit a pilot out,
never add one, because an enabled row nobody claimed puts a dead IP in the
egg and the pods then wait on a peer that will never boot. Roster issues
join the pane's existing issue text, so the Go button is still the gate.

The roster is one file per launch generation and deliberately not a live
view: pod peer tables are boot-static, so a player whose lobby crashed is
still in every pod's table and still playable, and live tracking would
evict that working pod mid-session. Poll runs at ~1Hz off the existing
network timer with its own deadline, and the strips are built at runtime —
InitializeComponent is decompiled 1995 designer output that the
differential tests compare literally.

Go/Load also re-checks CheckAllValues at the click instead of trusting the
last status tick: a pod that died in that gap went straight into the
mission, and the post-Load barrier in NetworkScan then waited forever for a
WaitingForLaunch that never arrives.

vPOD gains -bind <ip>. Both listeners defaulted to IPAddress.Any, so a
second vPOD on the machine lost the port and the console could only ever
see one fake pod; binding each instance to its own address runs a whole
eight-pod session side by side with no cockpits. Bind all of them or none —
Windows lets IPAddress.Any take a port that specific addresses already
hold, and the unbound instance then answers for every slot nobody claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-28 13:14:38 -05:00
co-authored by Claude Opus 5
parent 3d186293bf
commit 33f734da2d
7 changed files with 1228 additions and 7 deletions
+156
View File
@@ -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();
}
/// <summary>
/// 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.
/// </summary>
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<string, string> 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<string, string>)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();
}
+158
View File
@@ -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();
}
/// <summary>
/// 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.
/// </summary>
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<string, string>)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();
}
+857
View File
@@ -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;
/// <summary>
/// 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
/// (<c>emulator\steam\SESSION-CONTRACT.md</c> 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. <see cref="Poll"/> is cheap enough to call at ~1Hz from the
/// existing mission timers.
/// </summary>
internal static class SessionRoster
{
/// <summary>One claimed slot. Absence of a slot from <see cref="Claims"/> means nobody claimed it.</summary>
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; }
/// <summary>The game IP for the slot: 200.0.0.(111 + slot). Frozen, see SESSION-CONTRACT.md section 1.</summary>
internal IPAddress Address { get; private set; }
internal string Pilot { get; private set; }
internal string SteamId { get; private set; }
/// <summary>True for the session host (slot 0). Informational -- the console drives every claim the same way.</summary>
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::<name>]) 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<Claim> sNoClaims = new ReadOnlyCollection<Claim>(new List<Claim>());
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<Claim> sClaims = sNoClaims;
private static string sLoggedError = "";
/// <summary>
/// A roster file is present, parses, and is fresh. NOT "usable": call
/// <see cref="Validate"/> before applying, which is what catches a roster
/// for the wrong game, the wrong schema or the wrong addressing mode.
/// </summary>
internal static bool Active => sActive;
/// <summary>Matches steam_session.json. A change means a NEW launch generation, so re-apply.</summary>
internal static string SessionKey => sSessionKey;
internal static string SessionId => sSessionId;
/// <summary>"bt" or "rp", as written. Compare case-insensitively.</summary>
internal static string Game => sGame;
/// <summary>The addressing the session REQUIRES; internet sessions are flat, so this is false.</summary>
internal static bool AddressShift => sAddressShift;
internal static DateTime WrittenUtc => sWrittenUtc;
/// <summary>Lobby members holding no slot, for the banner. Not a blocker -- spectators are legal.</summary>
internal static int WaitingCount => sWaiting;
/// <summary>Empty when nothing is wrong. Operator-facing; show it in the banner, it is why there is no roster.</summary>
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<Claim> Claims => sClaims;
internal static string FilePath =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Tesla Console", "session_roster.json");
/// <summary>
/// Re-reads the roster if the file changed. Returns true when the roster
/// state changed and the caller should repaint. Never throws.
/// </summary>
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;
}
/// <summary>The claim on an address, or null. The address is the game IP, i.e. what the egg will carry.</summary>
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;
}
/// <summary>
/// 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.
/// <paramref name="enabledCol"/> and <paramref name="pilotCol"/> are taken
/// for call-shape symmetry with <see cref="Apply"/>; the checks here need
/// only each row's pod (row.Tag).
/// </summary>
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.";
}
}
/// <summary>
/// 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 <see cref="Validate"/> first and repaint their own issue
/// list afterwards (CheckAllValues) -- this method deliberately knows
/// nothing about either pane's UI. Never throws.
/// </summary>
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);
}
}
/// <summary>
/// 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.
/// </summary>
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<Claim> claims = new List<Claim>();
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<Claim>(claims);
sLoadError = "";
RefreshActive();
}
/// <summary>Structural refusals: the file contradicts itself, so none of it is trusted.</summary>
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 + ")");
}
/// <summary>
/// Applies the freshness window to already-parsed data. Split out of
/// <see cref="Parse"/> because age changes with the clock, not with the file.
/// </summary>
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;
}
/// <summary>Forgets the stat so the next Poll re-reads the same file.</summary>
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);
}
/// <summary>The address this pod will carry into the mission -- MissionAddress() in both game panes.</summary>
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) });
}
/// <summary>The slot an address belongs to, or -1 when it is outside the internet block.</summary>
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.";
}
/// <summary>
/// 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.
/// </summary>
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<int>();
return true;
}
catch
{
return false;
}
}
return token.Type == JTokenType.String
&& int.TryParse(token.Value<string>(), 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<bool>();
}
if (token.Type == JTokenType.String)
{
bool value;
return bool.TryParse(token.Value<string>(), 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<DateTime>().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;
}
}