vPOD: pod power controls, end-mission restart cycle, egg viewer polish

Adds pod-lifecycle controls to the vPOD window:

- Power Off / Power On: Power Off closes the TCP listener so the console can no
  longer connect, mimicking a pod with no game client running; Power On reopens
  it. The listener close/open is the faithful signal a console sees.
- End-mission graceful-exit + watchdog restart: on the end-mission command
  (StopMission) the game exits gracefully (listener closes, connection drops)
  and a watchdog relaunches it about 1.5 s later, coming back up in
  WaitingForEgg - the real pod's per-game cycle. A "Restart game after mission
  ends (watchdog)" checkbox (default on) toggles it; unchecked, the pod just
  returns to WaitingForEgg without exiting.
- Egg viewer: the last egg is kept across missions/restarts (no auto-clear) so
  it can be copied for dev use; a Clear button empties it on demand.
- Log pane defaults to half the window (egg lines are rarely wide); the split
  is set at load once the control has its real width.

Verified over real TCP: driving egg -> run -> end-mission makes the listener
drop then reopen with the pod back in WaitingForEgg (confirmed in vPOD's own
log: "Game exited gracefully" then "Watchdog restarted the game").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-08 10:43:44 -05:00
co-authored by Claude Fable 5
parent aff8bbf6e5
commit fd0c8c5196
3 changed files with 191 additions and 15 deletions
+28 -2
View File
@@ -33,6 +33,7 @@ internal sealed class PodSimulator
private ApplicationID mApplicationId;
private int mHostId;
private int mTransitionDelayMs = 400;
private bool mRestartOnEndMission = true;
// Egg reassembly
private byte[] mEggBuffer;
@@ -49,6 +50,7 @@ internal sealed class PodSimulator
public event Action<string> EggReceived; // full egg text (NUL-normalized)
public event Action EggProgress; // a chunk arrived (mid-transfer)
public event Action<string> Log;
public event Action EndMissionExit; // game exited on end mission; watchdog should restart it
public PodSimulator(MungaPodServer server, ApplicationID applicationId, int hostId)
{
@@ -77,6 +79,17 @@ internal sealed class PodSimulator
set { lock (mLock) { mTransitionDelayMs = Math.Max(0, value); } }
}
/// <summary>
/// When true (the faithful default), the end-mission command makes the game
/// exit and its watchdog restart it (the form drops and reopens the listener).
/// When false, the pod simply returns to WaitingForEgg without exiting.
/// </summary>
public bool RestartOnEndMission
{
get { lock (mLock) { return mRestartOnEndMission; } }
set { lock (mLock) { mRestartOnEndMission = value; } }
}
public string EggText { get { lock (mLock) { return mEggText; } } }
public int EggPercent
@@ -139,11 +152,24 @@ internal sealed class PodSimulator
break;
case StopMissionMessage _:
Log?.Invoke("<- StopMissionMessage");
Log?.Invoke("<- StopMissionMessage (end mission)");
if (State == ApplicationState.RunningMission || State == ApplicationState.SuspendingMission)
{
SetState(ApplicationState.EndingMission);
ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true);
bool restart;
lock (mLock) { restart = mRestartOnEndMission; }
if (restart)
{
// Faithful pod behaviour: the game exe exits gracefully on the
// end-mission command, then its watchdog restarts it. The form
// simulates the exit + restart by dropping and reopening the
// listener; the pod comes back up in WaitingForEgg.
EndMissionExit?.Invoke();
}
else
{
ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true);
}
}
break;
+11 -1
View File
@@ -16,9 +16,19 @@ shows everything on a live display.
`WaitingForEgg → LoadingMission → WaitingForLaunch → LaunchingMission →
RunningMission → …`, reacting to the egg stream and to Run / Stop / Abort /
Suspend / Resume messages, and acknowledging the egg.
- **End-mission graceful exit + watchdog restart** — on the console's end-mission
command the "game exe" exits (the listener closes, the console's connection
drops), then a watchdog relaunches it a moment later and it comes back up in
`WaitingForEgg`. This is the real pod's per-game cycle (`autoRestart`); the
*Restart game after mission ends (watchdog)* checkbox (on by default) toggles
it — unchecked, the pod just returns to `WaitingForEgg` without exiting.
- **Power On / Power Off** — Power Off closes the TCP listener so the console
cannot connect, mimicking a pod with no game client running; Power On reopens
it. **Reset** returns a live pod to `WaitingForEgg`.
- **Reassembles and shows the egg** the console streams (the `EggFileMessage`
chunks), one field per line, with a summary line (adventure / map / scenario /
pilot count).
pilot count). The last egg is **kept** across missions/restarts (so it can be
copied for dev use) until the **Clear** button empties the viewer.
- **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.)
+152 -12
View File
@@ -27,10 +27,17 @@ internal sealed class VPodForm : Form
private RadioButton mRedPlanetRadio;
private RadioButton mBattleTechRadio;
private Button mPowerButton;
private Button mPowerOffButton;
private Button mResetButton;
private CheckBox mRestartCheckbox;
private Timer mRestartTimer;
private SplitContainer mSplit;
private TextBox mEggBox;
private TextBox mLogBox;
// How long the "watchdog" waits before relaunching the exited game.
private const int WatchdogRestartMs = 1500;
public VPodForm(PodArguments options)
{
mOptions = options;
@@ -45,6 +52,10 @@ internal sealed class VPodForm : Form
mSimulator.StateChanged += OnStateChanged;
mSimulator.EggReceived += OnEggReceived;
mSimulator.EggProgress += OnEggProgress;
mSimulator.EndMissionExit += OnEndMissionExit;
mRestartTimer = new Timer { Interval = WatchdogRestartMs };
mRestartTimer.Tick += OnRestartTimerTick;
Load += OnFormLoad;
FormClosing += OnFormClosing;
@@ -62,7 +73,7 @@ internal sealed class VPodForm : Form
{
Text = "Pod Status",
Dock = DockStyle.Top,
Height = 168,
Height = 176,
Padding = new Padding(10)
};
@@ -113,32 +124,64 @@ internal sealed class VPodForm : Form
};
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();
mPowerButton = new Button { Text = "Power On", Location = new Point(12, 90), Size = new Size(94, 28) };
mPowerOffButton = new Button { Text = "Power Off", Location = new Point(112, 90), Size = new Size(94, 28) };
mResetButton = new Button { Text = "Reset", Location = new Point(212, 90), Size = new Size(94, 28) };
mPowerButton.Click += PowerOnClicked;
mPowerOffButton.Click += PowerOffClicked;
mResetButton.Click += (s, e) => mSimulator.Reset();
gameGroup.Controls.Add(mRedPlanetRadio);
gameGroup.Controls.Add(mBattleTechRadio);
gameGroup.Controls.Add(mPowerButton);
gameGroup.Controls.Add(mPowerOffButton);
gameGroup.Controls.Add(mResetButton);
mRestartCheckbox = new CheckBox
{
Text = "Restart game after mission ends (watchdog)",
Location = new Point(16, 146),
AutoSize = true,
Checked = true
};
mRestartCheckbox.CheckedChanged += (s, e) => mSimulator.RestartOnEndMission = mRestartCheckbox.Checked;
statusGroup.Controls.Add(mListeningLabel);
statusGroup.Controls.Add(mConnectionLabel);
statusGroup.Controls.Add(roleLabel);
statusGroup.Controls.Add(stateCaption);
statusGroup.Controls.Add(mStateLabel);
statusGroup.Controls.Add(mRestartCheckbox);
statusGroup.Controls.Add(gameGroup);
// ---- egg viewer + log ----
SplitContainer split = new SplitContainer
// Split the egg viewer (left) and protocol log (right). The 50/50 default
// is applied in OnFormLoad once the control has its real width (setting it
// here would clamp to the control's default size).
mSplit = new SplitContainer
{
Dock = DockStyle.Fill,
SplitterDistance = 480,
Orientation = Orientation.Vertical
};
SplitContainer split = mSplit;
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." };
// The egg is kept after a mission/restart (not auto-cleared) so it can be
// copied for dev use; the Clear button empties the viewer on demand.
Panel eggHeader = new Panel { Dock = DockStyle.Top, Height = 30 };
Button clearEggButton = new Button { Text = "Clear", Dock = DockStyle.Right, Width = 70 };
mEggSummaryLabel = new Label
{
Dock = DockStyle.Fill,
Text = "No egg loaded.",
TextAlign = ContentAlignment.MiddleLeft
};
clearEggButton.Click += (s, e) =>
{
mEggBox.Clear();
mEggSummaryLabel.Text = "No egg loaded.";
};
eggHeader.Controls.Add(mEggSummaryLabel);
eggHeader.Controls.Add(clearEggButton);
mEggBox = new TextBox
{
Dock = DockStyle.Fill,
@@ -150,7 +193,7 @@ internal sealed class VPodForm : Form
BackColor = Color.White
};
eggGroup.Controls.Add(mEggBox);
eggGroup.Controls.Add(mEggSummaryLabel);
eggGroup.Controls.Add(eggHeader);
GroupBox logGroup = new GroupBox { Text = "Protocol Log", Dock = DockStyle.Fill, Padding = new Padding(8) };
mLogBox = new TextBox
@@ -175,14 +218,98 @@ internal sealed class VPodForm : Form
private void OnFormLoad(object sender, EventArgs e)
{
UpdateListeningLabel(false);
// Now that the split has its real width, give the log half the window.
if (mSplit.Width > mSplit.Panel1MinSize + mSplit.Panel2MinSize)
{
mSplit.SplitterDistance = mSplit.Width / 2;
}
UpdateConnectionLabel(null);
UpdateStateLabel(mSimulator.State);
if (StartServer())
{
mSimulator.PowerOn();
SetPoweredState(on: true);
}
else
{
SetPoweredState(on: false);
}
}
private void OnFormClosing(object sender, FormClosingEventArgs e)
{
mServer.Stop();
}
private void PowerOnClicked(object sender, EventArgs e)
{
mRestartTimer.Stop(); // cancel any pending watchdog restart
if (!mServer.IsListening && !StartServer())
{
return; // couldn't bind the port; stay powered off
}
mSimulator.PowerOn();
SetPoweredState(on: true);
}
/// <summary>
/// Powers the pod off by closing the TCP listener, so the console can no
/// longer connect — the same condition as a pod with no game client running.
/// </summary>
private void PowerOffClicked(object sender, EventArgs e)
{
mRestartTimer.Stop();
mServer.Stop();
SetPoweredState(on: false);
}
/// <summary>
/// The game exited gracefully on the console's end-mission command. Simulate
/// the process exit by closing the listener (the console's connection drops),
/// then let the watchdog timer relaunch it.
/// </summary>
private void OnEndMissionExit()
{
if (IsDisposed) return;
BeginInvoke((Action)(() =>
{
OnLog("Game exited gracefully (end mission); watchdog will restart it...");
mServer.Stop();
UpdateListeningLabel(false);
UpdateConnectionLabel(null);
mStateLabel.Text = "Restarting...";
mStateLabel.ForeColor = Color.DarkOrange;
mPowerButton.Enabled = true;
mPowerOffButton.Enabled = false;
mResetButton.Enabled = false;
mRestartTimer.Stop();
mRestartTimer.Start();
}));
}
private void OnRestartTimerTick(object sender, EventArgs e)
{
mRestartTimer.Stop();
if (StartServer())
{
OnLog("Watchdog restarted the game.");
mSimulator.PowerOn();
SetPoweredState(on: true);
}
else
{
SetPoweredState(on: false);
}
}
/// <summary>Starts the listener and updates the status label. Returns false (with a message) if the port is unavailable.</summary>
private bool StartServer()
{
try
{
mServer.Start();
UpdateListeningLabel(true);
mSimulator.PowerOn();
return true;
}
catch (Exception ex)
{
@@ -192,12 +319,23 @@ internal sealed class VPodForm : Form
"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);
return false;
}
}
private void OnFormClosing(object sender, FormClosingEventArgs e)
/// <summary>Reflects the on/off state in the buttons and, when off, the status labels.</summary>
private void SetPoweredState(bool on)
{
mServer.Stop();
mPowerButton.Enabled = !on;
mPowerOffButton.Enabled = on;
mResetButton.Enabled = on;
if (!on)
{
UpdateListeningLabel(false);
UpdateConnectionLabel(null);
mStateLabel.Text = "Powered Off";
mStateLabel.ForeColor = Color.Gray;
}
}
private void GameToggleChanged(object sender, EventArgs e)
@@ -251,6 +389,8 @@ internal sealed class VPodForm : Form
private void OnStateChanged(ApplicationState state)
{
if (IsDisposed) return;
// The egg viewer is intentionally NOT cleared here — the last egg stays
// visible (copyable) across missions/restarts until the Clear button.
BeginInvoke((Action)(() => UpdateStateLabel(state)));
}