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>
This commit is contained in:
@@ -111,4 +111,33 @@
|
||||
autoRestart="true"
|
||||
hostType="None" />
|
||||
</Product>
|
||||
|
||||
<!-- vPOD - virtual pod / game-client stand-in for testing the game consoles
|
||||
(Console\vPOD). Speaks Munga on 1501 like a real rpl4opt.exe/btl4.exe and
|
||||
reports either ApplicationID (toggled live in its window). Deploy it to a
|
||||
pod exactly like a real client; the package (Console\vPOD\dist\vPOD.zip,
|
||||
built by pack.ps1) extracts to C:\Games\vPOD. -->
|
||||
<Product id="0041C870-6E5E-4F3B-9782-F94F2F76F21D"
|
||||
name="vPOD (Virtual Pod)"
|
||||
menuText="vPOD (Virtual Pod)..."
|
||||
hostTypeDialog="true">
|
||||
<Launch key="0041C870-6E5E-4F3B-9782-F94F2F76F21D"
|
||||
displayName="vPOD"
|
||||
exe="C:\Games\vPOD\vPOD.exe"
|
||||
args="-net 1501{res}"
|
||||
autoRestart="true"
|
||||
hostType="GameClient" />
|
||||
<Launch key="EA0D4129-8950-428D-8399-E6A77D2D566A"
|
||||
displayName="vPOD LC"
|
||||
exe="C:\Games\vPOD\vPOD.exe"
|
||||
args="-net 1501{res} -lc"
|
||||
autoRestart="true"
|
||||
hostType="LiveCamera" />
|
||||
<Launch key="FC7CE34E-F4FE-4218-84CD-B13A6FA58E57"
|
||||
displayName="vPOD MR"
|
||||
exe="C:\Games\vPOD\vPOD.exe"
|
||||
args="-net 1501{res} -mr"
|
||||
autoRestart="true"
|
||||
hostType="MissionReview" />
|
||||
</Product>
|
||||
</AppCatalog>
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
<Compile Remove="tests\**" />
|
||||
<None Remove="tests\**" />
|
||||
<Content Remove="tests\**" />
|
||||
<!-- vPOD is its own deployable exe under vPOD\; exclude it from the console build. -->
|
||||
<Compile Remove="vPOD\**" />
|
||||
<None Remove="vPOD\**" />
|
||||
<Content Remove="vPOD\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using Munga.Net;
|
||||
|
||||
namespace VPod;
|
||||
|
||||
/// <summary>
|
||||
/// The server side of the Munga control protocol. A real game client is the
|
||||
/// Munga server (the console <c>Connect()</c>s to pod:1501); the vendored
|
||||
/// <see cref="MungaSocket" /> 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.
|
||||
/// </summary>
|
||||
internal sealed class MungaPodServer
|
||||
{
|
||||
/// <summary>A message received from the console, plus the header it arrived under.</summary>
|
||||
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<Incoming> MessageReceived;
|
||||
public event Action<string> ConnectionChanged; // remote endpoint string, or null when dropped
|
||||
public event Action<string> 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 };
|
||||
}
|
||||
|
||||
/// <summary>Maps a (ClientID, MessageID) to the vendored typed message. Mirrors MungaSocket.Receive.</summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>Sends a pod->console message on the current connection (no-op if disconnected).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using Munga.Net;
|
||||
|
||||
namespace VPod;
|
||||
|
||||
/// <summary>
|
||||
/// 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:
|
||||
/// <c>-net <port></c> Munga control port (default 1501)
|
||||
/// <c>-lc</c> live-camera role (cosmetic here; state model is identical)
|
||||
/// <c>-mr</c> mission-review role
|
||||
/// <c>-res W H</c> resolution (ignored)
|
||||
/// <c>-app rp|bt</c> which ApplicationID to report (RP by default; also
|
||||
/// switchable live in the UI)
|
||||
/// <c>-host <id></c> the responding host id reported in state responses
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirror of the console's HostType (GameMachine=0, MissionReview=2, Console=3).
|
||||
/// Duplicated here so vPOD does not depend on the console assembly.
|
||||
/// </summary>
|
||||
internal enum HostType
|
||||
{
|
||||
GameMachineHostType = 0,
|
||||
MissionReviewHostType = 2,
|
||||
ConsoleHostType = 3
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
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;
|
||||
|
||||
// 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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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");
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 <port>] [-app rp|bt] [-lc|-mr] [-host <id>] [-res W H]
|
||||
```
|
||||
|
||||
- `-net <port>` 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 <id>` 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.
|
||||
@@ -0,0 +1,374 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -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
|
||||
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
vPOD - a virtual pod / game-client stand-in for testing the Tesla game
|
||||
consoles (Red Planet and BattleTech) 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 rpl4opt.exe / btl4.exe),
|
||||
emulates the pod ApplicationState machine, reassembles the streamed egg,
|
||||
and shows both on a live display. Deployable to a pod machine via the
|
||||
console's Manage Site -> Install Product (see dist\ + RedPlanet\Apps.xml).
|
||||
|
||||
net48 to match the rest of the suite and the vendored Munga Net.dll.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<AssemblyName>vPOD</AssemblyName>
|
||||
<RootNamespace>VPod</RootNamespace>
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<Version>1.0.0</Version>
|
||||
<Product>vPOD</Product>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- net48 reference assemblies so this builds without a full targeting pack installed -->
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The Munga wire types (messages, header, enums). Copied next to vPOD.exe so
|
||||
the deployable package is self-contained. -->
|
||||
<Reference Include="Munga Net">
|
||||
<HintPath>..\lib\Munga Net.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user