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