The eight internet pod rows are furniture the operator builds once in Manage Site — the slot-to-IP map is frozen — but which of those slots a human actually claimed changes every session. New SessionRoster reads the roster TeslaLobby writes at the state=launching flip, and both game panes grow a session strip above Mission Properties: a banner (session key, game, slots claimed, waiting, written-at), Apply Session, and the Reset Pods that until now existed only as a right-click on a Go button that is disabled exactly when the reset is wanted. Two properties shape all of it. Only claimed slots appear in the file, so absence is the unclaimed signal and every failure path — truncated, stale, unreadable, refused — degrades to "no roster", which is byte-for-byte today's arcade behaviour; museums run this software and a bad JSON file must never stop a mission that would otherwise run by hand. And enabled implies claimed, not the reverse: the operator may always sit a pilot out, never add one, because an enabled row nobody claimed puts a dead IP in the egg and the pods then wait on a peer that will never boot. Roster issues join the pane's existing issue text, so the Go button is still the gate. The roster is one file per launch generation and deliberately not a live view: pod peer tables are boot-static, so a player whose lobby crashed is still in every pod's table and still playable, and live tracking would evict that working pod mid-session. Poll runs at ~1Hz off the existing network timer with its own deadline, and the strips are built at runtime — InitializeComponent is decompiled 1995 designer output that the differential tests compare literally. Go/Load also re-checks CheckAllValues at the click instead of trusting the last status tick: a pod that died in that gap went straight into the mission, and the post-Load barrier in NetworkScan then waited forever for a WaitingForLaunch that never arrives. vPOD gains -bind <ip>. Both listeners defaulted to IPAddress.Any, so a second vPOD on the machine lost the port and the console could only ever see one fake pod; binding each instance to its own address runs a whole eight-pod session side by side with no cockpits. Bind all of them or none — Windows lets IPAddress.Any take a port that specific addresses already hold, and the unbound instance then answers for every slot nobody claimed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1054 lines
32 KiB
C#
1054 lines
32 KiB
C#
using System;
|
|
using System.Drawing;
|
|
using System.Text;
|
|
using System.Windows.Forms;
|
|
using Munga.Net;
|
|
using Tesla;
|
|
using Tesla.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. The right column is the virtual launcher — the pod
|
|
/// side of the console's Site Management (provisioning, product installs,
|
|
/// launch/kill/volume/shutdown), see LauncherRpcServer / PodProvisioning.
|
|
/// </summary>
|
|
internal sealed class VPodForm : Form
|
|
{
|
|
private readonly PodArguments mOptions;
|
|
private readonly MungaPodServer mServer;
|
|
private readonly PodSimulator mSimulator;
|
|
private readonly VirtualLauncher mLauncher;
|
|
private readonly LauncherRpcServer mRpcServer;
|
|
private readonly PodProvisioning mProvisioning;
|
|
|
|
private Label mListeningLabel;
|
|
private Label mConnectionLabel;
|
|
private Label mStateLabel;
|
|
private Label mEggSummaryLabel;
|
|
private RadioButton mRedPlanetRadio;
|
|
private RadioButton mBattleTechRadio;
|
|
private Button mPowerButton;
|
|
private Button mPowerOffButton;
|
|
private Button mStartGameButton;
|
|
private Button mStopGameButton;
|
|
private Button mResetButton;
|
|
private CheckBox mRestartCheckbox;
|
|
private Timer mRestartTimer;
|
|
private Timer mRebootTimer;
|
|
private SplitContainer mSplit;
|
|
private TextBox mEggBox;
|
|
private TextBox mLogBox;
|
|
|
|
// Virtual launcher / site-management column.
|
|
private Label mProvisionStatusLabel;
|
|
private Panel mPassphrasePanel;
|
|
private Label mRequestIdValueLabel;
|
|
private Label mPassphraseValueLabel;
|
|
private Label mNetConfigLabel;
|
|
private Label mRpcStatusLabel;
|
|
private Label mInstallStatusLabel;
|
|
private ProgressBar mInstallProgressBar;
|
|
private Button mReprovisionButton;
|
|
private CheckBox mRunScriptsCheckbox;
|
|
private CheckBox mRealLaunchCheckbox;
|
|
private CheckBox mRealAutoRestartCheckbox;
|
|
private CheckBox mRealVolumeCheckbox;
|
|
private ListView mAppsView;
|
|
|
|
private bool mPoweredOn;
|
|
|
|
// How long the "watchdog" waits before relaunching the exited game.
|
|
private const int WatchdogRestartMs = 1500;
|
|
|
|
// How long a console-requested Shutdown(restart) keeps the pod dark.
|
|
private const int RebootRestartMs = 4000;
|
|
|
|
public VPodForm(PodArguments options)
|
|
{
|
|
mOptions = options;
|
|
mServer = new MungaPodServer(options.Port, options.Bind);
|
|
mSimulator = new PodSimulator(mServer, options.Application, options.HostId);
|
|
mLauncher = new VirtualLauncher();
|
|
mRpcServer = new LauncherRpcServer(mLauncher, LauncherRpcServer.ManagePort, options.Bind);
|
|
mProvisioning = new PodProvisioning(PodProvisioning.MacForHost(options.HostId));
|
|
BuildUi();
|
|
|
|
mServer.Log += OnLog;
|
|
mServer.ConnectionChanged += OnConnectionChanged;
|
|
mServer.MessageReceived += OnMessageReceived;
|
|
mSimulator.Log += OnLog;
|
|
mSimulator.StateChanged += OnStateChanged;
|
|
mSimulator.EggReceived += OnEggReceived;
|
|
mSimulator.EggProgress += OnEggProgress;
|
|
mSimulator.EndMissionExit += OnEndMissionExit;
|
|
|
|
mLauncher.Log += OnSiteLog;
|
|
mLauncher.AppsChanged += OnAppsChanged;
|
|
mLauncher.InstallProgressChanged += OnInstallProgress;
|
|
mLauncher.ShutdownRequested += OnShutdownRequested;
|
|
mLauncher.ReprovisionRequested += OnReprovisionRequested;
|
|
mRpcServer.Log += OnSiteLog;
|
|
mRpcServer.ConnectionsChanged += OnRpcConnectionsChanged;
|
|
mProvisioning.Log += OnSiteLog;
|
|
mProvisioning.ConfigReceived += OnConfigReceived;
|
|
mProvisioning.Provisioned += OnProvisioned;
|
|
|
|
mRestartTimer = new Timer { Interval = WatchdogRestartMs };
|
|
mRestartTimer.Tick += OnRestartTimerTick;
|
|
mRebootTimer = new Timer { Interval = RebootRestartMs };
|
|
mRebootTimer.Tick += OnRebootTimerTick;
|
|
|
|
Load += OnFormLoad;
|
|
FormClosing += OnFormClosing;
|
|
}
|
|
|
|
private void BuildUi()
|
|
{
|
|
Text = $"vPOD - virtual pod (port {mOptions.Port}, host {mOptions.HostId})";
|
|
ClientSize = new Size(1280, 560);
|
|
MinimumSize = new Size(1100, 500);
|
|
Font = new Font("Segoe UI", 9f);
|
|
|
|
// ---- status panel ----
|
|
GroupBox statusGroup = new GroupBox
|
|
{
|
|
Text = "Pod Status",
|
|
Dock = DockStyle.Top,
|
|
Height = 176,
|
|
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 = "—"
|
|
};
|
|
|
|
// pod power: the whole virtual machine (launcher/site management included),
|
|
// distinct from starting/stopping the emulated game client below.
|
|
GroupBox powerGroup = new GroupBox
|
|
{
|
|
Text = "Pod Power",
|
|
Location = new Point(740, 20),
|
|
Size = new Size(150, 130)
|
|
};
|
|
mPowerButton = new Button { Text = "Power On", Location = new Point(12, 28), Size = new Size(126, 30) };
|
|
mPowerOffButton = new Button { Text = "Power Off", Location = new Point(12, 66), Size = new Size(126, 30) };
|
|
mPowerButton.Click += PowerOnClicked;
|
|
mPowerOffButton.Click += PowerOffClicked;
|
|
powerGroup.Controls.Add(mPowerButton);
|
|
powerGroup.Controls.Add(mPowerOffButton);
|
|
|
|
// game toggle + game-process controls (the "exe", not the machine)
|
|
GroupBox gameGroup = new GroupBox
|
|
{
|
|
Text = "Mimicking Game",
|
|
Location = new Point(400, 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;
|
|
mStartGameButton = new Button { Text = "Start Game", Location = new Point(12, 90), Size = new Size(94, 28) };
|
|
mStopGameButton = new Button { Text = "Stop Game", 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) };
|
|
mStartGameButton.Click += StartGameClicked;
|
|
mStopGameButton.Click += StopGameClicked;
|
|
mResetButton.Click += (s, e) => mSimulator.Reset();
|
|
gameGroup.Controls.Add(mRedPlanetRadio);
|
|
gameGroup.Controls.Add(mBattleTechRadio);
|
|
gameGroup.Controls.Add(mStartGameButton);
|
|
gameGroup.Controls.Add(mStopGameButton);
|
|
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(powerGroup);
|
|
statusGroup.Controls.Add(gameGroup);
|
|
|
|
// ---- egg viewer + log ----
|
|
// 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,
|
|
Orientation = Orientation.Vertical
|
|
};
|
|
SplitContainer split = mSplit;
|
|
|
|
GroupBox eggGroup = new GroupBox { Text = "Current Egg", Dock = DockStyle.Fill, Padding = new Padding(8) };
|
|
// 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,
|
|
Multiline = true,
|
|
ReadOnly = true,
|
|
ScrollBars = ScrollBars.Both,
|
|
WordWrap = false,
|
|
Font = new Font("Consolas", 9f),
|
|
BackColor = Color.White
|
|
};
|
|
eggGroup.Controls.Add(mEggBox);
|
|
eggGroup.Controls.Add(eggHeader);
|
|
|
|
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);
|
|
|
|
// ---- virtual launcher / site management column ----
|
|
GroupBox siteGroup = new GroupBox
|
|
{
|
|
Text = "Launcher / Site Management",
|
|
Dock = DockStyle.Right,
|
|
Width = 360,
|
|
Padding = new Padding(8)
|
|
};
|
|
|
|
Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 300 };
|
|
|
|
mProvisionStatusLabel = new Label
|
|
{
|
|
AutoSize = true,
|
|
Location = new Point(4, 4),
|
|
Font = new Font("Segoe UI", 9f, FontStyle.Bold),
|
|
Text = "Provisioning: —"
|
|
};
|
|
|
|
// Stand-in for the real pod's TeslaPasscodeDisplay: the operator reads
|
|
// these two values into the console's Configure dialog.
|
|
mPassphrasePanel = new Panel { Location = new Point(4, 28), Size = new Size(336, 62), Visible = false };
|
|
Label requestIdCaption = new Label { AutoSize = true, Location = new Point(0, 6), Text = "Request ID:" };
|
|
mRequestIdValueLabel = new Label
|
|
{
|
|
AutoSize = true,
|
|
Location = new Point(90, 0),
|
|
Font = new Font("Segoe UI", 15f, FontStyle.Bold),
|
|
Text = "—"
|
|
};
|
|
Label passphraseCaption = new Label { AutoSize = true, Location = new Point(0, 38), Text = "Passphrase:" };
|
|
mPassphraseValueLabel = new Label
|
|
{
|
|
AutoSize = true,
|
|
Location = new Point(90, 32),
|
|
Font = new Font("Segoe UI", 15f, FontStyle.Bold),
|
|
ForeColor = Color.Firebrick,
|
|
Text = "—"
|
|
};
|
|
mPassphrasePanel.Controls.Add(requestIdCaption);
|
|
mPassphrasePanel.Controls.Add(mRequestIdValueLabel);
|
|
mPassphrasePanel.Controls.Add(passphraseCaption);
|
|
mPassphrasePanel.Controls.Add(mPassphraseValueLabel);
|
|
|
|
mNetConfigLabel = new Label
|
|
{
|
|
Location = new Point(4, 94),
|
|
Size = new Size(336, 32),
|
|
ForeColor = Color.Gray,
|
|
Text = ""
|
|
};
|
|
|
|
mRpcStatusLabel = new Label
|
|
{
|
|
AutoSize = true,
|
|
Location = new Point(4, 130),
|
|
Text = "Launcher RPC: not running"
|
|
};
|
|
|
|
mInstallStatusLabel = new Label
|
|
{
|
|
Location = new Point(4, 154),
|
|
Size = new Size(336, 18),
|
|
Text = "No install in progress."
|
|
};
|
|
mInstallProgressBar = new ProgressBar
|
|
{
|
|
Location = new Point(4, 176),
|
|
Size = new Size(240, 22),
|
|
Minimum = 0,
|
|
Maximum = 100
|
|
};
|
|
mReprovisionButton = new Button
|
|
{
|
|
Text = "Reprovision",
|
|
Location = new Point(252, 174),
|
|
Size = new Size(88, 26)
|
|
};
|
|
mReprovisionButton.Click += ReprovisionClicked;
|
|
|
|
// Off (default) = packaged product scripts (postinstall.bat on install,
|
|
// pre-uninstall.bat on uninstall) are logged and removed unrun; on =
|
|
// they are executed like on the real pod.
|
|
mRunScriptsCheckbox = new CheckBox
|
|
{
|
|
Text = "Run package install/uninstall scripts",
|
|
Location = new Point(4, 206),
|
|
AutoSize = true,
|
|
Checked = false
|
|
};
|
|
mRunScriptsCheckbox.CheckedChanged += (s, e) =>
|
|
{
|
|
mLauncher.RunPackageScripts = mRunScriptsCheckbox.Checked;
|
|
OnSiteLog(mRunScriptsCheckbox.Checked
|
|
? "Packaged postinstall.bat / pre-uninstall.bat now EXECUTE."
|
|
: "Packaged postinstall.bat / pre-uninstall.bat are skipped (and removed).");
|
|
};
|
|
|
|
// Off = LaunchApp records simulated PIDs; on = start/kill real processes
|
|
// (the entries point into the real C:\Games, so this runs what the
|
|
// console deployed — exactly like the Agent).
|
|
mRealLaunchCheckbox = new CheckBox
|
|
{
|
|
Text = "Actually launch apps (real processes)",
|
|
Location = new Point(4, 228),
|
|
AutoSize = true,
|
|
Checked = false
|
|
};
|
|
mRealLaunchCheckbox.CheckedChanged += (s, e) =>
|
|
{
|
|
mLauncher.RealLaunch = mRealLaunchCheckbox.Checked;
|
|
mRealAutoRestartCheckbox.Enabled = mRealLaunchCheckbox.Checked;
|
|
OnSiteLog(mRealLaunchCheckbox.Checked
|
|
? "Launch/Kill now start and terminate REAL processes."
|
|
: "Launch/Kill now simulate PIDs (no real processes).");
|
|
};
|
|
|
|
// The Agent's autoRestart watchdog for real-launched apps (applies only
|
|
// to entries registered with autoRestart, like the real pod).
|
|
mRealAutoRestartCheckbox = new CheckBox
|
|
{
|
|
Text = "Auto-restart after the app exits (watchdog)",
|
|
Location = new Point(22, 250),
|
|
AutoSize = true,
|
|
Checked = true,
|
|
Enabled = false // meaningful only in real-launch mode
|
|
};
|
|
mRealAutoRestartCheckbox.CheckedChanged += (s, e) =>
|
|
{
|
|
mLauncher.RealAutoRestart = mRealAutoRestartCheckbox.Checked;
|
|
OnSiteLog(mRealAutoRestartCheckbox.Checked
|
|
? "Watchdog ON: autoRestart apps relaunch 2 s after exiting."
|
|
: "Watchdog OFF: exited apps stay down.");
|
|
};
|
|
|
|
// Off (default) = set_VolumeLevel only stores/echoes the float; on = it
|
|
// drives this machine's real master volume through the launcher's own
|
|
// chain (nircmd in the games root -> Core Audio -> winmm).
|
|
mRealVolumeCheckbox = new CheckBox
|
|
{
|
|
Text = "Actually set system volume",
|
|
Location = new Point(4, 272),
|
|
AutoSize = true,
|
|
Checked = false
|
|
};
|
|
mRealVolumeCheckbox.CheckedChanged += (s, e) =>
|
|
{
|
|
mLauncher.RealVolume = mRealVolumeCheckbox.Checked;
|
|
OnSiteLog(mRealVolumeCheckbox.Checked
|
|
? "set_VolumeLevel now changes the REAL system master volume."
|
|
: "set_VolumeLevel now only stores the value (no audio change).");
|
|
};
|
|
|
|
siteTop.Controls.Add(mProvisionStatusLabel);
|
|
siteTop.Controls.Add(mPassphrasePanel);
|
|
siteTop.Controls.Add(mNetConfigLabel);
|
|
siteTop.Controls.Add(mRpcStatusLabel);
|
|
siteTop.Controls.Add(mInstallStatusLabel);
|
|
siteTop.Controls.Add(mInstallProgressBar);
|
|
siteTop.Controls.Add(mReprovisionButton);
|
|
siteTop.Controls.Add(mRunScriptsCheckbox);
|
|
siteTop.Controls.Add(mRealLaunchCheckbox);
|
|
siteTop.Controls.Add(mRealAutoRestartCheckbox);
|
|
siteTop.Controls.Add(mRealVolumeCheckbox);
|
|
|
|
mAppsView = new ListView
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
View = View.Details,
|
|
FullRowSelect = true,
|
|
HeaderStyle = ColumnHeaderStyle.Nonclickable
|
|
};
|
|
mAppsView.Columns.Add("Installed App", 150);
|
|
mAppsView.Columns.Add("PID", 48);
|
|
mAppsView.Columns.Add("Auto", 42);
|
|
mAppsView.Columns.Add("Command", 320);
|
|
|
|
siteGroup.Controls.Add(mAppsView);
|
|
siteGroup.Controls.Add(siteTop);
|
|
|
|
Controls.Add(split);
|
|
Controls.Add(statusGroup);
|
|
Controls.Add(siteGroup);
|
|
}
|
|
|
|
private void OnFormLoad(object sender, EventArgs e)
|
|
{
|
|
// 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);
|
|
RefreshAppsList();
|
|
PowerOnClicked(this, EventArgs.Empty);
|
|
}
|
|
|
|
private void OnFormClosing(object sender, FormClosingEventArgs e)
|
|
{
|
|
mServer.Stop();
|
|
StopManagement();
|
|
mLauncher.KillAllApps(cancelPendingRestarts: true); // don't orphan real launched processes when the tool exits
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pod power = the whole virtual machine. Powering on boots the
|
|
/// launcher/site-management side and auto-starts the game client (a real
|
|
/// pod's boot-time autoRestart launch); the game can then be stopped and
|
|
/// started on its own while the pod stays up.
|
|
/// </summary>
|
|
private void PowerOnClicked(object sender, EventArgs e)
|
|
{
|
|
mRebootTimer.Stop();
|
|
SetPoweredState(on: true);
|
|
StartManagement();
|
|
StartGameClicked(sender, e);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Powers the whole pod off: the game listener AND the launcher/provisioning
|
|
/// side go dark, like unplugging the machine. (To mimic just the game exe
|
|
/// exiting while the pod stays up, use Stop Game.)
|
|
/// </summary>
|
|
private void PowerOffClicked(object sender, EventArgs e)
|
|
{
|
|
mRebootTimer.Stop();
|
|
StopGame();
|
|
StopManagement();
|
|
mLauncher.KillAllApps(cancelPendingRestarts: true); // the machine is "off": launched apps (real or simulated) die with it
|
|
SetPoweredState(on: false);
|
|
}
|
|
|
|
/// <summary>Starts the emulated game client (Munga listener) on a powered-on pod.</summary>
|
|
private void StartGameClicked(object sender, EventArgs e)
|
|
{
|
|
if (!mPoweredOn)
|
|
{
|
|
return;
|
|
}
|
|
mRestartTimer.Stop(); // cancel any pending watchdog restart
|
|
if (!mServer.IsListening && !StartServer())
|
|
{
|
|
SetGameState(running: false);
|
|
return; // couldn't bind the port; pod stays up, game stays stopped
|
|
}
|
|
mSimulator.PowerOn();
|
|
SetGameState(running: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops just the emulated game "exe": the Munga listener closes (the
|
|
/// console's game connection drops) but the pod — and its launcher /
|
|
/// site-management side — stays up, like killing the game process on a
|
|
/// real pod.
|
|
/// </summary>
|
|
private void StopGameClicked(object sender, EventArgs e)
|
|
{
|
|
StopGame();
|
|
}
|
|
|
|
private void StopGame()
|
|
{
|
|
mRestartTimer.Stop();
|
|
mServer.Stop();
|
|
SetGameState(running: 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;
|
|
mStartGameButton.Enabled = true;
|
|
mStopGameButton.Enabled = false;
|
|
mResetButton.Enabled = false;
|
|
mRestartTimer.Stop();
|
|
mRestartTimer.Start();
|
|
}));
|
|
}
|
|
|
|
private void OnRestartTimerTick(object sender, EventArgs e)
|
|
{
|
|
mRestartTimer.Stop();
|
|
if (!mPoweredOn)
|
|
{
|
|
return; // pod was powered off while the watchdog was pending
|
|
}
|
|
if (StartServer())
|
|
{
|
|
OnLog("Watchdog restarted the game.");
|
|
mSimulator.PowerOn();
|
|
SetGameState(running: true);
|
|
}
|
|
else
|
|
{
|
|
SetGameState(running: 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);
|
|
return true;
|
|
}
|
|
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);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Reflects the pod's (machine-level) on/off state in the buttons and,
|
|
/// when off, the status labels. Game-level state is <see cref="SetGameState" />.</summary>
|
|
private void SetPoweredState(bool on)
|
|
{
|
|
mPoweredOn = on;
|
|
mPowerButton.Enabled = !on;
|
|
mPowerOffButton.Enabled = on;
|
|
mReprovisionButton.Enabled = on && !mOptions.NoManage;
|
|
if (!on)
|
|
{
|
|
mStartGameButton.Enabled = false;
|
|
mStopGameButton.Enabled = false;
|
|
mResetButton.Enabled = false;
|
|
UpdateListeningLabel(false);
|
|
UpdateConnectionLabel(null);
|
|
mStateLabel.Text = "Powered Off";
|
|
mStateLabel.ForeColor = Color.Gray;
|
|
mProvisionStatusLabel.Text = "Provisioning: pod powered off";
|
|
mProvisionStatusLabel.ForeColor = Color.Gray;
|
|
mPassphrasePanel.Visible = false;
|
|
mRpcStatusLabel.Text = "Launcher RPC: pod powered off";
|
|
mRpcStatusLabel.ForeColor = Color.Gray;
|
|
}
|
|
}
|
|
|
|
/// <summary>Reflects whether the emulated game client is running; the pod
|
|
/// itself (launcher/site management) may still be up with the game stopped.</summary>
|
|
private void SetGameState(bool running)
|
|
{
|
|
mStartGameButton.Enabled = mPoweredOn && !running;
|
|
mStopGameButton.Enabled = mPoweredOn && running;
|
|
mResetButton.Enabled = mPoweredOn && running;
|
|
if (!running)
|
|
{
|
|
UpdateListeningLabel(false);
|
|
UpdateConnectionLabel(null);
|
|
if (mPoweredOn)
|
|
{
|
|
mStateLabel.Text = "Game Not Running";
|
|
mStateLabel.ForeColor = Color.Gray;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- virtual launcher / site management lifecycle ----
|
|
|
|
/// <summary>Brings the launcher side up for a powered-on pod: the RPC server
|
|
/// when a session key is stored, otherwise the provisioning beacons.</summary>
|
|
private void StartManagement()
|
|
{
|
|
if (mOptions.NoManage)
|
|
{
|
|
mProvisionStatusLabel.Text = "Provisioning: disabled (-nomanage)";
|
|
mProvisionStatusLabel.ForeColor = Color.Gray;
|
|
mRpcStatusLabel.Text = "Launcher RPC: disabled (-nomanage)";
|
|
mRpcStatusLabel.ForeColor = Color.Gray;
|
|
return;
|
|
}
|
|
byte[] key = mLauncher.LoadKey();
|
|
if (key != null)
|
|
{
|
|
StartRpcServer(key);
|
|
}
|
|
else
|
|
{
|
|
StartProvisioning();
|
|
}
|
|
}
|
|
|
|
private void StopManagement()
|
|
{
|
|
mProvisioning.Stop();
|
|
mRpcServer.Stop();
|
|
}
|
|
|
|
private void StartRpcServer(byte[] key)
|
|
{
|
|
try
|
|
{
|
|
mRpcServer.Start(key);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
OnSiteLog($"FAILED to listen on TCP {LauncherRpcServer.ManagePort}: {ex.Message}");
|
|
mRpcStatusLabel.Text = $"Launcher RPC: TCP {LauncherRpcServer.ManagePort} unavailable";
|
|
mRpcStatusLabel.ForeColor = Color.Firebrick;
|
|
return;
|
|
}
|
|
mProvisionStatusLabel.Text = "Provisioned — key " + KeyFingerprint(key);
|
|
mProvisionStatusLabel.ForeColor = Color.ForestGreen;
|
|
mPassphrasePanel.Visible = false;
|
|
mRpcStatusLabel.Text = $"Launcher RPC: listening on TCP {LauncherRpcServer.ManagePort}";
|
|
mRpcStatusLabel.ForeColor = Color.ForestGreen;
|
|
}
|
|
|
|
private void StartProvisioning()
|
|
{
|
|
mProvisioning.Start();
|
|
mProvisionStatusLabel.Text = "Unprovisioned — beaconing to console...";
|
|
mProvisionStatusLabel.ForeColor = Color.DarkGoldenrod;
|
|
mRequestIdValueLabel.Text = mProvisioning.RequestId;
|
|
mPassphraseValueLabel.Text = mProvisioning.Passphrase;
|
|
mPassphrasePanel.Visible = true;
|
|
mNetConfigLabel.Text = "Use Manage Site's \"Configure\" button and enter the passphrase above.";
|
|
mRpcStatusLabel.Text = "Launcher RPC: waiting for provisioning";
|
|
mRpcStatusLabel.ForeColor = Color.Gray;
|
|
}
|
|
|
|
/// <summary>Drops the session key, clears the installed-apps store and
|
|
/// re-enters provisioning — from the UI button, or when the console's
|
|
/// Reconfigure clears our store. (Fresh-pod state; extracted C:\Games
|
|
/// files are left on disk.)</summary>
|
|
private void DropKeyAndReprovision()
|
|
{
|
|
mLauncher.WipeApps();
|
|
mLauncher.DeleteKey();
|
|
mRpcServer.Stop();
|
|
mProvisioning.Stop();
|
|
mNetConfigLabel.Text = "";
|
|
if (mPoweredOn && !mOptions.NoManage)
|
|
{
|
|
StartProvisioning();
|
|
}
|
|
}
|
|
|
|
private void ReprovisionClicked(object sender, EventArgs e)
|
|
{
|
|
OnSiteLog("Reprovision requested from the vPOD window.");
|
|
DropKeyAndReprovision();
|
|
}
|
|
|
|
private static string KeyFingerprint(byte[] key)
|
|
{
|
|
return key.Length >= 4 ? $"{key[0]:X2}{key[1]:X2}{key[2]:X2}{key[3]:X2}…" : "?";
|
|
}
|
|
|
|
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;
|
|
// 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)));
|
|
}
|
|
|
|
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}%";
|
|
}
|
|
}));
|
|
}
|
|
|
|
// ---- virtual launcher event handlers (marshal to UI thread) ----
|
|
|
|
private void OnSiteLog(string message)
|
|
{
|
|
OnLog("[SITE] " + message);
|
|
}
|
|
|
|
private void OnProvisioned(byte[] key)
|
|
{
|
|
if (IsDisposed) return;
|
|
BeginInvoke((Action)(() =>
|
|
{
|
|
mLauncher.SaveKey(key);
|
|
mProvisioning.Stop();
|
|
StartRpcServer(key);
|
|
}));
|
|
}
|
|
|
|
private void OnConfigReceived(BasicConfigResponse config)
|
|
{
|
|
if (IsDisposed) return;
|
|
BeginInvoke((Action)(() =>
|
|
{
|
|
mNetConfigLabel.Text = $"Assigned (display only): IP {config.Address} / {config.Mask}" +
|
|
(string.IsNullOrEmpty(config.HostName) ? "" : $"\r\nhost \"{config.HostName}\", gw {config.Gateway}, dns {config.Dns}");
|
|
}));
|
|
}
|
|
|
|
private void OnRpcConnectionsChanged(int count)
|
|
{
|
|
if (IsDisposed) return;
|
|
BeginInvoke((Action)(() =>
|
|
{
|
|
if (!mRpcServer.IsListening)
|
|
{
|
|
return; // Stop() already updated the label
|
|
}
|
|
mRpcStatusLabel.Text = count > 0
|
|
? $"Launcher RPC: {count} console session(s) on TCP {LauncherRpcServer.ManagePort}"
|
|
: $"Launcher RPC: listening on TCP {LauncherRpcServer.ManagePort}";
|
|
mRpcStatusLabel.ForeColor = Color.ForestGreen;
|
|
}));
|
|
}
|
|
|
|
private void OnAppsChanged()
|
|
{
|
|
if (IsDisposed) return;
|
|
BeginInvoke((Action)RefreshAppsList);
|
|
}
|
|
|
|
private void RefreshAppsList()
|
|
{
|
|
LaunchData[] installed = mLauncher.GetInstalledApps();
|
|
LaunchedAppData[] launched = mLauncher.GetLaunchedApps();
|
|
mAppsView.BeginUpdate();
|
|
mAppsView.Items.Clear();
|
|
foreach (LaunchData app in installed)
|
|
{
|
|
StringBuilder pids = new StringBuilder();
|
|
foreach (LaunchedAppData proc in launched)
|
|
{
|
|
if (proc.LaunchKey == app.LaunchPair.LaunchKey)
|
|
{
|
|
if (pids.Length > 0) pids.Append(", ");
|
|
pids.Append(proc.ProcessId);
|
|
}
|
|
}
|
|
ListViewItem item = new ListViewItem(new[]
|
|
{
|
|
app.LaunchPair.DisplayName,
|
|
pids.ToString(),
|
|
app.AutoRestart ? "yes" : "",
|
|
string.IsNullOrEmpty(app.Arguments) ? app.ExeFile : app.ExeFile + " " + app.Arguments
|
|
});
|
|
mAppsView.Items.Add(item);
|
|
}
|
|
mAppsView.EndUpdate();
|
|
}
|
|
|
|
private void OnInstallProgress(Guid callId, OutOfBandProgress progress)
|
|
{
|
|
if (IsDisposed) return;
|
|
BeginInvoke((Action)(() =>
|
|
{
|
|
int percent = Math.Max(0, Math.Min(100, progress.PercentComplete));
|
|
if (progress.IsCompleted && percent >= 99)
|
|
{
|
|
// The wire deliberately tops out at 99 (the console retries on
|
|
// 100) — but the local bar can still show a finished install.
|
|
percent = 100;
|
|
}
|
|
mInstallProgressBar.Value = percent;
|
|
mInstallStatusLabel.Text = progress.IsCompleted
|
|
? "Install: " + progress.Status
|
|
: $"Installing: {progress.Status} ({progress.PercentComplete}%)";
|
|
}));
|
|
}
|
|
|
|
/// <summary>The console asked the pod to shut down or restart: go fully dark
|
|
/// (Munga + launcher), and come back after a "reboot" pause when restarting.</summary>
|
|
private void OnShutdownRequested(bool restart)
|
|
{
|
|
if (IsDisposed) return;
|
|
BeginInvoke((Action)(() =>
|
|
{
|
|
mRebootTimer.Stop();
|
|
StopGame();
|
|
StopManagement();
|
|
mLauncher.KillAllApps(cancelPendingRestarts: true); // launched apps die with the rebooting/halting machine
|
|
SetPoweredState(on: false);
|
|
if (restart)
|
|
{
|
|
mStateLabel.Text = "Rebooting...";
|
|
mStateLabel.ForeColor = Color.DarkOrange;
|
|
mRebootTimer.Start();
|
|
}
|
|
}));
|
|
}
|
|
|
|
private void OnRebootTimerTick(object sender, EventArgs e)
|
|
{
|
|
mRebootTimer.Stop();
|
|
OnLog("[SITE] Pod rebooted.");
|
|
PowerOnClicked(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// <summary>The console's Reconfigure cleared our store — drop the key and go
|
|
/// back to beaconing so the "Configure" button reappears in Manage Site.</summary>
|
|
private void OnReprovisionRequested()
|
|
{
|
|
if (IsDisposed) return;
|
|
BeginInvoke((Action)DropKeyAndReprovision);
|
|
}
|
|
|
|
// ---- 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();
|
|
}
|
|
}
|