Files
TeslaSuite/Console/vPOD/VPodForm.cs
T
CydandClaude Fable 5 3fd637c58a Add vPOD: a virtual pod / game-client stand-in for testing the consoles
vPOD impersonates a Tesla game client (rpl4opt.exe / btl4.exe) so the Red
Planet and BattleTech operator consoles can be exercised without real cockpit
hardware. New net48 WinForms project under Console\vPOD:

- MungaPodServer: the server half of the Munga control protocol (TCP 1501).
  The vendored MungaSocket is client-only, so this reimplements the identical
  framing ([16-byte header][12-byte base + body], dispatched by
  ClientID+MessageID) for the listening side, reusing the vendored message
  classes' WriteTo/BinaryReader serialization.
- PodSimulator: the ApplicationState machine driven by the console's messages -
  answers StateQuery, reassembles the streamed egg and acknowledges it, and
  walks WaitingForEgg -> LoadingMission -> WaitingForLaunch -> RunningMission
  and back on Run/Stop/Abort/Suspend/Resume.
- VPodForm: live display of listening/connection status, the colour-coded
  ApplicationState, an egg viewer (fields + summary), and a newest-first
  protocol log. A Red Planet / BattleTech toggle changes which ApplicationID
  the pod reports, live, so one vPOD stands in for either game.
- PodArguments: parses the real client's launch flags (-net/-app/-lc/-mr/
  -host/-res).

Deployable from Manage Site -> Install Product: a catalog product in
RedPlanet\Apps.xml (Game Client / Live Camera / Mission Review entries) plus
pack.ps1, which builds dist\vPOD.zip laying out vPOD\vPOD.exe for the launcher
to extract to C:\Games\vPOD. CatalogTests updated to 5 products / 11 entries
with the four vPOD entry assertions (88/88 pass). TeslaConsole.csproj excludes
vPOD\** from its **/*.cs glob; the project is added to the solution.

Verified end-to-end over real TCP: a console-role client using the vendored
MungaSocket drives connect -> WaitingForEgg -> stream egg -> (ack) ->
WaitingForLaunch -> Run -> RunningMission -> Stop -> WaitingForEgg, with vPOD
reporting the correct state at each step. MungaGame (what the console's game
windows use) is a thin wrapper over MungaSocket, so this exercises the exact
wire behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:34:53 -05:00

375 lines
11 KiB
C#

using System;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
using Munga.Net;
namespace VPod;
/// <summary>
/// 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.
/// </summary>
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();
}
}