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();