Files
TeslaSuite/vPOD/MungaPodServer.cs
T
CydandClaude Opus 5 33f734da2d Console: internet-session roster from TeslaLobby; vPOD per-address bind
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>
2026-07-28 13:14:38 -05:00

266 lines
6.8 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 readonly IPAddress mBind; // null = every interface (the default)
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)
: this(port, null)
{
}
// bind == null keeps the historical every-interface behaviour, so existing
// callers and single-pod runs are unchanged.
public MungaPodServer(int port, IPAddress bind)
{
mPort = port;
mBind = bind;
}
public void Start()
{
if (mRunning)
{
return;
}
mListener = new TcpListener(mBind ?? IPAddress.Any, mPort);
mListener.Start();
mRunning = true;
mAcceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "vPOD-accept" };
mAcceptThread.Start();
Log?.Invoke(mBind == null
? $"Listening on TCP {mPort} (all interfaces)."
: $"Listening on TCP {mPort} ({mBind} only).");
}
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-&gt;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;
}
}