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>
255 lines
6.4 KiB
C#
255 lines
6.4 KiB
C#
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;
|
|
}
|
|
}
|