From 3fd637c58a84b7bb95403a90d598d77b17dd459b Mon Sep 17 00:00:00 2001 From: Cyd Date: Wed, 8 Jul 2026 09:34:53 -0500 Subject: [PATCH] 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 --- .gitignore | 4 + Console/RedPlanet/Apps.xml | 29 ++ Console/TeslaConsole.csproj | 4 + .../TeslaConsole.DiffTests/CatalogTests.cs | 30 +- Console/vPOD/MungaPodServer.cs | 254 ++++++++++++ Console/vPOD/PodArguments.cs | 97 +++++ Console/vPOD/PodSimulator.cs | 307 ++++++++++++++ Console/vPOD/Program.cs | 16 + Console/vPOD/README.md | 59 +++ Console/vPOD/VPodForm.cs | 374 ++++++++++++++++++ Console/vPOD/dist/vPOD.zip | Bin 0 -> 18493 bytes Console/vPOD/pack.ps1 | 39 ++ Console/vPOD/vPOD.csproj | 44 +++ TeslaSuite.sln | 7 + 14 files changed, 1262 insertions(+), 2 deletions(-) create mode 100644 Console/vPOD/MungaPodServer.cs create mode 100644 Console/vPOD/PodArguments.cs create mode 100644 Console/vPOD/PodSimulator.cs create mode 100644 Console/vPOD/Program.cs create mode 100644 Console/vPOD/README.md create mode 100644 Console/vPOD/VPodForm.cs create mode 100644 Console/vPOD/dist/vPOD.zip create mode 100644 Console/vPOD/pack.ps1 create mode 100644 Console/vPOD/vPOD.csproj diff --git a/.gitignore b/.gitignore index 33d4ace..b286f15 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ __pycache__/ /410console/410consoleArchive.sit /410console/4_10-console-extracted/ /410console/.finf/ + +# vPOD: the packed dist zip is tracked (deployable via Install Product), but the +# intermediate stage folder pack.ps1 lays out before zipping is not. +/Console/vPOD/dist/vPOD/ diff --git a/Console/RedPlanet/Apps.xml b/Console/RedPlanet/Apps.xml index ec28749..206ed18 100644 --- a/Console/RedPlanet/Apps.xml +++ b/Console/RedPlanet/Apps.xml @@ -111,4 +111,33 @@ autoRestart="true" hostType="None" /> + + + + + + + diff --git a/Console/TeslaConsole.csproj b/Console/TeslaConsole.csproj index 5e7cdfe..6d52eff 100644 --- a/Console/TeslaConsole.csproj +++ b/Console/TeslaConsole.csproj @@ -45,6 +45,10 @@ + + + + diff --git a/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs b/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs index 814005e..278c30a 100644 --- a/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs +++ b/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs @@ -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")); } } diff --git a/Console/vPOD/MungaPodServer.cs b/Console/vPOD/MungaPodServer.cs new file mode 100644 index 0000000..10dfb05 --- /dev/null +++ b/Console/vPOD/MungaPodServer.cs @@ -0,0 +1,254 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using Munga.Net; + +namespace VPod; + +/// +/// The server side of the Munga control protocol. A real game client is the +/// Munga server (the console Connect()s to pod:1501); the vendored +/// 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. +/// +internal sealed class MungaPodServer +{ + /// A message received from the console, plus the header it arrived under. + 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 MessageReceived; + public event Action ConnectionChanged; // remote endpoint string, or null when dropped + public event Action 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 }; + } + + /// Maps a (ClientID, MessageID) to the vendored typed message. Mirrors MungaSocket.Receive. + 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 + } + + /// Sends a pod->console message on the current connection (no-op if disconnected). + 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; + } +} diff --git a/Console/vPOD/PodArguments.cs b/Console/vPOD/PodArguments.cs new file mode 100644 index 0000000..163269c --- /dev/null +++ b/Console/vPOD/PodArguments.cs @@ -0,0 +1,97 @@ +using System; +using Munga.Net; + +namespace VPod; + +/// +/// 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: +/// -net <port> Munga control port (default 1501) +/// -lc live-camera role (cosmetic here; state model is identical) +/// -mr mission-review role +/// -res W H resolution (ignored) +/// -app rp|bt which ApplicationID to report (RP by default; also +/// switchable live in the UI) +/// -host <id> the responding host id reported in state responses +/// +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; + } + } +} + +/// +/// Mirror of the console's HostType (GameMachine=0, MissionReview=2, Console=3). +/// Duplicated here so vPOD does not depend on the console assembly. +/// +internal enum HostType +{ + GameMachineHostType = 0, + MissionReviewHostType = 2, + ConsoleHostType = 3 +} diff --git a/Console/vPOD/PodSimulator.cs b/Console/vPOD/PodSimulator.cs new file mode 100644 index 0000000..48eb39e --- /dev/null +++ b/Console/vPOD/PodSimulator.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using Munga.Net; + +namespace VPod; + +/// +/// 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. +/// +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 mEggChunks = new HashSet(); + private string mEggText; + + // Cancels a pending delayed transition when a newer one supersedes it. + private int mTransitionToken; + private Timer mTransitionTimer; + + public event Action StateChanged; + public event Action EggReceived; // full egg text (NUL-normalized) + public event Action EggProgress; // a chunk arrived (mid-transfer) + public event Action 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); + } + } + } + + /// Bring the pod online: Initializing -> WaitingForEgg. + public void PowerOn() + { + SetState(ApplicationState.InitializingState); + ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true); + } + + /// Operator reset: drop any mission and return to WaitingForEgg immediately. + 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; + } +} diff --git a/Console/vPOD/Program.cs b/Console/vPOD/Program.cs new file mode 100644 index 0000000..0433b71 --- /dev/null +++ b/Console/vPOD/Program.cs @@ -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)); + } +} diff --git a/Console/vPOD/README.md b/Console/vPOD/README.md new file mode 100644 index 0000000..24e8e66 --- /dev/null +++ b/Console/vPOD/README.md @@ -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 ] [-app rp|bt] [-lc|-mr] [-host ] [-res W H] +``` + +- `-net ` 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 ` 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. diff --git a/Console/vPOD/VPodForm.cs b/Console/vPOD/VPodForm.cs new file mode 100644 index 0000000..662ec2a --- /dev/null +++ b/Console/vPOD/VPodForm.cs @@ -0,0 +1,374 @@ +using System; +using System.Drawing; +using System.Text; +using System.Windows.Forms; +using Munga.Net; + +namespace VPod; + +/// +/// 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. +/// +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(); + } +} diff --git a/Console/vPOD/dist/vPOD.zip b/Console/vPOD/dist/vPOD.zip new file mode 100644 index 0000000000000000000000000000000000000000..bd6df8d78d28f6f3d20d06aedaa2e43db01008e3 GIT binary patch literal 18493 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

4dSywdBcu9a{+Fx?Rb(_B{5{n#!D34azv4#=V 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 D*zod@ literal 0 HcmV?d00001 diff --git a/Console/vPOD/pack.ps1 b/Console/vPOD/pack.ps1 new file mode 100644 index 0000000..a7602d2 --- /dev/null +++ b/Console/vPOD/pack.ps1 @@ -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 diff --git a/Console/vPOD/vPOD.csproj b/Console/vPOD/vPOD.csproj new file mode 100644 index 0000000..1f36b8e --- /dev/null +++ b/Console/vPOD/vPOD.csproj @@ -0,0 +1,44 @@ + + + + + WinExe + true + net48 + latest + disable + disable + vPOD + VPod + true + 1.0.0.0 + 1.0.0 + vPOD + + + + + + + + + + + ..\lib\Munga Net.dll + true + + + + diff --git a/TeslaSuite.sln b/TeslaSuite.sln index 7de397a..0ac4d94 100644 --- a/TeslaSuite.sln +++ b/TeslaSuite.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tesla.Contract", "Contract\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tesla.SecureConfig", "SecureConfig\Tesla.SecureConfig.csproj", "{070A6093-6C46-4A5B-A119-47ED195530E1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "vPOD", "Console\vPOD\vPOD.csproj", "{9EAC97A1-D71A-4AAB-9957-A79A1587D406}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -52,9 +54,14 @@ Global {070A6093-6C46-4A5B-A119-47ED195530E1}.Debug|Any CPU.Build.0 = Debug|Any CPU {070A6093-6C46-4A5B-A119-47ED195530E1}.Release|Any CPU.ActiveCfg = Release|Any CPU {070A6093-6C46-4A5B-A119-47ED195530E1}.Release|Any CPU.Build.0 = Release|Any CPU + {9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {27C769F3-F07F-456E-ACB7-F4A4A21AB6D1} = {91E92ADD-9F67-41E4-B43C-CCB7B2D95F15} {467AA87A-FBD4-45D7-B8F8-9336C95884D2} = {27C769F3-F07F-456E-ACB7-F4A4A21AB6D1} + {9EAC97A1-D71A-4AAB-9957-A79A1587D406} = {91E92ADD-9F67-41E4-B43C-CCB7B2D95F15} EndGlobalSection EndGlobal