Files
TeslaSuite/Console/vPOD/PodSimulator.cs
T
CydandClaude Fable 5 fd0c8c5196 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>
2026-07-08 10:43:44 -05:00

334 lines
8.8 KiB
C#

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using Munga.Net;
namespace VPod;
/// <summary>
/// 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.
/// </summary>
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;
private bool mRestartOnEndMission = true;
// Egg reassembly
private byte[] mEggBuffer;
private int mEggTotal;
private int mEggReceived;
private readonly HashSet<int> mEggChunks = new HashSet<int>();
private string mEggText;
// Cancels a pending delayed transition when a newer one supersedes it.
private int mTransitionToken;
private Timer mTransitionTimer;
public event Action<ApplicationState> StateChanged;
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)
{
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); } }
}
/// <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
{
get
{
lock (mLock)
{
if (mEggTotal <= 0) return 0;
return (int)(100L * mEggReceived / mEggTotal);
}
}
}
/// <summary>Bring the pod online: Initializing -> WaitingForEgg.</summary>
public void PowerOn()
{
SetState(ApplicationState.InitializingState);
ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true);
}
/// <summary>Operator reset: drop any mission and return to WaitingForEgg immediately.</summary>
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 (end mission)");
if (State == ApplicationState.RunningMission || State == ApplicationState.SuspendingMission)
{
SetState(ApplicationState.EndingMission);
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;
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;
}
}