Promote vPOD to a top-level project (Console/vPOD -> vPOD/)

vPOD has outgrown its home inside the console's folder: it now emulates both
halves of a pod (Munga game client + TeslaLauncher service / provisioning),
so it lives at the repo root beside Console/, Launcher/, Contract/ and
SecureConfig/, like the peer it has become.

Accompanying changes: project references rebased (Contract, SecureConfig,
the console's vendored Munga Net.dll), solution + DiffTests reference paths,
the console csproj's now-obsolete vPOD source exclusion removed, and the
root README / Apps.xml / vPOD README path mentions updated. pack.ps1 is
self-relative and now emits vPOD/dist/vPOD.zip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-10 09:52:59 -05:00
co-authored by Claude Fable 5
parent 60893172c3
commit cb7c655530
17 changed files with 21 additions and 22 deletions
+4 -4
View File
@@ -113,10 +113,10 @@
</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. -->
(vPOD\ at the repo root). 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
(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)..."
-4
View File
@@ -45,10 +45,6 @@
<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>
@@ -57,7 +57,7 @@
<ProjectReference Include="..\..\..\SecureConfig\Tesla.SecureConfig.csproj" />
<!-- vPOD's virtual launcher (server side of the pod RPC), exercised end-to-end
against the real PodManagerConnection client in VPodLauncherServerTests. -->
<ProjectReference Include="..\..\vPOD\vPOD.csproj" />
<ProjectReference Include="..\..\..\vPOD\vPOD.csproj" />
</ItemGroup>
</Project>
-7
View File
@@ -1,7 +0,0 @@
# Build output
[Bb]in/
[Oo]bj/
# Packaged install output (pack.ps1) — rebuilt on demand, like the console and
# launcher packages; the operator selects the zip at Install Product time.
/dist/
-402
View File
@@ -1,402 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using System.Threading;
using Tesla;
using Tesla.Net;
namespace VPod;
/// <summary>
/// vPOD's stand-in for the pod's TeslaLauncher service: the OFB-encrypted,
/// framed-JSON ILauncherService RPC server on TCP 53290 that the console's
/// Site Management / SitePanel talk to (client: PodManagerConnection).
/// Mirrors Launcher/TeslaLauncherService.HandleConsoleClient:
///
/// - OFB/CONF handshake on the provisioned 32-byte session key
/// (NegotiateCryptoStreams is symmetric, so the shared implementation
/// serves the pod side too).
/// - Loop: PodRpc.ReadRequest -> dispatch by method name -> WriteResponse.
/// - After answering InitiateInstallProduct, the same connection carries the
/// product zip out-of-band ([8-byte Int64 size][raw bytes]) and then closes;
/// the console polls GetOutOfBandProgress on its main connection meanwhile,
/// so multiple concurrent client connections are required.
/// - Install completion reports 99% (not 100) — the console's
/// InstallProductWorker breaks its retry loop only on 99.
///
/// All state lives in <see cref="VirtualLauncher" />; unlike the real service,
/// postinstall.bat from a package is logged but never executed.
/// </summary>
internal sealed class LauncherRpcServer
{
public const int ManagePort = 53290;
private readonly VirtualLauncher mLauncher;
private readonly int mPort;
private byte[] mSessionKey;
private TcpListener mListener;
private Thread mAcceptThread;
private volatile bool mRunning;
private readonly object mClientsLock = new object();
private readonly List<TcpClient> mClients = new List<TcpClient>();
public event Action<string> Log;
public event Action<int> ConnectionsChanged; // number of active console sessions
public bool IsListening => mRunning;
public LauncherRpcServer(VirtualLauncher launcher, int port = ManagePort)
{
mLauncher = launcher;
mPort = port;
}
/// <summary>Starts listening with the given provisioned session key. Throws if
/// the port cannot be bound (e.g. a real TeslaLauncher on the same machine).</summary>
public void Start(byte[] sessionKey)
{
if (mRunning)
{
return;
}
mSessionKey = sessionKey;
mListener = new TcpListener(IPAddress.Any, mPort);
mListener.Start();
mRunning = true;
mAcceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "vPOD-launcher-accept" };
mAcceptThread.Start();
Log?.Invoke($"Launcher RPC listening on TCP {mPort}.");
}
public void Stop()
{
if (!mRunning)
{
return;
}
mRunning = false;
try { mListener?.Stop(); } catch { }
lock (mClientsLock)
{
foreach (TcpClient client in mClients)
{
try { client.Close(); } catch { }
}
mClients.Clear();
}
ConnectionsChanged?.Invoke(0);
Log?.Invoke("Launcher RPC stopped.");
}
private void AcceptLoop()
{
while (mRunning)
{
TcpClient client;
try
{
client = mListener.AcceptTcpClient();
}
catch
{
break; // listener stopped
}
// Unlike the Munga side, the console legitimately opens several
// concurrent connections (main session + out-of-band installs).
Thread worker = new Thread(() => HandleClient(client)) { IsBackground = true, Name = "vPOD-launcher-session" };
worker.Start();
}
}
private void HandleClient(TcpClient client)
{
string remote;
try
{
remote = client.Client.RemoteEndPoint?.ToString() ?? "?";
}
catch
{
remote = "?";
}
lock (mClientsLock)
{
mClients.Add(client);
ConnectionsChanged?.Invoke(mClients.Count);
}
try
{
using (client)
{
NetworkStream netStream = client.GetStream();
// Same session timeouts as the real service: an idle console
// connection is dropped after 30 s and the console reconnects.
netStream.WriteTimeout = 10000;
netStream.ReadTimeout = 30000;
if (!PodConfigurationServer.NegotiateCryptoStreams(netStream, mSessionKey, out Stream outStream, out Stream inStream))
{
Log?.Invoke($"{remote}: CONF mismatch — session key mismatch, dropping connection.");
return;
}
Log?.Invoke($"Console session started from {remote}.");
SessionLoop(remote, inStream, outStream);
}
}
catch (Exception ex)
{
Log?.Invoke($"{remote}: session error: {ex.Message}");
}
finally
{
lock (mClientsLock)
{
mClients.Remove(client);
ConnectionsChanged?.Invoke(mClients.Count);
}
Log?.Invoke($"Console session from {remote} ended.");
}
}
private void SessionLoop(string remote, Stream inStream, Stream outStream)
{
while (mRunning)
{
RpcRequest request;
try
{
request = PodRpc.ReadRequest(inStream);
}
catch
{
return; // disconnected (EOF/IO/timeout)
}
if (request == null)
{
return;
}
string method = request.Method ?? "???";
IReadOnlyList<JsonElement> args = request.Args ?? new List<JsonElement>();
// GetOutOfBandProgress is polled 4x/second during installs — don't log it.
if (method != "GetOutOfBandProgress" && method != "Ping")
{
Log?.Invoke($"<- {method}");
}
object result = null;
string error = null;
try
{
result = Dispatch(method, args);
}
catch (Exception ex)
{
error = ex.Message;
Log?.Invoke($"{method} ERROR: {ex.Message}");
}
try
{
PodRpc.WriteResponse(outStream, result, error);
}
catch
{
return; // disconnected while writing
}
// The product zip follows the InitiateInstallProduct response on this
// same connection, then the console closes it.
if (method == "InitiateInstallProduct" && error == null && result is Guid installGuid)
{
ReceiveInstallFile(inStream, installGuid);
return;
}
}
}
/// <summary>Maps the console's method names (dispatch-by-name, including the
/// get_/set_ property accessors) onto the VirtualLauncher. Mirrors the real
/// service's DispatchCommandAsync.</summary>
private object Dispatch(string method, IReadOnlyList<JsonElement> args)
{
switch (method)
{
case "Ping":
return args.Count > 0 && args[0].ValueKind != JsonValueKind.Null
? mLauncher.Ping(args[0].GetDateTime())
: DateTime.Now;
case "GetInstalledApps":
return mLauncher.GetInstalledApps();
case "GetLaunchableApps":
return mLauncher.GetLaunchableApps();
case "GetLaunchedApps":
return mLauncher.GetLaunchedApps();
case "FullUpdate":
return mLauncher.FullUpdate();
case "GetOutOfBandProgress":
return mLauncher.GetOutOfBandProgress(args[0].GetGuid());
case "InitiateInstallProduct":
return mLauncher.InitiateInstallProduct();
case "InstallApp":
mLauncher.InstallApp(args[0].Deserialize<LaunchData>(PodRpc.JsonOptions));
return null;
case "UninstallApp":
mLauncher.UninstallApp(args[0].GetGuid());
return null;
case "RemoveApp":
mLauncher.RemoveApp(args[0].GetGuid());
return null;
case "LaunchApp":
return mLauncher.LaunchApp(args[0].GetGuid());
case "KillApp":
mLauncher.KillApp(args[0].GetGuid(), args[1].GetInt32());
return null;
case "KillAllOfType":
mLauncher.KillAllOfType(args[0].GetGuid());
return null;
case "KillAllApps":
mLauncher.KillAllApps();
return null;
case "Shutdown":
mLauncher.Shutdown(args[0].GetBoolean());
return null;
case "ClearStore":
mLauncher.ClearStore();
return null;
case "get_VolumeLevel":
return mLauncher.VolumeLevel;
case "set_VolumeLevel":
mLauncher.VolumeLevel = args[0].GetSingle();
return null;
default:
Log?.Invoke($"Unknown command \"{method}\" — answering null.");
return null;
}
}
/// <summary>Receives the out-of-band product zip and extracts it into the
/// virtual games root, reporting progress exactly like the real service:
/// 0-50% receive, 50-95% extract, 99% "Complete" (IsCompleted).</summary>
private void ReceiveInstallFile(Stream stream, Guid callId)
{
string tempZip = null;
try
{
byte[] sizeBuffer = ReadExact(stream, 8);
long fileSize = BitConverter.ToInt64(sizeBuffer, 0);
Log?.Invoke($"Install {callId:N}: receiving {fileSize:N0} bytes...");
mLauncher.UpdateProgress(callId, 0, "Receiving file...");
tempZip = Path.Combine(mLauncher.DataDirectory, $"install_{callId:N}.zip");
using (FileStream fs = File.Create(tempZip))
{
byte[] buffer = new byte[65536];
long received = 0;
while (received < fileSize)
{
int toRead = (int)Math.Min(buffer.Length, fileSize - received);
int read = stream.Read(buffer, 0, toRead);
if (read == 0)
{
throw new IOException("Connection closed during file transfer.");
}
fs.Write(buffer, 0, read);
received += read;
mLauncher.UpdateProgress(callId, (int)(received * 50 / fileSize), "Receiving file...");
}
}
mLauncher.UpdateProgress(callId, 50, "Extracting...");
string gamesRoot = Path.GetFullPath(mLauncher.GamesRoot);
Directory.CreateDirectory(gamesRoot);
using (ZipArchive zip = ZipFile.OpenRead(tempZip))
{
int total = zip.Entries.Count;
int done = 0;
foreach (ZipArchiveEntry entry in zip.Entries)
{
string destPath = Path.GetFullPath(Path.Combine(gamesRoot, entry.FullName));
// Zip-slip protection, as in the real service.
if (!destPath.StartsWith(gamesRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destPath);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(destPath));
entry.ExtractToFile(destPath, overwrite: true);
}
done++;
mLauncher.UpdateProgress(callId, 50 + done * 45 / Math.Max(total, 1), "Extracting...");
}
}
Log?.Invoke($"Install {callId:N}: extracted to {gamesRoot}");
// The real service runs (then deletes) a packaged postinstall.bat here.
// vPOD never executes package scripts on the host machine.
string postInstall = Path.Combine(gamesRoot, "postinstall.bat");
if (File.Exists(postInstall))
{
mLauncher.UpdateProgress(callId, 96, "Skipping postinstall (vPOD)...");
Log?.Invoke($"Install {callId:N}: postinstall.bat present — NOT executed (vPOD), removed.");
try { File.Delete(postInstall); } catch { }
}
mLauncher.UpdateProgress(callId, 99, "Complete", isCompleted: true);
Log?.Invoke($"Install {callId:N}: complete.");
}
catch (Exception ex)
{
mLauncher.UpdateProgress(callId, 0, $"Failed: {ex.Message}", isCompleted: true);
Log?.Invoke($"Install {callId:N} FAILED: {ex.Message}");
}
finally
{
try { if (tempZip != null) File.Delete(tempZip); } catch { }
}
}
private static byte[] ReadExact(Stream 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)
{
throw new EndOfStreamException("Connection closed mid-read.");
}
offset += read;
}
return buffer;
}
}
-254
View File
@@ -1,254 +0,0 @@
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-&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;
}
}
-106
View File
@@ -1,106 +0,0 @@
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 &lt;port&gt;</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 &lt;id&gt;</c> the responding host id reported in state responses
///
/// vPOD-only (not a real game-client option):
/// <c>-nomanage</c> disable the virtual launcher / site-management side
/// (no provisioning beacons, no TCP 53290 listener)
/// </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 bool NoManage { get; private set; }
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;
case "-nomanage":
result.NoManage = true;
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
}
-378
View File
@@ -1,378 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Tesla;
namespace VPod;
/// <summary>
/// The pod side of the SecureConfig first-boot provisioning protocol, display-only:
/// unlike a real pod (the Launcher's PodSecureConfigurator) it never touches the
/// NIC, registry or hostname — the network config the console assigns is only
/// surfaced to the UI. The wire behaviour matches the real pod, so the console's
/// Manage Site "Configure" flow works unmodified:
///
/// 1. Broadcast a "RQST" beacon (MAC + 3-char RequestId) to UDP 53291 every 10 s
/// — the console shows a "Configure &lt;RequestId&gt;" button.
/// 2. Operator enters the pod's network settings and the 5-char passphrase shown
/// in vPOD's window; the console broadcasts an AES-encrypted "RPLY" (network
/// config) to UDP 53292, key = PBKDF2(passphrase).
/// 3. The console TCP-connects to the pod's (entered) address on 53292; after the
/// OFB/CONF handshake on the passphrase key, the pod sends an RSA public key
/// and receives the RSA-encrypted 32-byte session key — the key that unlocks
/// the launcher RPC channel (TCP 53290) from then on.
///
/// Reuses the shared TeslaSecureConfiguration pieces where they are public
/// (UdpBeacon, BasicConfigResponse, NegotiateCryptoStreams); the passphrase KDF
/// salt and the "RQST"/"RPLY" tags are internal there and duplicated below.
/// </summary>
internal sealed class PodProvisioning
{
public const int ConsoleRequestPort = 53291; // console listens for RQST beacons
public const int PodReplyPort = 53292; // pod listens for RPLY + the TCP key exchange
// Mirrors of internals in SecureConfig/SecureConfig.cs (PodConfigurationServer):
// the PBKDF2 salt for the passphrase-derived AES key, and the pod-side
// passphrase/request-id alphabet + lengths (SetupPod validates passphrase == 5).
private static readonly byte[] sPassphraseSalt = new byte[32]
{
23, 171, 81, 217, 236, 209, 212, 116, 169, 9,
74, 52, 39, 251, 31, 242, 222, 196, 249, 241,
166, 216, 158, 218, 21, 17, 71, 101, 50, 231,
231, 239
};
private const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
private const int RequestIdLength = 3;
private const int PassphraseLength = 5;
private readonly byte[] mMacAddress;
private readonly object mLock = new object();
private UdpBeacon mBeacon;
private UdpClient mReplyListener;
private TcpListener mKeyExchangeListener;
private Thread mWorker;
private volatile bool mRunning;
// Bumped by every Start/Stop so a worker from a previous session can neither
// tear down nor complete a newer one (e.g. quick power-off/power-on cycles).
private volatile int mGeneration;
public event Action<string> Log;
public event Action<BasicConfigResponse> ConfigReceived; // display-only network config
public event Action<byte[]> Provisioned; // the 32-byte session key
public string RequestId { get; private set; }
public string Passphrase { get; private set; }
public bool IsRunning => mRunning;
/// <summary>The pod's stable fake MAC: locally-administered "VPOD" + host id, so
/// the console recognizes the same virtual pod across reprovisions (Site.FindPod).</summary>
public static byte[] MacForHost(int hostId)
{
return new byte[6] { 0x02, 0x56, 0x50, 0x4F, 0x44, (byte)hostId };
}
public PodProvisioning(byte[] macAddress)
{
mMacAddress = macAddress;
}
public void Start()
{
int generation;
lock (mLock)
{
if (mRunning)
{
return;
}
mRunning = true;
generation = ++mGeneration;
RequestId = GenerateRandomString(RequestIdLength);
Passphrase = GenerateRandomString(PassphraseLength);
byte[] payload = new byte[mMacAddress.Length + RequestIdLength];
mMacAddress.CopyTo(payload, 0);
Encoding.ASCII.GetBytes(RequestId).CopyTo(payload, mMacAddress.Length);
mBeacon = new UdpBeacon(Encoding.UTF8.GetBytes("RQST"), payload, 10000.0, ConsoleRequestPort, null);
mBeacon.Start();
mWorker = new Thread(() => ProvisionWorker(generation)) { IsBackground = true, Name = "vPOD-provision" };
mWorker.Start();
}
Log?.Invoke($"Provisioning: beaconing RQST (Request ID {RequestId}, passphrase {Passphrase}).");
}
public void Stop()
{
lock (mLock)
{
mGeneration++; // orphan any live worker
if (!mRunning)
{
return;
}
mRunning = false;
try { mBeacon?.Stop(); } catch { }
mBeacon = null;
try { mReplyListener?.Close(); } catch { }
mReplyListener = null;
try { mKeyExchangeListener?.Stop(); } catch { }
mKeyExchangeListener = null;
}
}
private bool IsCurrent(int generation)
{
return mRunning && generation == mGeneration;
}
private void ProvisionWorker(int generation)
{
try
{
// ---- Phase 1: wait for the console's RPLY (proves the operator typed
// our passphrase) and surface the assigned network config. ----
byte[] weakKey = DeriveKeyFromPassphrase(Passphrase);
BasicConfigResponse config = ReceiveReply(weakKey, generation);
if (config == null || !IsCurrent(generation))
{
return; // stopped
}
Log?.Invoke($"Provisioning: RPLY received — assigned IP {config.Address} / {config.Mask}" +
(string.IsNullOrEmpty(config.HostName) ? "" : $", host \"{config.HostName}\"") +
" (display only, not applied).");
ConfigReceived?.Invoke(config);
lock (mLock)
{
try { mBeacon?.Stop(); } catch { }
mBeacon = null;
}
// ---- Phase 2: accept the console's TCP key exchange on 53292. ----
TcpListener listener;
lock (mLock)
{
if (!IsCurrent(generation))
{
return;
}
try
{
listener = new TcpListener(IPAddress.Any, PodReplyPort);
listener.Start();
}
catch (Exception ex)
{
Log?.Invoke($"Provisioning: cannot listen on TCP {PodReplyPort}: {ex.Message}");
return;
}
mKeyExchangeListener = listener;
}
Log?.Invoke("Provisioning: waiting for the console's key exchange on TCP " + PodReplyPort + "...");
while (IsCurrent(generation))
{
TcpClient client = null;
try
{
client = listener.AcceptTcpClient();
byte[] sessionKey = ExchangeSessionKey(client, weakKey);
if (sessionKey == null)
{
Log?.Invoke("Provisioning: key-exchange handshake failed (wrong passphrase key?); still waiting.");
continue;
}
if (!IsCurrent(generation))
{
return;
}
Log?.Invoke("Provisioning: session key received — pod is provisioned.");
Provisioned?.Invoke(sessionKey);
return;
}
catch (Exception ex)
{
if (IsCurrent(generation))
{
Log?.Invoke("Provisioning: key exchange error: " + ex.Message);
}
else
{
return; // listener stopped
}
}
finally
{
try { client?.Close(); } catch { }
}
}
}
finally
{
StopGeneration(generation);
}
}
/// <summary>Tears the session down only if it is still the one this worker
/// belongs to — a newer Start() must not be disturbed by an old worker exiting.</summary>
private void StopGeneration(int generation)
{
lock (mLock)
{
if (generation != mGeneration)
{
return;
}
mRunning = false;
try { mBeacon?.Stop(); } catch { }
mBeacon = null;
try { mReplyListener?.Close(); } catch { }
mReplyListener = null;
try { mKeyExchangeListener?.Stop(); } catch { }
mKeyExchangeListener = null;
}
}
/// <summary>Listens on UDP 53292 for an "RPLY" datagram that decrypts and
/// verifies under our passphrase key. Cancellable mirror of the shared
/// UdpBeaconListener (which cannot be stopped once blocked in Receive):
/// packet = "RPLY"(4) + IV(16) + AES-CBC(config + SHA1(config)).</summary>
private BasicConfigResponse ReceiveReply(byte[] weakKey, int generation)
{
UdpClient udp;
lock (mLock)
{
if (!IsCurrent(generation))
{
return null;
}
try
{
udp = new UdpClient(PodReplyPort) { EnableBroadcast = true };
}
catch (Exception ex)
{
Log?.Invoke($"Provisioning: cannot listen on UDP {PodReplyPort}: {ex.Message}");
return null;
}
mReplyListener = udp;
}
byte[] header = Encoding.UTF8.GetBytes("RPLY");
using (Rijndael aes = Rijndael.Create())
using (SHA1 sha1 = SHA1.Create())
{
aes.Key = weakKey;
while (IsCurrent(generation))
{
byte[] packet;
try
{
IPEndPoint remote = new IPEndPoint(IPAddress.Any, 0);
packet = udp.Receive(ref remote);
}
catch
{
return null; // socket closed by Stop()
}
try
{
int ivLength = aes.IV.Length;
if (packet.Length < header.Length + ivLength)
{
continue;
}
bool tagOk = true;
for (int i = 0; i < header.Length; i++)
{
if (packet[i] != header[i]) { tagOk = false; break; }
}
if (!tagOk)
{
continue;
}
byte[] iv = new byte[ivLength];
Buffer.BlockCopy(packet, header.Length, iv, 0, ivLength);
byte[] plain;
using (ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, iv))
using (MemoryStream ms = new MemoryStream())
{
using (CryptoStream cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Write))
{
cs.Write(packet, header.Length + ivLength, packet.Length - header.Length - ivLength);
cs.FlushFinalBlock();
}
plain = ms.ToArray();
}
int messageLength = plain.Length - sha1.HashSize / 8;
if (messageLength <= 0)
{
continue;
}
byte[] hash = sha1.ComputeHash(plain, 0, messageLength);
bool hashOk = true;
for (int i = 0; i < hash.Length; i++)
{
if (plain[messageLength + i] != hash[i]) { hashOk = false; break; }
}
if (!hashOk)
{
continue; // wrong passphrase (or noise) — keep listening
}
byte[] message = new byte[messageLength];
Buffer.BlockCopy(plain, 0, message, 0, messageLength);
return new BasicConfigResponse(message);
}
catch
{
// undecryptable/malformed datagram — keep listening
}
}
}
return null;
}
/// <summary>The RSA leg, mirroring the real pod (PodConfigurationClient): after
/// the OFB/CONF handshake on the passphrase key, send our RSA public key and
/// decrypt the console's session key with it. Returns null if CONF fails.</summary>
private byte[] ExchangeSessionKey(TcpClient client, byte[] weakKey)
{
if (!PodConfigurationServer.NegotiateCryptoStreams(client.GetStream(), weakKey, out Stream outStream, out Stream inStream))
{
return null;
}
BinaryWriter writer = new BinaryWriter(outStream);
BinaryReader reader = new BinaryReader(inStream);
using (RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(2048))
{
writer.Write(rsa.ToXmlString(includePrivateParameters: false));
writer.Flush();
byte[] encryptedKey = reader.ReadBytes(reader.ReadInt32());
return rsa.Decrypt(encryptedKey, fOAEP: false);
}
}
internal static byte[] DeriveKeyFromPassphrase(string passphrase)
{
using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(passphrase, sPassphraseSalt, 1000))
{
return pbkdf2.GetBytes(32);
}
}
private static string GenerateRandomString(int length)
{
// RNGCryptoServiceProvider-quality randomness is unnecessary here (the real
// pod uses System.Random too), but avoid same-seed collisions across quick
// restarts by seeding from Guid entropy.
Random random = new Random(Guid.NewGuid().GetHashCode());
char[] chars = new char[length];
for (int i = 0; i < length; i++)
{
chars[i] = Alphabet[random.Next(Alphabet.Length)];
}
return new string(chars);
}
}
-333
View File
@@ -1,333 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using Munga.Net;
namespace VPod;
/// <summary>
/// Emulates a game client's ApplicationState machine over the Munga control
/// channel, driving it from the messages the console sends. This is the brain of
/// vPOD: it answers state queries, reassembles the streamed egg, and walks the
/// pod through the load/run/stop lifecycle the console's game window expects.
///
/// The console (RPGame/BTGame NetworkScan) gates purely on the reported
/// ApplicationState:
/// WaitingForEgg -- ready; console streams the egg here
/// (egg complete) -> LoadingMission -> WaitingForLaunch (console's "Loaded")
/// RunMission -> LaunchingMission -> RunningMission (console's "Running")
/// StopMission -> EndingMission -> WaitingForEgg (mission over/reset)
/// AbortMission -> AbortingMission -> WaitingForEgg
/// Intermediate states are shown for realism; the console only waits on the gate
/// states, so a short delay between them is harmless.
/// </summary>
internal sealed class PodSimulator
{
private const int NetworkManagerFromHost = 0;
private readonly MungaPodServer mServer;
private readonly object mLock = new object();
private ApplicationState mState = ApplicationState.InitializingState;
private ApplicationID mApplicationId;
private int mHostId;
private int mTransitionDelayMs = 400;
private bool mRestartOnEndMission = true;
// Egg reassembly
private byte[] mEggBuffer;
private int mEggTotal;
private int mEggReceived;
private readonly HashSet<int> mEggChunks = new HashSet<int>();
private string mEggText;
// Cancels a pending delayed transition when a newer one supersedes it.
private int mTransitionToken;
private Timer mTransitionTimer;
public event Action<ApplicationState> StateChanged;
public event Action<string> EggReceived; // full egg text (NUL-normalized)
public event Action EggProgress; // a chunk arrived (mid-transfer)
public event Action<string> Log;
public event Action EndMissionExit; // game exited on end mission; watchdog should restart it
public PodSimulator(MungaPodServer server, ApplicationID applicationId, int hostId)
{
mServer = server;
mApplicationId = applicationId;
mHostId = hostId;
}
public ApplicationState State { get { lock (mLock) { return mState; } } }
public ApplicationID ApplicationId
{
get { lock (mLock) { return mApplicationId; } }
set
{
lock (mLock) { mApplicationId = value; }
Log?.Invoke("Reported application changed to " + value + ".");
}
}
public int HostId { get { lock (mLock) { return mHostId; } } }
public int TransitionDelayMs
{
get { lock (mLock) { return mTransitionDelayMs; } }
set { lock (mLock) { mTransitionDelayMs = Math.Max(0, value); } }
}
/// <summary>
/// When true (the faithful default), the end-mission command makes the game
/// exit and its watchdog restart it (the form drops and reopens the listener).
/// When false, the pod simply returns to WaitingForEgg without exiting.
/// </summary>
public bool RestartOnEndMission
{
get { lock (mLock) { return mRestartOnEndMission; } }
set { lock (mLock) { mRestartOnEndMission = value; } }
}
public string EggText { get { lock (mLock) { return mEggText; } } }
public int EggPercent
{
get
{
lock (mLock)
{
if (mEggTotal <= 0) return 0;
return (int)(100L * mEggReceived / mEggTotal);
}
}
}
/// <summary>Bring the pod online: Initializing -> WaitingForEgg.</summary>
public void PowerOn()
{
SetState(ApplicationState.InitializingState);
ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true);
}
/// <summary>Operator reset: drop any mission and return to WaitingForEgg immediately.</summary>
public void Reset()
{
lock (mLock)
{
ClearEggLocked();
CancelPendingLocked();
}
SetState(ApplicationState.WaitingForEgg);
Log?.Invoke("Reset to WaitingForEgg.");
}
public void HandleMessage(MungaPodServer.Incoming incoming)
{
MungaMessage msg = incoming?.Message;
if (msg == null)
{
return;
}
switch (msg)
{
case StateQueryMessage _:
RespondState();
break;
case EggFileMessage egg:
HandleEggChunk(egg);
break;
case RunMissionMessage _:
Log?.Invoke("<- RunMissionMessage");
if (State == ApplicationState.WaitingForLaunch)
{
SetState(ApplicationState.LaunchingMission);
mServer.Send(new ReadyToRunMessage(HostId), 0, HostId);
ScheduleTransition(ApplicationState.RunningMission);
}
break;
case StopMissionMessage _:
Log?.Invoke("<- StopMissionMessage (end mission)");
if (State == ApplicationState.RunningMission || State == ApplicationState.SuspendingMission)
{
SetState(ApplicationState.EndingMission);
bool restart;
lock (mLock) { restart = mRestartOnEndMission; }
if (restart)
{
// Faithful pod behaviour: the game exe exits gracefully on the
// end-mission command, then its watchdog restarts it. The form
// simulates the exit + restart by dropping and reopening the
// listener; the pod comes back up in WaitingForEgg.
EndMissionExit?.Invoke();
}
else
{
ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true);
}
}
break;
case AbortMissionMessage _:
Log?.Invoke("<- AbortMissionMessage");
SetState(ApplicationState.AbortingMission);
mServer.Send(new AbortMissionResponseMessage(HostId), 0, HostId);
ScheduleTransition(ApplicationState.WaitingForEgg, ResetEggOnArrive: true);
break;
case SuspendMissionMessage _:
Log?.Invoke("<- SuspendMissionMessage");
if (State == ApplicationState.RunningMission)
{
SetState(ApplicationState.SuspendingMission);
}
break;
case ResumeMissionMessage _:
Log?.Invoke("<- ResumeMissionMessage");
if (State == ApplicationState.SuspendingMission)
{
SetState(ApplicationState.ResumingMission);
ScheduleTransition(ApplicationState.RunningMission);
}
break;
case LoadMissionMessage _:
Log?.Invoke("<- LoadMissionMessage");
if (State == ApplicationState.WaitingForEgg)
{
SetState(ApplicationState.LoadingMission);
ScheduleTransition(ApplicationState.WaitingForLaunch);
}
break;
case CheckLoadMessage _:
Log?.Invoke("<- CheckLoadMessage");
break;
case LightsOutMissionMessage _:
Log?.Invoke("<- LightsOutMissionMessage");
break;
}
}
private void RespondState()
{
ApplicationState state;
ApplicationID app;
int host;
lock (mLock)
{
state = mState;
app = mApplicationId;
host = mHostId;
}
mServer.Send(new StateResponseMessage(host, state, app), 0, host);
}
private void HandleEggChunk(EggFileMessage egg)
{
bool complete = false;
string text = null;
lock (mLock)
{
if (mEggBuffer == null || mEggTotal != egg.NotationFileLength)
{
mEggTotal = egg.NotationFileLength;
mEggBuffer = new byte[mEggTotal];
mEggReceived = 0;
mEggChunks.Clear();
}
int offset = egg.SequenceNumber * 1000;
int len = egg.ThisMessageLength;
if (offset >= 0 && offset + len <= mEggBuffer.Length && mEggChunks.Add(egg.SequenceNumber))
{
Buffer.BlockCopy(egg.NotationData, 0, mEggBuffer, offset, len);
mEggReceived += len;
}
if (mEggReceived >= mEggTotal && mEggTotal > 0)
{
// On the wire the egg is NUL-delimited ASCII; show it line-per-field.
text = Encoding.ASCII.GetString(mEggBuffer).Replace('\0', '\n');
mEggText = text;
complete = true;
}
}
EggProgress?.Invoke();
if (complete)
{
Log?.Invoke($"Egg received ({mEggTotal} bytes, {mEggChunks.Count} chunks). Acknowledging.");
mServer.Send(new AcknowledgeEggFileMessage(), NetworkManagerFromHost, HostId);
EggReceived?.Invoke(text);
// WaitingForEgg -> LoadingMission -> WaitingForLaunch
SetState(ApplicationState.LoadingMission);
ScheduleTransition(ApplicationState.WaitingForLaunch);
}
}
private void SetState(ApplicationState state)
{
bool changed;
lock (mLock)
{
changed = mState != state;
mState = state;
}
if (changed)
{
Log?.Invoke("State -> " + state);
StateChanged?.Invoke(state);
}
}
private void ScheduleTransition(ApplicationState target, bool ResetEggOnArrive = false)
{
int token;
int delay;
lock (mLock)
{
token = ++mTransitionToken;
delay = mTransitionDelayMs;
mTransitionTimer?.Dispose();
mTransitionTimer = new Timer(delegate
{
bool proceed;
lock (mLock)
{
proceed = token == mTransitionToken;
if (proceed && ResetEggOnArrive)
{
ClearEggLocked();
}
}
if (proceed)
{
SetState(target);
}
}, null, delay, Timeout.Infinite);
}
}
private void CancelPendingLocked()
{
mTransitionToken++;
mTransitionTimer?.Dispose();
mTransitionTimer = null;
}
private void ClearEggLocked()
{
mEggBuffer = null;
mEggTotal = 0;
mEggReceived = 0;
mEggChunks.Clear();
mEggText = null;
}
}
-16
View File
@@ -1,16 +0,0 @@
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));
}
}
-155
View File
@@ -1,155 +0,0 @@
# 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.
It also impersonates the pod's **TeslaLauncher service** (the "Launcher / Site
Management" column), so the console's Manage Site — provisioning, Install /
Uninstall Product, launch/kill, volume, restart/shutdown — can be tested
end-to-end with no cockpit and **no console changes**. See
[Site management / virtual launcher](#site-management--virtual-launcher).
## 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.
- **End-mission graceful exit + watchdog restart** — on the console's end-mission
command the "game exe" exits (the listener closes, the console's connection
drops), then a watchdog relaunches it a moment later and it comes back up in
`WaitingForEgg`. This is the real pod's per-game cycle (`autoRestart`); the
*Restart game after mission ends (watchdog)* checkbox (on by default) toggles
it — unchecked, the pod just returns to `WaitingForEgg` without exiting.
- **Pod Power (Power On / Power Off)** — the whole virtual machine: Power Off
darkens the game listener AND the launcher / site-management side; Power On
boots the launcher (or provisioning beacons) and auto-starts the game, like a
real pod booting with an `autoRestart` product installed.
- **Start Game / Stop Game** — just the emulated game "exe", separate from pod
power: Stop Game closes the Munga listener (the console's game connection
drops) while the pod and its launcher service stay up — the state of a pod
whose game client crashed or was killed. **Reset** returns a running game to
`WaitingForEgg`.
- **Reassembles and shows the egg** the console streams (the `EggFileMessage`
chunks), one field per line, with a summary line (adventure / map / scenario /
pilot count). The last egg is **kept** across missions/restarts (so it can be
copied for dev use) until the **Clear** button empties the viewer.
- **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] [-nomanage]
```
- `-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).
- `-nomanage` vPOD-only: disable the virtual launcher / site-management side
(no provisioning beacons, no TCP 53290 listener) — e.g. when a real
TeslaLauncher runs on the same machine, or for a second vPOD instance.
## Site management / virtual launcher
vPOD's right-hand column is a stand-in for the pod's **TeslaLauncher service**:
the OFB-encrypted, framed-JSON `ILauncherService` RPC on **TCP 53290** that the
console's Manage Site and squad/pod panel talk to (`Tesla.Contract` /
`Tesla.SecureConfig` are the same shared wire libraries both real ends use).
### Provisioning (first run)
The console only talks to a pod's launcher after minting a 32-byte session key
for it via the SecureConfig **Configure** flow, so an unprovisioned vPOD
behaves like a freshly-imaged pod — minus the NIC/registry changes (the
assigned network config is shown in the window but never applied):
1. Run vPOD (no `-nomanage`). It broadcasts `RQST` beacons and displays a
**Request ID** and **Passphrase** (the real pod shows these on its screen).
2. In the console: **Manage Site** — a **"Configure &lt;Request ID&gt;"** button
appears at the bottom. Click it, enter the pod's name/squad, its IP
(**127.0.0.1** when vPOD runs on the console machine — accepted), any
subnet (e.g. 255.255.255.0), and the passphrase from vPOD's window.
3. The console sends the encrypted config + session key; vPOD stores the key
and starts the launcher RPC. The pod row goes healthy (`Idle [<n> ms]`).
The key persists in `%LocalAppData%\vPOD\TeslaKeyStore.key` (launcher format),
so provisioning is one-time. **Reprovision** resets to a fresh pod — drops the
key, clears the installed-apps store (`LaunchApps.json`) and returns to beacon
mode — pair it with deleting the pod in Manage Site (the console's
**Reconfigure…** does both ends automatically: its `ClearStore` makes vPOD do
the same wipe and beacon again). Extracted packages under `Games\` are left on
disk either way.
### Product deployments
Right-click the pod row → **Install Product ▸** works exactly as against a real
pod: the console streams the package zip out-of-band on a second 53290
connection while polling progress on the first; vPOD extracts it into its
virtual games root and reports the launcher's usual `050%` receive / `5095%`
extract / `99% Complete` progression. Then:
- Installed apps land in the column's list (and in `GetInstalledApps`, so the
Uninstall menu populates). Registrations persist in
`%LocalAppData%\vPOD\LaunchApps.json`.
- Packages extract under `%LocalAppData%\vPOD\Games\` (the virtual `C:\Games`).
A packaged `postinstall.bat` is logged and removed but **never executed**.
- **Launch/Kill** from the squad panel are simulated (fake PIDs, no real
process); Volume round-trips; **Restart/Shutdown** power-cycle the virtual
pod (dark for a few seconds on restart).
### Ports
| Port | Proto | Direction | Purpose |
|---|---|---|---|
| 1501 | TCP | console → vPOD | Munga game control (existing) |
| 53290 | TCP | console → vPOD | Launcher RPC (`ILauncherService`) |
| 53291 | UDP | vPOD → console (broadcast) | `RQST` provisioning beacon |
| 53292 | UDP+TCP | console → vPOD | `RPLY` config broadcast + RSA key exchange |
Same-machine testing needs no firewall changes (loopback). Running vPOD on a
**different machine** needs inbound allows for TCP 1501/53290/53292 and UDP
53292 on the vPOD machine (the console installer already opens its own side).
Not emulated: the console's remote Windows-service control (`ServiceController`
over SCM/SMB, used by some SitePanel service start/stop paths — dormant against
real pods too, since it queries service name `TeslaLauncherService` while the
launcher registers as `Tesla Application Launcher`), and really launching
deployed exes (a second game client would fight vPOD for its own ports).
An end-to-end loopback test of this server against the console's real
`PodManagerConnection` client lives in the differential suite:
`Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs`.
## 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.
-969
View File
@@ -1,969 +0,0 @@
using System;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
using Munga.Net;
using Tesla;
using Tesla.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. The right column is the virtual launcher — the pod
/// side of the console's Site Management (provisioning, product installs,
/// launch/kill/volume/shutdown), see LauncherRpcServer / PodProvisioning.
/// </summary>
internal sealed class VPodForm : Form
{
private readonly PodArguments mOptions;
private readonly MungaPodServer mServer;
private readonly PodSimulator mSimulator;
private readonly VirtualLauncher mLauncher;
private readonly LauncherRpcServer mRpcServer;
private readonly PodProvisioning mProvisioning;
private Label mListeningLabel;
private Label mConnectionLabel;
private Label mStateLabel;
private Label mEggSummaryLabel;
private RadioButton mRedPlanetRadio;
private RadioButton mBattleTechRadio;
private Button mPowerButton;
private Button mPowerOffButton;
private Button mStartGameButton;
private Button mStopGameButton;
private Button mResetButton;
private CheckBox mRestartCheckbox;
private Timer mRestartTimer;
private Timer mRebootTimer;
private SplitContainer mSplit;
private TextBox mEggBox;
private TextBox mLogBox;
// Virtual launcher / site-management column.
private Label mProvisionStatusLabel;
private Panel mPassphrasePanel;
private Label mRequestIdValueLabel;
private Label mPassphraseValueLabel;
private Label mNetConfigLabel;
private Label mRpcStatusLabel;
private Label mInstallStatusLabel;
private ProgressBar mInstallProgressBar;
private Button mReprovisionButton;
private ListView mAppsView;
private bool mPoweredOn;
// How long the "watchdog" waits before relaunching the exited game.
private const int WatchdogRestartMs = 1500;
// How long a console-requested Shutdown(restart) keeps the pod dark.
private const int RebootRestartMs = 4000;
public VPodForm(PodArguments options)
{
mOptions = options;
mServer = new MungaPodServer(options.Port);
mSimulator = new PodSimulator(mServer, options.Application, options.HostId);
mLauncher = new VirtualLauncher();
mRpcServer = new LauncherRpcServer(mLauncher);
mProvisioning = new PodProvisioning(PodProvisioning.MacForHost(options.HostId));
BuildUi();
mServer.Log += OnLog;
mServer.ConnectionChanged += OnConnectionChanged;
mServer.MessageReceived += OnMessageReceived;
mSimulator.Log += OnLog;
mSimulator.StateChanged += OnStateChanged;
mSimulator.EggReceived += OnEggReceived;
mSimulator.EggProgress += OnEggProgress;
mSimulator.EndMissionExit += OnEndMissionExit;
mLauncher.Log += OnSiteLog;
mLauncher.AppsChanged += OnAppsChanged;
mLauncher.InstallProgressChanged += OnInstallProgress;
mLauncher.ShutdownRequested += OnShutdownRequested;
mLauncher.ReprovisionRequested += OnReprovisionRequested;
mRpcServer.Log += OnSiteLog;
mRpcServer.ConnectionsChanged += OnRpcConnectionsChanged;
mProvisioning.Log += OnSiteLog;
mProvisioning.ConfigReceived += OnConfigReceived;
mProvisioning.Provisioned += OnProvisioned;
mRestartTimer = new Timer { Interval = WatchdogRestartMs };
mRestartTimer.Tick += OnRestartTimerTick;
mRebootTimer = new Timer { Interval = RebootRestartMs };
mRebootTimer.Tick += OnRebootTimerTick;
Load += OnFormLoad;
FormClosing += OnFormClosing;
}
private void BuildUi()
{
Text = $"vPOD - virtual pod (port {mOptions.Port}, host {mOptions.HostId})";
ClientSize = new Size(1280, 560);
MinimumSize = new Size(1100, 500);
Font = new Font("Segoe UI", 9f);
// ---- status panel ----
GroupBox statusGroup = new GroupBox
{
Text = "Pod Status",
Dock = DockStyle.Top,
Height = 176,
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 = "—"
};
// pod power: the whole virtual machine (launcher/site management included),
// distinct from starting/stopping the emulated game client below.
GroupBox powerGroup = new GroupBox
{
Text = "Pod Power",
Location = new Point(400, 20),
Size = new Size(150, 130)
};
mPowerButton = new Button { Text = "Power On", Location = new Point(12, 28), Size = new Size(126, 30) };
mPowerOffButton = new Button { Text = "Power Off", Location = new Point(12, 66), Size = new Size(126, 30) };
mPowerButton.Click += PowerOnClicked;
mPowerOffButton.Click += PowerOffClicked;
powerGroup.Controls.Add(mPowerButton);
powerGroup.Controls.Add(mPowerOffButton);
// game toggle + game-process controls (the "exe", not the machine)
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;
mStartGameButton = new Button { Text = "Start Game", Location = new Point(12, 90), Size = new Size(94, 28) };
mStopGameButton = new Button { Text = "Stop Game", Location = new Point(112, 90), Size = new Size(94, 28) };
mResetButton = new Button { Text = "Reset", Location = new Point(212, 90), Size = new Size(94, 28) };
mStartGameButton.Click += StartGameClicked;
mStopGameButton.Click += StopGameClicked;
mResetButton.Click += (s, e) => mSimulator.Reset();
gameGroup.Controls.Add(mRedPlanetRadio);
gameGroup.Controls.Add(mBattleTechRadio);
gameGroup.Controls.Add(mStartGameButton);
gameGroup.Controls.Add(mStopGameButton);
gameGroup.Controls.Add(mResetButton);
mRestartCheckbox = new CheckBox
{
Text = "Restart game after mission ends (watchdog)",
Location = new Point(16, 146),
AutoSize = true,
Checked = true
};
mRestartCheckbox.CheckedChanged += (s, e) => mSimulator.RestartOnEndMission = mRestartCheckbox.Checked;
statusGroup.Controls.Add(mListeningLabel);
statusGroup.Controls.Add(mConnectionLabel);
statusGroup.Controls.Add(roleLabel);
statusGroup.Controls.Add(stateCaption);
statusGroup.Controls.Add(mStateLabel);
statusGroup.Controls.Add(mRestartCheckbox);
statusGroup.Controls.Add(powerGroup);
statusGroup.Controls.Add(gameGroup);
// ---- egg viewer + log ----
// Split the egg viewer (left) and protocol log (right). The 50/50 default
// is applied in OnFormLoad once the control has its real width (setting it
// here would clamp to the control's default size).
mSplit = new SplitContainer
{
Dock = DockStyle.Fill,
Orientation = Orientation.Vertical
};
SplitContainer split = mSplit;
GroupBox eggGroup = new GroupBox { Text = "Current Egg", Dock = DockStyle.Fill, Padding = new Padding(8) };
// The egg is kept after a mission/restart (not auto-cleared) so it can be
// copied for dev use; the Clear button empties the viewer on demand.
Panel eggHeader = new Panel { Dock = DockStyle.Top, Height = 30 };
Button clearEggButton = new Button { Text = "Clear", Dock = DockStyle.Right, Width = 70 };
mEggSummaryLabel = new Label
{
Dock = DockStyle.Fill,
Text = "No egg loaded.",
TextAlign = ContentAlignment.MiddleLeft
};
clearEggButton.Click += (s, e) =>
{
mEggBox.Clear();
mEggSummaryLabel.Text = "No egg loaded.";
};
eggHeader.Controls.Add(mEggSummaryLabel);
eggHeader.Controls.Add(clearEggButton);
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(eggHeader);
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);
// ---- virtual launcher / site management column ----
GroupBox siteGroup = new GroupBox
{
Text = "Launcher / Site Management",
Dock = DockStyle.Right,
Width = 360,
Padding = new Padding(8)
};
Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 216 };
mProvisionStatusLabel = new Label
{
AutoSize = true,
Location = new Point(4, 4),
Font = new Font("Segoe UI", 9f, FontStyle.Bold),
Text = "Provisioning: —"
};
// Stand-in for the real pod's TeslaPasscodeDisplay: the operator reads
// these two values into the console's Configure dialog.
mPassphrasePanel = new Panel { Location = new Point(4, 28), Size = new Size(336, 62), Visible = false };
Label requestIdCaption = new Label { AutoSize = true, Location = new Point(0, 6), Text = "Request ID:" };
mRequestIdValueLabel = new Label
{
AutoSize = true,
Location = new Point(90, 0),
Font = new Font("Segoe UI", 15f, FontStyle.Bold),
Text = "—"
};
Label passphraseCaption = new Label { AutoSize = true, Location = new Point(0, 38), Text = "Passphrase:" };
mPassphraseValueLabel = new Label
{
AutoSize = true,
Location = new Point(90, 32),
Font = new Font("Segoe UI", 15f, FontStyle.Bold),
ForeColor = Color.Firebrick,
Text = "—"
};
mPassphrasePanel.Controls.Add(requestIdCaption);
mPassphrasePanel.Controls.Add(mRequestIdValueLabel);
mPassphrasePanel.Controls.Add(passphraseCaption);
mPassphrasePanel.Controls.Add(mPassphraseValueLabel);
mNetConfigLabel = new Label
{
Location = new Point(4, 94),
Size = new Size(336, 32),
ForeColor = Color.Gray,
Text = ""
};
mRpcStatusLabel = new Label
{
AutoSize = true,
Location = new Point(4, 130),
Text = "Launcher RPC: not running"
};
mInstallStatusLabel = new Label
{
Location = new Point(4, 154),
Size = new Size(336, 18),
Text = "No install in progress."
};
mInstallProgressBar = new ProgressBar
{
Location = new Point(4, 176),
Size = new Size(240, 22),
Minimum = 0,
Maximum = 100
};
mReprovisionButton = new Button
{
Text = "Reprovision",
Location = new Point(252, 174),
Size = new Size(88, 26)
};
mReprovisionButton.Click += ReprovisionClicked;
siteTop.Controls.Add(mProvisionStatusLabel);
siteTop.Controls.Add(mPassphrasePanel);
siteTop.Controls.Add(mNetConfigLabel);
siteTop.Controls.Add(mRpcStatusLabel);
siteTop.Controls.Add(mInstallStatusLabel);
siteTop.Controls.Add(mInstallProgressBar);
siteTop.Controls.Add(mReprovisionButton);
mAppsView = new ListView
{
Dock = DockStyle.Fill,
View = View.Details,
FullRowSelect = true,
HeaderStyle = ColumnHeaderStyle.Nonclickable
};
mAppsView.Columns.Add("Installed App", 150);
mAppsView.Columns.Add("PID", 48);
mAppsView.Columns.Add("Auto", 42);
mAppsView.Columns.Add("Command", 320);
siteGroup.Controls.Add(mAppsView);
siteGroup.Controls.Add(siteTop);
Controls.Add(split);
Controls.Add(statusGroup);
Controls.Add(siteGroup);
}
private void OnFormLoad(object sender, EventArgs e)
{
// Now that the split has its real width, give the log half the window.
if (mSplit.Width > mSplit.Panel1MinSize + mSplit.Panel2MinSize)
{
mSplit.SplitterDistance = mSplit.Width / 2;
}
UpdateConnectionLabel(null);
UpdateStateLabel(mSimulator.State);
RefreshAppsList();
PowerOnClicked(this, EventArgs.Empty);
}
private void OnFormClosing(object sender, FormClosingEventArgs e)
{
mServer.Stop();
StopManagement();
}
/// <summary>
/// Pod power = the whole virtual machine. Powering on boots the
/// launcher/site-management side and auto-starts the game client (a real
/// pod's boot-time autoRestart launch); the game can then be stopped and
/// started on its own while the pod stays up.
/// </summary>
private void PowerOnClicked(object sender, EventArgs e)
{
mRebootTimer.Stop();
SetPoweredState(on: true);
StartManagement();
StartGameClicked(sender, e);
}
/// <summary>
/// Powers the whole pod off: the game listener AND the launcher/provisioning
/// side go dark, like unplugging the machine. (To mimic just the game exe
/// exiting while the pod stays up, use Stop Game.)
/// </summary>
private void PowerOffClicked(object sender, EventArgs e)
{
mRebootTimer.Stop();
StopGame();
StopManagement();
SetPoweredState(on: false);
}
/// <summary>Starts the emulated game client (Munga listener) on a powered-on pod.</summary>
private void StartGameClicked(object sender, EventArgs e)
{
if (!mPoweredOn)
{
return;
}
mRestartTimer.Stop(); // cancel any pending watchdog restart
if (!mServer.IsListening && !StartServer())
{
SetGameState(running: false);
return; // couldn't bind the port; pod stays up, game stays stopped
}
mSimulator.PowerOn();
SetGameState(running: true);
}
/// <summary>
/// Stops just the emulated game "exe": the Munga listener closes (the
/// console's game connection drops) but the pod — and its launcher /
/// site-management side — stays up, like killing the game process on a
/// real pod.
/// </summary>
private void StopGameClicked(object sender, EventArgs e)
{
StopGame();
}
private void StopGame()
{
mRestartTimer.Stop();
mServer.Stop();
SetGameState(running: false);
}
/// <summary>
/// The game exited gracefully on the console's end-mission command. Simulate
/// the process exit by closing the listener (the console's connection drops),
/// then let the watchdog timer relaunch it.
/// </summary>
private void OnEndMissionExit()
{
if (IsDisposed) return;
BeginInvoke((Action)(() =>
{
OnLog("Game exited gracefully (end mission); watchdog will restart it...");
mServer.Stop();
UpdateListeningLabel(false);
UpdateConnectionLabel(null);
mStateLabel.Text = "Restarting...";
mStateLabel.ForeColor = Color.DarkOrange;
mStartGameButton.Enabled = true;
mStopGameButton.Enabled = false;
mResetButton.Enabled = false;
mRestartTimer.Stop();
mRestartTimer.Start();
}));
}
private void OnRestartTimerTick(object sender, EventArgs e)
{
mRestartTimer.Stop();
if (!mPoweredOn)
{
return; // pod was powered off while the watchdog was pending
}
if (StartServer())
{
OnLog("Watchdog restarted the game.");
mSimulator.PowerOn();
SetGameState(running: true);
}
else
{
SetGameState(running: false);
}
}
/// <summary>Starts the listener and updates the status label. Returns false (with a message) if the port is unavailable.</summary>
private bool StartServer()
{
try
{
mServer.Start();
UpdateListeningLabel(true);
return true;
}
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);
return false;
}
}
/// <summary>Reflects the pod's (machine-level) on/off state in the buttons and,
/// when off, the status labels. Game-level state is <see cref="SetGameState" />.</summary>
private void SetPoweredState(bool on)
{
mPoweredOn = on;
mPowerButton.Enabled = !on;
mPowerOffButton.Enabled = on;
mReprovisionButton.Enabled = on && !mOptions.NoManage;
if (!on)
{
mStartGameButton.Enabled = false;
mStopGameButton.Enabled = false;
mResetButton.Enabled = false;
UpdateListeningLabel(false);
UpdateConnectionLabel(null);
mStateLabel.Text = "Powered Off";
mStateLabel.ForeColor = Color.Gray;
mProvisionStatusLabel.Text = "Provisioning: pod powered off";
mProvisionStatusLabel.ForeColor = Color.Gray;
mPassphrasePanel.Visible = false;
mRpcStatusLabel.Text = "Launcher RPC: pod powered off";
mRpcStatusLabel.ForeColor = Color.Gray;
}
}
/// <summary>Reflects whether the emulated game client is running; the pod
/// itself (launcher/site management) may still be up with the game stopped.</summary>
private void SetGameState(bool running)
{
mStartGameButton.Enabled = mPoweredOn && !running;
mStopGameButton.Enabled = mPoweredOn && running;
mResetButton.Enabled = mPoweredOn && running;
if (!running)
{
UpdateListeningLabel(false);
UpdateConnectionLabel(null);
if (mPoweredOn)
{
mStateLabel.Text = "Game Not Running";
mStateLabel.ForeColor = Color.Gray;
}
}
}
// ---- virtual launcher / site management lifecycle ----
/// <summary>Brings the launcher side up for a powered-on pod: the RPC server
/// when a session key is stored, otherwise the provisioning beacons.</summary>
private void StartManagement()
{
if (mOptions.NoManage)
{
mProvisionStatusLabel.Text = "Provisioning: disabled (-nomanage)";
mProvisionStatusLabel.ForeColor = Color.Gray;
mRpcStatusLabel.Text = "Launcher RPC: disabled (-nomanage)";
mRpcStatusLabel.ForeColor = Color.Gray;
return;
}
byte[] key = mLauncher.LoadKey();
if (key != null)
{
StartRpcServer(key);
}
else
{
StartProvisioning();
}
}
private void StopManagement()
{
mProvisioning.Stop();
mRpcServer.Stop();
}
private void StartRpcServer(byte[] key)
{
try
{
mRpcServer.Start(key);
}
catch (Exception ex)
{
OnSiteLog($"FAILED to listen on TCP {LauncherRpcServer.ManagePort}: {ex.Message}");
mRpcStatusLabel.Text = $"Launcher RPC: TCP {LauncherRpcServer.ManagePort} unavailable";
mRpcStatusLabel.ForeColor = Color.Firebrick;
return;
}
mProvisionStatusLabel.Text = "Provisioned — key " + KeyFingerprint(key);
mProvisionStatusLabel.ForeColor = Color.ForestGreen;
mPassphrasePanel.Visible = false;
mRpcStatusLabel.Text = $"Launcher RPC: listening on TCP {LauncherRpcServer.ManagePort}";
mRpcStatusLabel.ForeColor = Color.ForestGreen;
}
private void StartProvisioning()
{
mProvisioning.Start();
mProvisionStatusLabel.Text = "Unprovisioned — beaconing to console...";
mProvisionStatusLabel.ForeColor = Color.DarkGoldenrod;
mRequestIdValueLabel.Text = mProvisioning.RequestId;
mPassphraseValueLabel.Text = mProvisioning.Passphrase;
mPassphrasePanel.Visible = true;
mNetConfigLabel.Text = "Use Manage Site's \"Configure\" button and enter the passphrase above.";
mRpcStatusLabel.Text = "Launcher RPC: waiting for provisioning";
mRpcStatusLabel.ForeColor = Color.Gray;
}
/// <summary>Drops the session key, clears the installed-apps store and
/// re-enters provisioning — from the UI button, or when the console's
/// Reconfigure clears our store. (Fresh-pod state; extracted Games\ files
/// are left on disk.)</summary>
private void DropKeyAndReprovision()
{
mLauncher.WipeApps();
mLauncher.DeleteKey();
mRpcServer.Stop();
mProvisioning.Stop();
mNetConfigLabel.Text = "";
if (mPoweredOn && !mOptions.NoManage)
{
StartProvisioning();
}
}
private void ReprovisionClicked(object sender, EventArgs e)
{
OnSiteLog("Reprovision requested from the vPOD window.");
DropKeyAndReprovision();
}
private static string KeyFingerprint(byte[] key)
{
return key.Length >= 4 ? $"{key[0]:X2}{key[1]:X2}{key[2]:X2}{key[3]:X2}…" : "?";
}
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;
// The egg viewer is intentionally NOT cleared here — the last egg stays
// visible (copyable) across missions/restarts until the Clear button.
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}%";
}
}));
}
// ---- virtual launcher event handlers (marshal to UI thread) ----
private void OnSiteLog(string message)
{
OnLog("[SITE] " + message);
}
private void OnProvisioned(byte[] key)
{
if (IsDisposed) return;
BeginInvoke((Action)(() =>
{
mLauncher.SaveKey(key);
mProvisioning.Stop();
StartRpcServer(key);
}));
}
private void OnConfigReceived(BasicConfigResponse config)
{
if (IsDisposed) return;
BeginInvoke((Action)(() =>
{
mNetConfigLabel.Text = $"Assigned (display only): IP {config.Address} / {config.Mask}" +
(string.IsNullOrEmpty(config.HostName) ? "" : $"\r\nhost \"{config.HostName}\", gw {config.Gateway}, dns {config.Dns}");
}));
}
private void OnRpcConnectionsChanged(int count)
{
if (IsDisposed) return;
BeginInvoke((Action)(() =>
{
if (!mRpcServer.IsListening)
{
return; // Stop() already updated the label
}
mRpcStatusLabel.Text = count > 0
? $"Launcher RPC: {count} console session(s) on TCP {LauncherRpcServer.ManagePort}"
: $"Launcher RPC: listening on TCP {LauncherRpcServer.ManagePort}";
mRpcStatusLabel.ForeColor = Color.ForestGreen;
}));
}
private void OnAppsChanged()
{
if (IsDisposed) return;
BeginInvoke((Action)RefreshAppsList);
}
private void RefreshAppsList()
{
LaunchData[] installed = mLauncher.GetInstalledApps();
LaunchedAppData[] launched = mLauncher.GetLaunchedApps();
mAppsView.BeginUpdate();
mAppsView.Items.Clear();
foreach (LaunchData app in installed)
{
StringBuilder pids = new StringBuilder();
foreach (LaunchedAppData proc in launched)
{
if (proc.LaunchKey == app.LaunchPair.LaunchKey)
{
if (pids.Length > 0) pids.Append(", ");
pids.Append(proc.ProcessId);
}
}
ListViewItem item = new ListViewItem(new[]
{
app.LaunchPair.DisplayName,
pids.ToString(),
app.AutoRestart ? "yes" : "",
string.IsNullOrEmpty(app.Arguments) ? app.ExeFile : app.ExeFile + " " + app.Arguments
});
mAppsView.Items.Add(item);
}
mAppsView.EndUpdate();
}
private void OnInstallProgress(Guid callId, OutOfBandProgress progress)
{
if (IsDisposed) return;
BeginInvoke((Action)(() =>
{
int percent = Math.Max(0, Math.Min(100, progress.PercentComplete));
if (progress.IsCompleted && percent >= 99)
{
// The wire deliberately tops out at 99 (the console retries on
// 100) — but the local bar can still show a finished install.
percent = 100;
}
mInstallProgressBar.Value = percent;
mInstallStatusLabel.Text = progress.IsCompleted
? "Install: " + progress.Status
: $"Installing: {progress.Status} ({progress.PercentComplete}%)";
}));
}
/// <summary>The console asked the pod to shut down or restart: go fully dark
/// (Munga + launcher), and come back after a "reboot" pause when restarting.</summary>
private void OnShutdownRequested(bool restart)
{
if (IsDisposed) return;
BeginInvoke((Action)(() =>
{
mRebootTimer.Stop();
StopGame();
StopManagement();
SetPoweredState(on: false);
if (restart)
{
mStateLabel.Text = "Rebooting...";
mStateLabel.ForeColor = Color.DarkOrange;
mRebootTimer.Start();
}
}));
}
private void OnRebootTimerTick(object sender, EventArgs e)
{
mRebootTimer.Stop();
OnLog("[SITE] Pod rebooted.");
PowerOnClicked(this, EventArgs.Empty);
}
/// <summary>The console's Reconfigure cleared our store — drop the key and go
/// back to beaconing so the "Configure" button reappears in Manage Site.</summary>
private void OnReprovisionRequested()
{
if (IsDisposed) return;
BeginInvoke((Action)DropKeyAndReprovision);
}
// ---- 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();
}
}
-405
View File
@@ -1,405 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using Tesla.Net;
namespace VPod;
/// <summary>
/// The state behind vPOD's virtual TeslaLauncher service: the installed-app
/// registry, the (simulated) launched-process table, the volume level, the
/// install-progress map and the session-key store. This is the pod-side model
/// that <see cref="LauncherRpcServer" /> dispatches the console's
/// ILauncherService calls onto — the vPOD equivalent of the real launcher's
/// Service+Agent pair, minus any actual process launching.
///
/// Persisted under %LocalAppData%\vPOD:
/// TeslaKeyStore.key session key, launcher format: [1-byte len][key bytes]
/// LaunchApps.json installed apps (the wire LaunchData list, PodRpc JSON)
/// Games\ where InstallProduct zips are extracted (virtual C:\Games)
/// </summary>
internal sealed class VirtualLauncher
{
private readonly object mLock = new object();
private readonly List<LaunchData> mInstalledApps = new List<LaunchData>();
private readonly List<LaunchedAppData> mLaunchedApps = new List<LaunchedAppData>();
private readonly Dictionary<Guid, OutOfBandProgress> mInstallProgress = new Dictionary<Guid, OutOfBandProgress>();
private float mVolumeLevel = 1.0f;
private int mNextPid = 4000;
public event Action<string> Log;
public event Action AppsChanged; // installed or launched list changed
public event Action<Guid, OutOfBandProgress> InstallProgressChanged;
public event Action<bool> ShutdownRequested; // restart?
public event Action ReprovisionRequested; // console cleared the store (Reconfigure)
public string DataDirectory { get; }
public string GamesRoot => Path.Combine(DataDirectory, "Games");
public string KeyFilePath => Path.Combine(DataDirectory, "TeslaKeyStore.key");
private string LaunchAppsPath => Path.Combine(DataDirectory, "LaunchApps.json");
public VirtualLauncher()
: this(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "vPOD"))
{
}
public VirtualLauncher(string dataDirectory)
{
DataDirectory = dataDirectory;
Directory.CreateDirectory(DataDirectory);
LoadApps();
}
// ---- session key store (launcher's TeslaKeyStore.key format) ----
/// <summary>The provisioned session key, or null when unprovisioned.</summary>
public byte[] LoadKey()
{
try
{
if (!File.Exists(KeyFilePath))
{
return null;
}
byte[] raw = File.ReadAllBytes(KeyFilePath);
if (raw.Length < 2 || raw.Length != raw[0] + 1)
{
return null;
}
byte[] key = new byte[raw[0]];
Buffer.BlockCopy(raw, 1, key, 0, key.Length);
return key;
}
catch
{
return null;
}
}
public void SaveKey(byte[] key)
{
byte[] raw = new byte[key.Length + 1];
raw[0] = (byte)key.Length;
key.CopyTo(raw, 1);
File.WriteAllBytes(KeyFilePath, raw);
}
public void DeleteKey()
{
try { File.Delete(KeyFilePath); } catch { }
}
// ---- ILauncherService state ----
public DateTime Ping(DateTime now)
{
return now;
}
public float VolumeLevel
{
get { lock (mLock) { return mVolumeLevel; } }
set
{
lock (mLock) { mVolumeLevel = value; }
Log?.Invoke($"Volume set to {value:P0}.");
}
}
public LaunchData[] GetInstalledApps()
{
lock (mLock) { return mInstalledApps.ToArray(); }
}
public LaunchPair[] GetLaunchableApps()
{
lock (mLock)
{
LaunchPair[] pairs = new LaunchPair[mInstalledApps.Count];
for (int i = 0; i < mInstalledApps.Count; i++)
{
pairs[i] = mInstalledApps[i].LaunchPair;
}
return pairs;
}
}
public LaunchedAppData[] GetLaunchedApps()
{
lock (mLock) { return mLaunchedApps.ToArray(); }
}
public FullUpdateData FullUpdate()
{
lock (mLock)
{
return new FullUpdateData
{
InstalledApps = mInstalledApps.ToArray(),
LaunchedApps = mLaunchedApps.ToArray(),
VolumeLevel = mVolumeLevel
};
}
}
/// <summary>Registers a launch entry (upsert by LaunchKey) — the per-entry call
/// the console makes after a product install, and the write behind LaunchApps.json.</summary>
public void InstallApp(LaunchData data)
{
lock (mLock)
{
int existing = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == data.LaunchPair.LaunchKey);
if (existing >= 0)
{
mInstalledApps[existing] = data;
}
else
{
mInstalledApps.Add(data);
}
SaveApps();
}
Log?.Invoke($"InstallApp: \"{data.LaunchPair.DisplayName}\" -> {data.ExeFile} {data.Arguments}");
AppsChanged?.Invoke();
}
public void UninstallApp(Guid launchKey)
{
string cleanupDir = null;
string name = null;
lock (mLock)
{
int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == launchKey);
if (index < 0)
{
return;
}
LaunchData app = mInstalledApps[index];
name = app.LaunchPair.DisplayName;
mInstalledApps.RemoveAt(index);
mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey);
SaveApps();
// Like the real Agent's CleanupDir: remove the product folder, but only
// when it's inside our virtual games root and no other app still uses it.
string dir = app.WorkingDirectory;
if (!string.IsNullOrEmpty(dir)
&& IsUnderGamesRoot(dir)
&& !mInstalledApps.Exists(a => string.Equals(a.WorkingDirectory, dir, StringComparison.OrdinalIgnoreCase)))
{
cleanupDir = dir;
}
}
if (cleanupDir != null)
{
try
{
if (Directory.Exists(cleanupDir))
{
Directory.Delete(cleanupDir, recursive: true);
Log?.Invoke($"UninstallApp: removed product directory {cleanupDir}");
}
}
catch (Exception ex)
{
Log?.Invoke($"UninstallApp: could not remove {cleanupDir}: {ex.Message}");
}
}
Log?.Invoke($"UninstallApp: \"{name}\" removed.");
AppsChanged?.Invoke();
}
public void RemoveApp(Guid launchKey)
{
lock (mLock)
{
int removed = mInstalledApps.RemoveAll(a => a.LaunchPair.LaunchKey == launchKey);
if (removed == 0)
{
return;
}
mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey);
SaveApps();
}
Log?.Invoke($"RemoveApp: {launchKey} unregistered.");
AppsChanged?.Invoke();
}
public int LaunchApp(Guid launchKey)
{
LaunchData app;
int pid;
lock (mLock)
{
int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == launchKey);
if (index < 0)
{
Log?.Invoke($"LaunchApp: unknown launch key {launchKey}.");
return 0;
}
app = mInstalledApps[index];
pid = mNextPid++;
mLaunchedApps.Add(new LaunchedAppData { ProcessId = pid, LaunchKey = launchKey });
}
Log?.Invoke($"LaunchApp: \"{app.LaunchPair.DisplayName}\" -> simulated PID {pid} (no real process).");
AppsChanged?.Invoke();
return pid;
}
public void KillApp(Guid launchKey, int processId)
{
int removed;
lock (mLock)
{
removed = mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey && l.ProcessId == processId);
}
if (removed > 0)
{
Log?.Invoke($"KillApp: simulated PID {processId} killed.");
AppsChanged?.Invoke();
}
}
public void KillAllOfType(Guid launchKey)
{
int removed;
lock (mLock)
{
removed = mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey);
}
if (removed > 0)
{
Log?.Invoke($"KillAllOfType: {removed} simulated process(es) killed.");
AppsChanged?.Invoke();
}
}
public void KillAllApps()
{
int removed;
lock (mLock)
{
removed = mLaunchedApps.Count;
mLaunchedApps.Clear();
}
if (removed > 0)
{
Log?.Invoke($"KillAllApps: {removed} simulated process(es) killed.");
AppsChanged?.Invoke();
}
}
public void Shutdown(bool restart)
{
Log?.Invoke(restart ? "Shutdown(restart) — power-cycling the pod." : "Shutdown — powering the pod off.");
ShutdownRequested?.Invoke(restart);
}
/// <summary>The console's Reconfigure calls this before deleting the pod from the
/// site. Wipe the store AND drop the session key: the console no longer holds it,
/// so re-entering provisioning (beacon mode) is what makes Reconfigure completable
/// without the real pod's reboot-with-DHCP step.</summary>
public void ClearStore()
{
WipeApps();
Log?.Invoke("ClearStore: installed apps wiped; dropping session key to re-enter provisioning.");
ReprovisionRequested?.Invoke();
}
/// <summary>Clears the installed/launched app registries (LaunchApps.json) —
/// the store-wipe half of ClearStore, also used by the UI's Reprovision.</summary>
public void WipeApps()
{
lock (mLock)
{
mInstalledApps.Clear();
mLaunchedApps.Clear();
SaveApps();
}
AppsChanged?.Invoke();
}
// ---- install progress (InitiateInstallProduct / GetOutOfBandProgress) ----
public Guid InitiateInstallProduct()
{
Guid callId = Guid.NewGuid();
UpdateProgress(callId, 0, "Waiting for file...");
return callId;
}
public OutOfBandProgress GetOutOfBandProgress(Guid callId)
{
lock (mLock)
{
if (mInstallProgress.TryGetValue(callId, out OutOfBandProgress progress))
{
return progress;
}
}
return new OutOfBandProgress { PercentComplete = 0, Status = "Unknown call ID", IsCompleted = true };
}
public void UpdateProgress(Guid callId, int percent, string status, bool isCompleted = false)
{
OutOfBandProgress progress = new OutOfBandProgress
{
PercentComplete = percent,
Status = status,
IsCompleted = isCompleted
};
lock (mLock)
{
mInstallProgress[callId] = progress;
}
InstallProgressChanged?.Invoke(callId, progress);
}
private bool IsUnderGamesRoot(string path)
{
try
{
string full = Path.GetFullPath(path);
string root = Path.GetFullPath(GamesRoot) + Path.DirectorySeparatorChar;
return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
// ---- persistence (PodRpc's JSON options round-trip the wire types exactly) ----
private void LoadApps()
{
try
{
if (!File.Exists(LaunchAppsPath))
{
return;
}
LaunchData[] apps = JsonSerializer.Deserialize<LaunchData[]>(File.ReadAllText(LaunchAppsPath), PodRpc.JsonOptions);
if (apps != null)
{
mInstalledApps.AddRange(apps);
}
}
catch
{
// unreadable store: start empty, like a fresh pod
}
}
private void SaveApps()
{
try
{
File.WriteAllText(LaunchAppsPath, JsonSerializer.Serialize(mInstalledApps.ToArray(), PodRpc.JsonOptions));
}
catch (Exception ex)
{
Log?.Invoke("Could not persist LaunchApps.json: " + ex.Message);
}
}
}
-39
View File
@@ -1,39 +0,0 @@
# 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
-66
View File
@@ -1,66 +0,0 @@
<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 differential suite drives the real PodManagerConnection client against
vPOD's LauncherRpcServer in-process (VPodLauncherServerTests). -->
<InternalsVisibleTo Include="TeslaConsole.DiffTests" />
</ItemGroup>
<ItemGroup>
<!-- Framework assemblies for extracting InstallProduct zips (virtual launcher) -->
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
</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>
<ItemGroup>
<!-- The shared console<->launcher wire libraries, so vPOD can also stand in
for the pod's TeslaLauncher service (Site Management / Install Product):
PodRpc framing + ILauncherService wire types, and the SecureConfig
provisioning protocol + OFB crypto-stream handshake. vPOD implements the
SERVER side of the existing contract only - no new RPCs. -->
<ProjectReference Include="..\..\Contract\Tesla.Contract.csproj" />
<ProjectReference Include="..\..\SecureConfig\Tesla.SecureConfig.csproj" />
</ItemGroup>
</Project>