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:
@@ -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)..."
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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->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;
|
||||
}
|
||||
}
|
||||
@@ -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 <port></c> Munga control port (default 1501)
|
||||
/// <c>-lc</c> live-camera role (cosmetic here; state model is identical)
|
||||
/// <c>-mr</c> mission-review role
|
||||
/// <c>-res W H</c> resolution (ignored)
|
||||
/// <c>-app rp|bt</c> which ApplicationID to report (RP by default; also
|
||||
/// switchable live in the UI)
|
||||
/// <c>-host <id></c> the responding host id reported in state responses
|
||||
///
|
||||
/// 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
|
||||
}
|
||||
@@ -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 <RequestId>" 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 <Request ID>"** 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 `0–50%` receive / `50–95%`
|
||||
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.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user