diff --git a/.gitignore b/.gitignore
index 33d4ace..b286f15 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,7 @@ __pycache__/
/410console/410consoleArchive.sit
/410console/4_10-console-extracted/
/410console/.finf/
+
+# vPOD: the packed dist zip is tracked (deployable via Install Product), but the
+# intermediate stage folder pack.ps1 lays out before zipping is not.
+/Console/vPOD/dist/vPOD/
diff --git a/Console/RedPlanet/Apps.xml b/Console/RedPlanet/Apps.xml
index ec28749..206ed18 100644
--- a/Console/RedPlanet/Apps.xml
+++ b/Console/RedPlanet/Apps.xml
@@ -111,4 +111,33 @@
autoRestart="true"
hostType="None" />
+
+
+
+
+
+
+
diff --git a/Console/TeslaConsole.csproj b/Console/TeslaConsole.csproj
index 5e7cdfe..6d52eff 100644
--- a/Console/TeslaConsole.csproj
+++ b/Console/TeslaConsole.csproj
@@ -45,6 +45,10 @@
+
+
+
+
diff --git a/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs b/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs
index 814005e..278c30a 100644
--- a/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs
+++ b/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs
@@ -25,8 +25,8 @@ namespace TeslaConsole.DiffTests
=> _fx.Recovered.Run("CatalogEntry", new[] { _catalog, launchKey, w, h });
[Fact]
- public void Catalog_Has_Four_Products_And_Eight_Entries()
- => Assert.Equal("products=4;entries=8",
+ public void Catalog_Has_Five_Products_And_Eleven_Entries()
+ => Assert.Equal("products=5;entries=11",
_fx.Recovered.Run("CatalogSummary", new[] { _catalog }));
[Fact]
@@ -100,5 +100,31 @@ namespace TeslaConsole.DiffTests
=> Assert.Equal(
@"BattleTech 4.11 MR|2e9b8628-9c20-42fb-b070-e9c38d521082|C:\Games\BT411\btl4.exe|-net 1501 -mr|C:\Games\BT411|True",
Entry("2E9B8628-9C20-42FB-B070-E9C38D521082"));
+
+ // vPOD (Virtual Pod) — the game-client stand-in for testing the consoles.
+
+ [Fact]
+ public void VPod_GameClient_Matches_Expected()
+ => Assert.Equal(
+ @"vPOD|0041c870-6e5e-4f3b-9782-f94f2f76f21d|C:\Games\vPOD\vPOD.exe|-net 1501|C:\Games\vPOD|True",
+ Entry("0041C870-6E5E-4F3B-9782-F94F2F76F21D"));
+
+ [Fact]
+ public void VPod_GameClient_With_Resolution_Matches_Expected()
+ => Assert.Equal(
+ @"vPOD|0041c870-6e5e-4f3b-9782-f94f2f76f21d|C:\Games\vPOD\vPOD.exe|-net 1501 -res 1024 768|C:\Games\vPOD|True",
+ Entry("0041C870-6E5E-4F3B-9782-F94F2F76F21D", "1024", "768"));
+
+ [Fact]
+ public void VPod_LiveCamera_Matches_Expected()
+ => Assert.Equal(
+ @"vPOD LC|ea0d4129-8950-428d-8399-e6a77d2d566a|C:\Games\vPOD\vPOD.exe|-net 1501 -lc|C:\Games\vPOD|True",
+ Entry("EA0D4129-8950-428D-8399-E6A77D2D566A"));
+
+ [Fact]
+ public void VPod_MissionReview_Matches_Expected()
+ => Assert.Equal(
+ @"vPOD MR|fc7ce34e-f4fe-4218-84cd-b13a6fa58e57|C:\Games\vPOD\vPOD.exe|-net 1501 -mr|C:\Games\vPOD|True",
+ Entry("FC7CE34E-F4FE-4218-84CD-B13A6FA58E57"));
}
}
diff --git a/Console/vPOD/MungaPodServer.cs b/Console/vPOD/MungaPodServer.cs
new file mode 100644
index 0000000..10dfb05
--- /dev/null
+++ b/Console/vPOD/MungaPodServer.cs
@@ -0,0 +1,254 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using Munga.Net;
+
+namespace VPod;
+
+///
+/// The server side of the Munga control protocol. A real game client is the
+/// Munga server (the console Connect()s to pod:1501); the vendored
+/// only implements the client half, so this
+/// reimplements the identical framing for the listening side.
+///
+/// Wire format (mirrors MungaSocket.Send/Receive, little-endian):
+/// [16-byte NetworkPacketHeader][MungaMessage: 4=len 4=id 4=flags + body]
+/// where the message length field counts the 12-byte base but not the header.
+/// Messages are dispatched to the vendored typed classes by ClientID+MessageID.
+///
+internal sealed class MungaPodServer
+{
+ /// A message received from the console, plus the header it arrived under.
+ public sealed class Incoming
+ {
+ public NetworkPacketHeader Header;
+ public MungaMessage Message;
+ }
+
+ private readonly int mPort;
+ private TcpListener mListener;
+ private Thread mAcceptThread;
+ private volatile bool mRunning;
+
+ private readonly object mClientLock = new object();
+ private TcpClient mClient;
+ private NetworkStream mStream;
+
+ public event Action MessageReceived;
+ public event Action ConnectionChanged; // remote endpoint string, or null when dropped
+ public event Action Log;
+
+ public bool IsListening => mRunning;
+
+ public bool IsConnected
+ {
+ get
+ {
+ lock (mClientLock)
+ {
+ return mClient != null && mClient.Connected;
+ }
+ }
+ }
+
+ public MungaPodServer(int port)
+ {
+ mPort = port;
+ }
+
+ public void Start()
+ {
+ if (mRunning)
+ {
+ return;
+ }
+ mListener = new TcpListener(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).");
+ }
+
+ public void Stop()
+ {
+ mRunning = false;
+ try { mListener?.Stop(); } catch { }
+ DropClient();
+ Log?.Invoke("Stopped listening.");
+ }
+
+ private void AcceptLoop()
+ {
+ while (mRunning)
+ {
+ TcpClient client;
+ try
+ {
+ client = mListener.AcceptTcpClient();
+ }
+ catch
+ {
+ break; // listener stopped
+ }
+ // One console at a time: replace any prior connection.
+ DropClient();
+ lock (mClientLock)
+ {
+ mClient = client;
+ mClient.NoDelay = true;
+ mStream = mClient.GetStream();
+ }
+ string remote = client.Client.RemoteEndPoint?.ToString() ?? "?";
+ Log?.Invoke($"Console connected from {remote}.");
+ ConnectionChanged?.Invoke(remote);
+ ReceiveLoop(client);
+ Log?.Invoke("Console disconnected.");
+ ConnectionChanged?.Invoke(null);
+ }
+ }
+
+ private void ReceiveLoop(TcpClient client)
+ {
+ try
+ {
+ NetworkStream stream = client.GetStream();
+ while (mRunning && client.Connected)
+ {
+ Incoming incoming = ReadMessage(stream);
+ if (incoming == null)
+ {
+ break;
+ }
+ MessageReceived?.Invoke(incoming);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log?.Invoke("Receive error: " + ex.Message);
+ }
+ finally
+ {
+ DropClient();
+ }
+ }
+
+ private static Incoming ReadMessage(NetworkStream stream)
+ {
+ byte[] headerBytes = ReadExact(stream, NetworkPacketHeader.PacketHeaderSize); // 16
+ if (headerBytes == null)
+ {
+ return null;
+ }
+ NetworkPacketHeader header = new NetworkPacketHeader(headerBytes);
+
+ byte[] baseBytes = ReadExact(stream, MungaMessage.BaseMessageSize); // 12
+ if (baseBytes == null)
+ {
+ return null;
+ }
+ MungaMessage probe = new MungaMessage(new BinaryReader(new MemoryStream(baseBytes)), header.ClientID);
+ int total = probe.MessageLength; // includes the 12-byte base
+ byte[] full = new byte[total];
+ Buffer.BlockCopy(baseBytes, 0, full, 0, MungaMessage.BaseMessageSize);
+ if (total > MungaMessage.BaseMessageSize)
+ {
+ byte[] body = ReadExact(stream, total - MungaMessage.BaseMessageSize);
+ if (body == null)
+ {
+ return null;
+ }
+ Buffer.BlockCopy(body, 0, full, MungaMessage.BaseMessageSize, body.Length);
+ }
+
+ BinaryReader reader = new BinaryReader(new MemoryStream(full));
+ MungaMessage message = Dispatch(header.ClientID, probe.MessageID, reader);
+ return new Incoming { Header = header, Message = message };
+ }
+
+ /// Maps a (ClientID, MessageID) to the vendored typed message. Mirrors MungaSocket.Receive.
+ private static MungaMessage Dispatch(ClientID clientId, int messageId, BinaryReader reader)
+ {
+ switch (clientId)
+ {
+ case ClientID.NetworkManagerClientID:
+ switch (messageId)
+ {
+ case 3: return new EggFileMessage(reader);
+ case 4: return new AcknowledgeEggFileMessage(reader);
+ }
+ break;
+ case ClientID.ApplicationClientID:
+ switch (messageId)
+ {
+ case 3: return new StateQueryMessage(reader);
+ case 4: return new CheckLoadMessage(reader);
+ case 5: return new RunMissionMessage(reader);
+ case 6: return new StopMissionMessage(reader);
+ case 8: return new SuspendMissionMessage(reader);
+ case 9: return new ResumeMissionMessage(reader);
+ case 10: return new LoadMissionMessage(reader);
+ case 11: return new AbortMissionMessage(reader);
+ case 12: return new LightsOutMissionMessage(reader);
+ }
+ break;
+ }
+ return null; // unknown/ignored message id
+ }
+
+ /// Sends a pod->console message on the current connection (no-op if disconnected).
+ public void Send(MungaMessage message, int gameId, int fromHost)
+ {
+ lock (mClientLock)
+ {
+ if (mStream == null || mClient == null || !mClient.Connected)
+ {
+ return;
+ }
+ try
+ {
+ NetworkPacketHeader header = new NetworkPacketHeader(message.ClientID, gameId, fromHost, Environment.TickCount);
+ MemoryStream ms = new MemoryStream();
+ BinaryWriter writer = new BinaryWriter(ms);
+ header.WriteTo(writer);
+ message.WriteTo(writer);
+ byte[] bytes = ms.ToArray();
+ mStream.Write(bytes, 0, bytes.Length);
+ mStream.Flush();
+ }
+ catch (Exception ex)
+ {
+ Log?.Invoke("Send error: " + ex.Message);
+ }
+ }
+ }
+
+ private void DropClient()
+ {
+ lock (mClientLock)
+ {
+ try { mStream?.Dispose(); } catch { }
+ try { mClient?.Close(); } catch { }
+ mStream = null;
+ mClient = null;
+ }
+ }
+
+ private static byte[] ReadExact(NetworkStream stream, int count)
+ {
+ byte[] buffer = new byte[count];
+ int offset = 0;
+ while (offset < count)
+ {
+ int read = stream.Read(buffer, offset, count - offset);
+ if (read == 0)
+ {
+ return null; // connection closed
+ }
+ offset += read;
+ }
+ return buffer;
+ }
+}
diff --git a/Console/vPOD/PodArguments.cs b/Console/vPOD/PodArguments.cs
new file mode 100644
index 0000000..163269c
--- /dev/null
+++ b/Console/vPOD/PodArguments.cs
@@ -0,0 +1,97 @@
+using System;
+using Munga.Net;
+
+namespace VPod;
+
+///
+/// The launch options a real game client understands, parsed from the command
+/// line the console/launcher starts it with. vPOD accepts the same shape so it
+/// is a drop-in stand-in:
+/// -net <port> Munga control port (default 1501)
+/// -lc live-camera role (cosmetic here; state model is identical)
+/// -mr mission-review role
+/// -res W H resolution (ignored)
+/// -app rp|bt which ApplicationID to report (RP by default; also
+/// switchable live in the UI)
+/// -host <id> the responding host id reported in state responses
+///
+internal sealed class PodArguments
+{
+ public int Port { get; private set; } = MungaSocket.ConsolePort; // 1501
+
+ public ApplicationID Application { get; private set; } = ApplicationID.RPL4;
+
+ public HostType HostType { get; private set; } = HostType.GameMachineHostType;
+
+ public int HostId { get; private set; } = 1;
+
+ public static PodArguments Parse(string[] args)
+ {
+ PodArguments result = new PodArguments();
+ for (int i = 0; i < args.Length; i++)
+ {
+ string token = args[i].ToLowerInvariant();
+ switch (token)
+ {
+ case "-net":
+ if (i + 1 < args.Length && int.TryParse(args[i + 1], out int port))
+ {
+ result.Port = port;
+ i++;
+ }
+ break;
+ case "-lc":
+ result.HostType = HostType.MissionReviewHostType;
+ break;
+ case "-mr":
+ result.HostType = HostType.MissionReviewHostType;
+ break;
+ case "-res":
+ i += 2; // width height, ignored
+ break;
+ case "-app":
+ if (i + 1 < args.Length)
+ {
+ result.Application = ParseApp(args[i + 1]);
+ i++;
+ }
+ break;
+ case "-host":
+ if (i + 1 < args.Length && int.TryParse(args[i + 1], out int host))
+ {
+ result.HostId = host;
+ i++;
+ }
+ break;
+ }
+ }
+ return result;
+ }
+
+ private static ApplicationID ParseApp(string value)
+ {
+ switch (value.ToLowerInvariant())
+ {
+ case "bt":
+ case "btl4":
+ case "battletech":
+ return ApplicationID.BTL4;
+ case "nd":
+ case "ndl4":
+ return ApplicationID.NDL4;
+ default:
+ return ApplicationID.RPL4;
+ }
+ }
+}
+
+///
+/// Mirror of the console's HostType (GameMachine=0, MissionReview=2, Console=3).
+/// Duplicated here so vPOD does not depend on the console assembly.
+///
+internal enum HostType
+{
+ GameMachineHostType = 0,
+ MissionReviewHostType = 2,
+ ConsoleHostType = 3
+}
diff --git a/Console/vPOD/PodSimulator.cs b/Console/vPOD/PodSimulator.cs
new file mode 100644
index 0000000..48eb39e
--- /dev/null
+++ b/Console/vPOD/PodSimulator.cs
@@ -0,0 +1,307 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Threading;
+using Munga.Net;
+
+namespace VPod;
+
+///
+/// Emulates a game client's ApplicationState machine over the Munga control
+/// channel, driving it from the messages the console sends. This is the brain of
+/// vPOD: it answers state queries, reassembles the streamed egg, and walks the
+/// pod through the load/run/stop lifecycle the console's game window expects.
+///
+/// The console (RPGame/BTGame NetworkScan) gates purely on the reported
+/// ApplicationState:
+/// WaitingForEgg -- ready; console streams the egg here
+/// (egg complete) -> LoadingMission -> WaitingForLaunch (console's "Loaded")
+/// RunMission -> LaunchingMission -> RunningMission (console's "Running")
+/// StopMission -> EndingMission -> WaitingForEgg (mission over/reset)
+/// AbortMission -> AbortingMission -> WaitingForEgg
+/// Intermediate states are shown for realism; the console only waits on the gate
+/// states, so a short delay between them is harmless.
+///
+internal sealed class PodSimulator
+{
+ private const int NetworkManagerFromHost = 0;
+
+ private readonly MungaPodServer mServer;
+ private readonly object mLock = new object();
+
+ private ApplicationState mState = ApplicationState.InitializingState;
+ private ApplicationID mApplicationId;
+ private int mHostId;
+ private int mTransitionDelayMs = 400;
+
+ // Egg reassembly
+ private byte[] mEggBuffer;
+ private int mEggTotal;
+ private int mEggReceived;
+ private readonly HashSet mEggChunks = new HashSet();
+ private string mEggText;
+
+ // Cancels a pending delayed transition when a newer one supersedes it.
+ private int mTransitionToken;
+ private Timer mTransitionTimer;
+
+ public event Action StateChanged;
+ public event Action EggReceived; // full egg text (NUL-normalized)
+ public event Action EggProgress; // a chunk arrived (mid-transfer)
+ public event Action Log;
+
+ public PodSimulator(MungaPodServer server, ApplicationID applicationId, int hostId)
+ {
+ mServer = server;
+ mApplicationId = applicationId;
+ mHostId = hostId;
+ }
+
+ public ApplicationState State { get { lock (mLock) { return mState; } } }
+
+ public ApplicationID ApplicationId
+ {
+ get { lock (mLock) { return mApplicationId; } }
+ set
+ {
+ lock (mLock) { mApplicationId = value; }
+ Log?.Invoke("Reported application changed to " + value + ".");
+ }
+ }
+
+ public int HostId { get { lock (mLock) { return mHostId; } } }
+
+ public int TransitionDelayMs
+ {
+ get { lock (mLock) { return mTransitionDelayMs; } }
+ set { lock (mLock) { mTransitionDelayMs = Math.Max(0, value); } }
+ }
+
+ public string EggText { get { lock (mLock) { return mEggText; } } }
+
+ public int EggPercent
+ {
+ get
+ {
+ lock (mLock)
+ {
+ if (mEggTotal <= 0) return 0;
+ return (int)(100L * mEggReceived / mEggTotal);
+ }
+ }
+ }
+
+ /// Bring the pod online: Initializing -> WaitingForEgg.
+ public void PowerOn()
+ {
+ SetState(ApplicationState.InitializingState);
+ ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true);
+ }
+
+ /// Operator reset: drop any mission and return to WaitingForEgg immediately.
+ public void Reset()
+ {
+ lock (mLock)
+ {
+ ClearEggLocked();
+ CancelPendingLocked();
+ }
+ SetState(ApplicationState.WaitingForEgg);
+ Log?.Invoke("Reset to WaitingForEgg.");
+ }
+
+ public void HandleMessage(MungaPodServer.Incoming incoming)
+ {
+ MungaMessage msg = incoming?.Message;
+ if (msg == null)
+ {
+ return;
+ }
+
+ switch (msg)
+ {
+ case StateQueryMessage _:
+ RespondState();
+ break;
+
+ case EggFileMessage egg:
+ HandleEggChunk(egg);
+ break;
+
+ case RunMissionMessage _:
+ Log?.Invoke("<- RunMissionMessage");
+ if (State == ApplicationState.WaitingForLaunch)
+ {
+ SetState(ApplicationState.LaunchingMission);
+ mServer.Send(new ReadyToRunMessage(HostId), 0, HostId);
+ ScheduleTransition(ApplicationState.RunningMission);
+ }
+ break;
+
+ case StopMissionMessage _:
+ Log?.Invoke("<- StopMissionMessage");
+ if (State == ApplicationState.RunningMission || State == ApplicationState.SuspendingMission)
+ {
+ SetState(ApplicationState.EndingMission);
+ ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true);
+ }
+ break;
+
+ case AbortMissionMessage _:
+ Log?.Invoke("<- AbortMissionMessage");
+ SetState(ApplicationState.AbortingMission);
+ mServer.Send(new AbortMissionResponseMessage(HostId), 0, HostId);
+ ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true);
+ break;
+
+ case SuspendMissionMessage _:
+ Log?.Invoke("<- SuspendMissionMessage");
+ if (State == ApplicationState.RunningMission)
+ {
+ SetState(ApplicationState.SuspendingMission);
+ }
+ break;
+
+ case ResumeMissionMessage _:
+ Log?.Invoke("<- ResumeMissionMessage");
+ if (State == ApplicationState.SuspendingMission)
+ {
+ SetState(ApplicationState.ResumingMission);
+ ScheduleTransition(ApplicationState.RunningMission);
+ }
+ break;
+
+ case LoadMissionMessage _:
+ Log?.Invoke("<- LoadMissionMessage");
+ if (State == ApplicationState.WaitingForEgg)
+ {
+ SetState(ApplicationState.LoadingMission);
+ ScheduleTransition(ApplicationState.WaitingForLaunch);
+ }
+ break;
+
+ case CheckLoadMessage _:
+ Log?.Invoke("<- CheckLoadMessage");
+ break;
+
+ case LightsOutMissionMessage _:
+ Log?.Invoke("<- LightsOutMissionMessage");
+ break;
+ }
+ }
+
+ private void RespondState()
+ {
+ ApplicationState state;
+ ApplicationID app;
+ int host;
+ lock (mLock)
+ {
+ state = mState;
+ app = mApplicationId;
+ host = mHostId;
+ }
+ mServer.Send(new StateResponseMessage(host, state, app), 0, host);
+ }
+
+ private void HandleEggChunk(EggFileMessage egg)
+ {
+ bool complete = false;
+ string text = null;
+ lock (mLock)
+ {
+ if (mEggBuffer == null || mEggTotal != egg.NotationFileLength)
+ {
+ mEggTotal = egg.NotationFileLength;
+ mEggBuffer = new byte[mEggTotal];
+ mEggReceived = 0;
+ mEggChunks.Clear();
+ }
+ int offset = egg.SequenceNumber * 1000;
+ int len = egg.ThisMessageLength;
+ if (offset >= 0 && offset + len <= mEggBuffer.Length && mEggChunks.Add(egg.SequenceNumber))
+ {
+ Buffer.BlockCopy(egg.NotationData, 0, mEggBuffer, offset, len);
+ mEggReceived += len;
+ }
+ if (mEggReceived >= mEggTotal && mEggTotal > 0)
+ {
+ // On the wire the egg is NUL-delimited ASCII; show it line-per-field.
+ text = Encoding.ASCII.GetString(mEggBuffer).Replace('\0', '\n');
+ mEggText = text;
+ complete = true;
+ }
+ }
+
+ EggProgress?.Invoke();
+
+ if (complete)
+ {
+ Log?.Invoke($"Egg received ({mEggTotal} bytes, {mEggChunks.Count} chunks). Acknowledging.");
+ mServer.Send(new AcknowledgeEggFileMessage(), NetworkManagerFromHost, HostId);
+ EggReceived?.Invoke(text);
+ // WaitingForEgg -> LoadingMission -> WaitingForLaunch
+ SetState(ApplicationState.LoadingMission);
+ ScheduleTransition(ApplicationState.WaitingForLaunch);
+ }
+ }
+
+ private void SetState(ApplicationState state)
+ {
+ bool changed;
+ lock (mLock)
+ {
+ changed = mState != state;
+ mState = state;
+ }
+ if (changed)
+ {
+ Log?.Invoke("State -> " + state);
+ StateChanged?.Invoke(state);
+ }
+ }
+
+ private void ScheduleTransition(ApplicationState target, bool ResetEggOnArrive = false)
+ {
+ int token;
+ int delay;
+ lock (mLock)
+ {
+ token = ++mTransitionToken;
+ delay = mTransitionDelayMs;
+ mTransitionTimer?.Dispose();
+ mTransitionTimer = new Timer(delegate
+ {
+ bool proceed;
+ lock (mLock)
+ {
+ proceed = token == mTransitionToken;
+ if (proceed && ResetEggOnArrive)
+ {
+ ClearEggLocked();
+ }
+ }
+ if (proceed)
+ {
+ SetState(target);
+ }
+ }, null, delay, Timeout.Infinite);
+ }
+ }
+
+ private void CancelPendingLocked()
+ {
+ mTransitionToken++;
+ mTransitionTimer?.Dispose();
+ mTransitionTimer = null;
+ }
+
+ private void ClearEggLocked()
+ {
+ mEggBuffer = null;
+ mEggTotal = 0;
+ mEggReceived = 0;
+ mEggChunks.Clear();
+ mEggText = null;
+ }
+}
diff --git a/Console/vPOD/Program.cs b/Console/vPOD/Program.cs
new file mode 100644
index 0000000..0433b71
--- /dev/null
+++ b/Console/vPOD/Program.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Windows.Forms;
+
+namespace VPod;
+
+internal static class Program
+{
+ [STAThread]
+ private static void Main(string[] args)
+ {
+ Application.EnableVisualStyles();
+ Application.SetCompatibleTextRenderingDefault(false);
+ PodArguments options = PodArguments.Parse(args);
+ Application.Run(new VPodForm(options));
+ }
+}
diff --git a/Console/vPOD/README.md b/Console/vPOD/README.md
new file mode 100644
index 0000000..24e8e66
--- /dev/null
+++ b/Console/vPOD/README.md
@@ -0,0 +1,59 @@
+# vPOD — virtual pod / game-client stand-in
+
+A test tool that impersonates a Tesla game client (Red Planet's `rpl4opt.exe`
+or BattleTech's `btl4.exe`) so the operator **consoles can be exercised without
+real cockpit hardware**. It speaks the Munga command/control protocol as a
+server on TCP 1501 — the console connects to it exactly as it would a real pod —
+emulates the pod `ApplicationState` machine, reassembles the streamed egg, and
+shows everything on a live display.
+
+## What it does
+
+- **Listens on TCP 1501** (configurable) and answers the console's
+ `StateQuery` with a `StateResponse`, reporting the game (`ApplicationID`) and
+ the current `ApplicationState`.
+- **Walks the mission lifecycle** the console drives it through:
+ `WaitingForEgg → LoadingMission → WaitingForLaunch → LaunchingMission →
+ RunningMission → …`, reacting to the egg stream and to Run / Stop / Abort /
+ Suspend / Resume messages, and acknowledging the egg.
+- **Reassembles and shows the egg** the console streams (the `EggFileMessage`
+ chunks), one field per line, with a summary line (adventure / map / scenario /
+ pilot count).
+- **Game toggle** — a Red Planet ⇄ BattleTech switch on the window changes which
+ `ApplicationID` the pod reports, live, so one vPOD can stand in for either
+ game. (`-app rp|bt` sets the initial choice.)
+- A **newest-first protocol log** of the traffic.
+
+## Running it
+
+```
+vPOD.exe [-net ] [-app rp|bt] [-lc|-mr] [-host ] [-res W H]
+```
+
+- `-net ` Munga control port (default **1501**).
+- `-app rp|bt` which game to report initially (also switchable in the UI).
+- `-lc` / `-mr` live-camera / mission-review role (cosmetic; the state model is
+ identical to a game machine).
+- `-host ` responding host id reported in state responses (default 1).
+- `-res W H` accepted and ignored (real clients take it; kept for drop-in
+ launch compatibility).
+
+## Deploying from the console (Manage Site → Install Product)
+
+vPOD is a catalog product (`RedPlanet\Apps.xml`, id `0041C870-…`) with Game
+Client / Live Camera / Mission Review entries, so it appears in **Manage Site →
+Install Product** like any game. Build the deployable package first:
+
+```
+pwsh -File pack.ps1 # produces dist\vPOD.zip
+```
+
+The zip lays out `vPOD\vPOD.exe` (+ `Munga Net.dll`) so the launcher extracts it
+to `C:\Games\vPOD` and the catalog entry launches `C:\Games\vPOD\vPOD.exe`.
+
+## Testing locally against the console
+
+The default site ships a **`local` pod at 127.0.0.1**. Run vPOD on the console
+machine, open a game window (e.g. *Games → Red Planet: Death Race*), and enable
+the local pod — the console connects to `127.0.0.1:1501` (vPOD), and you can
+drive Load → Run → Stop and watch vPOD's state and egg viewer follow along.
diff --git a/Console/vPOD/VPodForm.cs b/Console/vPOD/VPodForm.cs
new file mode 100644
index 0000000..662ec2a
--- /dev/null
+++ b/Console/vPOD/VPodForm.cs
@@ -0,0 +1,374 @@
+using System;
+using System.Drawing;
+using System.Text;
+using System.Windows.Forms;
+using Munga.Net;
+
+namespace VPod;
+
+///
+/// The vPOD window: a live view of the simulated pod. Top panel shows the
+/// listening/connection status and the current ApplicationState (colour-coded),
+/// with the game toggle (Red Planet / BattleTech) that changes which
+/// ApplicationID the pod reports. Below, the left pane is the egg viewer (the
+/// last egg the console streamed, one field per line) and the right pane is a
+/// scrolling protocol log.
+///
+internal sealed class VPodForm : Form
+{
+ private readonly PodArguments mOptions;
+ private readonly MungaPodServer mServer;
+ private readonly PodSimulator mSimulator;
+
+ private Label mListeningLabel;
+ private Label mConnectionLabel;
+ private Label mStateLabel;
+ private Label mEggSummaryLabel;
+ private RadioButton mRedPlanetRadio;
+ private RadioButton mBattleTechRadio;
+ private Button mPowerButton;
+ private Button mResetButton;
+ private TextBox mEggBox;
+ private TextBox mLogBox;
+
+ public VPodForm(PodArguments options)
+ {
+ mOptions = options;
+ mServer = new MungaPodServer(options.Port);
+ mSimulator = new PodSimulator(mServer, options.Application, options.HostId);
+ BuildUi();
+
+ mServer.Log += OnLog;
+ mServer.ConnectionChanged += OnConnectionChanged;
+ mServer.MessageReceived += OnMessageReceived;
+ mSimulator.Log += OnLog;
+ mSimulator.StateChanged += OnStateChanged;
+ mSimulator.EggReceived += OnEggReceived;
+ mSimulator.EggProgress += OnEggProgress;
+
+ Load += OnFormLoad;
+ FormClosing += OnFormClosing;
+ }
+
+ private void BuildUi()
+ {
+ Text = $"vPOD - virtual pod (port {mOptions.Port}, host {mOptions.HostId})";
+ ClientSize = new Size(920, 560);
+ MinimumSize = new Size(760, 460);
+ Font = new Font("Segoe UI", 9f);
+
+ // ---- status panel ----
+ GroupBox statusGroup = new GroupBox
+ {
+ Text = "Pod Status",
+ Dock = DockStyle.Top,
+ Height = 168,
+ Padding = new Padding(10)
+ };
+
+ mListeningLabel = new Label { AutoSize = true, Location = new Point(16, 26) };
+ mConnectionLabel = new Label { AutoSize = true, Location = new Point(16, 50) };
+
+ Label roleLabel = new Label
+ {
+ AutoSize = true,
+ Location = new Point(16, 74),
+ Text = "Role: " + RoleText(mOptions.HostType)
+ };
+
+ Label stateCaption = new Label
+ {
+ AutoSize = true,
+ Location = new Point(16, 106),
+ Text = "Application State:"
+ };
+ mStateLabel = new Label
+ {
+ AutoSize = true,
+ Location = new Point(130, 100),
+ Font = new Font("Segoe UI", 15f, FontStyle.Bold),
+ Text = "—"
+ };
+
+ // game toggle
+ GroupBox gameGroup = new GroupBox
+ {
+ Text = "Mimicking Game",
+ Location = new Point(560, 20),
+ Size = new Size(330, 130)
+ };
+ mRedPlanetRadio = new RadioButton
+ {
+ Text = "Red Planet (RPL4)",
+ Location = new Point(18, 28),
+ AutoSize = true,
+ Checked = mOptions.Application == ApplicationID.RPL4
+ };
+ mBattleTechRadio = new RadioButton
+ {
+ Text = "BattleTech (BTL4)",
+ Location = new Point(18, 56),
+ AutoSize = true,
+ Checked = mOptions.Application == ApplicationID.BTL4
+ };
+ mRedPlanetRadio.CheckedChanged += GameToggleChanged;
+ mBattleTechRadio.CheckedChanged += GameToggleChanged;
+ mPowerButton = new Button { Text = "Power On", Location = new Point(18, 88), Size = new Size(90, 28) };
+ mResetButton = new Button { Text = "Reset", Location = new Point(116, 88), Size = new Size(90, 28) };
+ mPowerButton.Click += (s, e) => mSimulator.PowerOn();
+ mResetButton.Click += (s, e) => mSimulator.Reset();
+ gameGroup.Controls.Add(mRedPlanetRadio);
+ gameGroup.Controls.Add(mBattleTechRadio);
+ gameGroup.Controls.Add(mPowerButton);
+ gameGroup.Controls.Add(mResetButton);
+
+ statusGroup.Controls.Add(mListeningLabel);
+ statusGroup.Controls.Add(mConnectionLabel);
+ statusGroup.Controls.Add(roleLabel);
+ statusGroup.Controls.Add(stateCaption);
+ statusGroup.Controls.Add(mStateLabel);
+ statusGroup.Controls.Add(gameGroup);
+
+ // ---- egg viewer + log ----
+ SplitContainer split = new SplitContainer
+ {
+ Dock = DockStyle.Fill,
+ SplitterDistance = 480,
+ Orientation = Orientation.Vertical
+ };
+
+ GroupBox eggGroup = new GroupBox { Text = "Current Egg", Dock = DockStyle.Fill, Padding = new Padding(8) };
+ mEggSummaryLabel = new Label { Dock = DockStyle.Top, Height = 40, Text = "No egg loaded." };
+ mEggBox = new TextBox
+ {
+ Dock = DockStyle.Fill,
+ Multiline = true,
+ ReadOnly = true,
+ ScrollBars = ScrollBars.Both,
+ WordWrap = false,
+ Font = new Font("Consolas", 9f),
+ BackColor = Color.White
+ };
+ eggGroup.Controls.Add(mEggBox);
+ eggGroup.Controls.Add(mEggSummaryLabel);
+
+ GroupBox logGroup = new GroupBox { Text = "Protocol Log", Dock = DockStyle.Fill, Padding = new Padding(8) };
+ mLogBox = new TextBox
+ {
+ Dock = DockStyle.Fill,
+ Multiline = true,
+ ReadOnly = true,
+ ScrollBars = ScrollBars.Both,
+ WordWrap = false,
+ Font = new Font("Consolas", 9f),
+ BackColor = Color.FromArgb(24, 24, 24),
+ ForeColor = Color.Gainsboro
+ };
+ logGroup.Controls.Add(mLogBox);
+
+ split.Panel1.Controls.Add(eggGroup);
+ split.Panel2.Controls.Add(logGroup);
+
+ Controls.Add(split);
+ Controls.Add(statusGroup);
+ }
+
+ private void OnFormLoad(object sender, EventArgs e)
+ {
+ UpdateListeningLabel(false);
+ UpdateConnectionLabel(null);
+ UpdateStateLabel(mSimulator.State);
+ try
+ {
+ mServer.Start();
+ UpdateListeningLabel(true);
+ mSimulator.PowerOn();
+ }
+ catch (Exception ex)
+ {
+ UpdateListeningLabel(false);
+ OnLog("FAILED to listen on port " + mOptions.Port + ": " + ex.Message);
+ MessageBox.Show(this,
+ "Could not listen on TCP port " + mOptions.Port + ".\n\n" + ex.Message +
+ "\n\nIs another pod or vPOD already using it?",
+ "vPOD", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+ }
+
+ private void OnFormClosing(object sender, FormClosingEventArgs e)
+ {
+ mServer.Stop();
+ }
+
+ private void GameToggleChanged(object sender, EventArgs e)
+ {
+ if (sender is RadioButton rb && !rb.Checked)
+ {
+ return; // only act on the newly-checked one
+ }
+ mSimulator.ApplicationId = mBattleTechRadio.Checked ? ApplicationID.BTL4 : ApplicationID.RPL4;
+ }
+
+ // ---- event handlers (marshal to UI thread) ----
+
+ private const int MaxLogLines = 500;
+
+ private void OnLog(string message)
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)(() =>
+ {
+ // Newest first: prepend, and cap the buffer so it can't grow without bound.
+ string line = $"[{DateTime.Now:HH:mm:ss}] {message}";
+ string existing = mLogBox.Text;
+ string combined = existing.Length > 0 ? line + "\r\n" + existing : line;
+ string[] lines = combined.Split(new[] { "\r\n" }, StringSplitOptions.None);
+ if (lines.Length > MaxLogLines)
+ {
+ combined = string.Join("\r\n", lines, 0, MaxLogLines);
+ }
+ mLogBox.Text = combined;
+ }));
+ }
+
+ private void OnConnectionChanged(string remote)
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)(() => UpdateConnectionLabel(remote)));
+ }
+
+ private void OnMessageReceived(MungaPodServer.Incoming incoming)
+ {
+ // The simulator drives all protocol behaviour; the UI only logs
+ // non-query traffic (StateQuery is once-a-second noise).
+ if (!(incoming.Message is StateQueryMessage) && incoming.Message != null)
+ {
+ OnLog("<- " + incoming.Message.GetType().Name);
+ }
+ mSimulator.HandleMessage(incoming);
+ }
+
+ private void OnStateChanged(ApplicationState state)
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)(() => UpdateStateLabel(state)));
+ }
+
+ private void OnEggReceived(string eggText)
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)(() =>
+ {
+ mEggBox.Text = eggText.Replace("\n", "\r\n");
+ mEggSummaryLabel.Text = SummarizeEgg(eggText);
+ }));
+ }
+
+ private void OnEggProgress()
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)(() =>
+ {
+ int pct = mSimulator.EggPercent;
+ if (pct < 100 && pct > 0)
+ {
+ mEggSummaryLabel.Text = $"Receiving egg... {pct}%";
+ }
+ }));
+ }
+
+ // ---- label helpers ----
+
+ private void UpdateListeningLabel(bool listening)
+ {
+ mListeningLabel.Text = listening
+ ? $"Listening on TCP {mOptions.Port} ●"
+ : $"Not listening on TCP {mOptions.Port}";
+ mListeningLabel.ForeColor = listening ? Color.ForestGreen : Color.Firebrick;
+ }
+
+ private void UpdateConnectionLabel(string remote)
+ {
+ if (string.IsNullOrEmpty(remote))
+ {
+ mConnectionLabel.Text = "Console: not connected";
+ mConnectionLabel.ForeColor = Color.Gray;
+ }
+ else
+ {
+ mConnectionLabel.Text = "Console: connected from " + remote;
+ mConnectionLabel.ForeColor = Color.ForestGreen;
+ }
+ }
+
+ private void UpdateStateLabel(ApplicationState state)
+ {
+ mStateLabel.Text = Prettify(state);
+ mStateLabel.ForeColor = ColorFor(state);
+ }
+
+ private static string RoleText(HostType hostType)
+ {
+ switch (hostType)
+ {
+ case HostType.MissionReviewHostType:
+ return "Camera / Mission Review";
+ case HostType.ConsoleHostType:
+ return "Console";
+ default:
+ return "Game Machine (player)";
+ }
+ }
+
+ private static string Prettify(ApplicationState state)
+ {
+ switch (state)
+ {
+ case ApplicationState.InitializingState: return "Initializing";
+ case ApplicationState.WaitingForEgg: return "Waiting For Egg";
+ case ApplicationState.LoadingMission: return "Loading Mission";
+ case ApplicationState.WaitingForLaunch: return "Waiting For Launch";
+ case ApplicationState.LaunchingMission: return "Launching Mission";
+ case ApplicationState.RunningMission: return "Running Mission";
+ case ApplicationState.EndingMission: return "Ending Mission";
+ case ApplicationState.StoppingMission: return "Stopping Mission";
+ case ApplicationState.SuspendingMission: return "Suspended";
+ case ApplicationState.ResumingMission: return "Resuming Mission";
+ case ApplicationState.AbortingMission: return "Aborting Mission";
+ case ApplicationState.CreatingMission: return "Creating Mission";
+ default: return state.ToString();
+ }
+ }
+
+ private static Color ColorFor(ApplicationState state)
+ {
+ switch (state)
+ {
+ case ApplicationState.WaitingForEgg: return Color.ForestGreen;
+ case ApplicationState.WaitingForLaunch: return Color.DarkGoldenrod;
+ case ApplicationState.RunningMission: return Color.RoyalBlue;
+ case ApplicationState.InitializingState: return Color.Gray;
+ default: return Color.DarkOrange;
+ }
+ }
+
+ private static string SummarizeEgg(string eggText)
+ {
+ string adventure = null, map = null, scenario = null;
+ int pilots = 0;
+ foreach (string raw in eggText.Split('\n'))
+ {
+ string line = raw.Trim();
+ if (line.StartsWith("adventure=")) adventure = line.Substring(10);
+ else if (line.StartsWith("map=")) map = line.Substring(4);
+ else if (line.StartsWith("scenario=")) scenario = line.Substring(9);
+ else if (line.StartsWith("pilot=")) pilots++;
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.Append(adventure ?? "(egg)");
+ if (map != null) sb.Append(" • map=").Append(map);
+ if (scenario != null) sb.Append(" • ").Append(scenario);
+ sb.Append(" • ").Append(pilots).Append(pilots == 1 ? " pilot" : " pilots");
+ return sb.ToString();
+ }
+}
diff --git a/Console/vPOD/dist/vPOD.zip b/Console/vPOD/dist/vPOD.zip
new file mode 100644
index 0000000..bd6df8d
Binary files /dev/null and b/Console/vPOD/dist/vPOD.zip differ
diff --git a/Console/vPOD/pack.ps1 b/Console/vPOD/pack.ps1
new file mode 100644
index 0000000..a7602d2
--- /dev/null
+++ b/Console/vPOD/pack.ps1
@@ -0,0 +1,39 @@
+# Builds the vPOD install package for the console's Manage Site -> Install Product.
+#
+# The launcher extracts the zip's entries directly into C:\Games\, so the archive
+# root must contain a `vPOD\` folder holding vPOD.exe + its dependencies. The
+# console's Apps.xml vPOD product then launches C:\Games\vPOD\vPOD.exe.
+#
+# Usage: pwsh -File pack.ps1 (Release, default)
+# pwsh -File pack.ps1 -Config Debug
+param([string]$Config = 'Release')
+
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $MyInvocation.MyCommand.Path
+$bin = Join-Path $root "bin\$Config\net48"
+$distDir = Join-Path $root 'dist'
+$stage = Join-Path $distDir 'vPOD' # -> C:\Games\vPOD on the pod
+$zipPath = Join-Path $distDir 'vPOD.zip'
+
+Write-Host "Building vPOD ($Config)..."
+dotnet build (Join-Path $root 'vPOD.csproj') -c $Config --nologo -v minimal | Out-Null
+
+if (-not (Test-Path (Join-Path $bin 'vPOD.exe'))) {
+ throw "Build output not found: $bin\vPOD.exe"
+}
+
+# Fresh stage dir.
+if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
+New-Item -ItemType Directory -Force $stage | Out-Null
+
+# The runtime payload: the exe + the vendored Munga Net.dll (copied local by the
+# csproj) + config. No pdb/xml.
+Get-ChildItem $bin -File |
+ Where-Object { $_.Extension -in '.exe', '.dll', '.config' } |
+ ForEach-Object { Copy-Item $_.FullName $stage }
+
+if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
+Compress-Archive -Path $stage -DestinationPath $zipPath -Force
+
+Write-Host "Packaged: $zipPath"
+Get-ChildItem $stage | Select-Object Name, Length | Format-Table -AutoSize
diff --git a/Console/vPOD/vPOD.csproj b/Console/vPOD/vPOD.csproj
new file mode 100644
index 0000000..1f36b8e
--- /dev/null
+++ b/Console/vPOD/vPOD.csproj
@@ -0,0 +1,44 @@
+
+
+
+
+ WinExe
+ true
+ net48
+ latest
+ disable
+ disable
+ vPOD
+ VPod
+ true
+ 1.0.0.0
+ 1.0.0
+ vPOD
+
+
+
+
+
+
+
+
+
+
+ ..\lib\Munga Net.dll
+ true
+
+
+
+
diff --git a/TeslaSuite.sln b/TeslaSuite.sln
index 7de397a..0ac4d94 100644
--- a/TeslaSuite.sln
+++ b/TeslaSuite.sln
@@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tesla.Contract", "Contract\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tesla.SecureConfig", "SecureConfig\Tesla.SecureConfig.csproj", "{070A6093-6C46-4A5B-A119-47ED195530E1}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "vPOD", "Console\vPOD\vPOD.csproj", "{9EAC97A1-D71A-4AAB-9957-A79A1587D406}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -52,9 +54,14 @@ Global
{070A6093-6C46-4A5B-A119-47ED195530E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{070A6093-6C46-4A5B-A119-47ED195530E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{070A6093-6C46-4A5B-A119-47ED195530E1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{27C769F3-F07F-456E-ACB7-F4A4A21AB6D1} = {91E92ADD-9F67-41E4-B43C-CCB7B2D95F15}
{467AA87A-FBD4-45D7-B8F8-9336C95884D2} = {27C769F3-F07F-456E-ACB7-F4A4A21AB6D1}
+ {9EAC97A1-D71A-4AAB-9957-A79A1587D406} = {91E92ADD-9F67-41E4-B43C-CCB7B2D95F15}
EndGlobalSection
EndGlobal