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; } }