Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85595b8c52 | ||
|
|
106fa610c0 | ||
|
|
8ba428b6a4 | ||
|
|
91640dcbf2 | ||
|
|
eefb8054e0 | ||
|
|
8730b9b966 | ||
|
|
1d95a800fd | ||
|
|
f07df88acb | ||
|
|
13f8e0456b | ||
|
|
80ee1d26ea | ||
|
|
a5f25c8bb8 | ||
|
|
6a77fd0991 | ||
|
|
d30ce8bdbe | ||
|
|
cb7c655530 | ||
|
|
60893172c3 | ||
|
|
e87a8a22f1 | ||
|
|
e0a3c72370 | ||
|
|
1daaf4af78 |
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
name: verify
|
||||||
|
description: Drive the TeslaConsole WinForms app against vPOD to verify console changes end-to-end on this machine.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Verifying console changes against vPOD
|
||||||
|
|
||||||
|
Build (Debug is what the exes below point at):
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet build Console/TeslaConsole.csproj
|
||||||
|
dotnet build vPOD/vPOD.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Launch both (the machine's `C:\ProgramData\Tesla Console\local.siteconfig`
|
||||||
|
already has squad `bay1` with a provisioned `vPOD` pod at 127.0.0.1; vPOD's
|
||||||
|
session key persists in `%LocalAppData%\vPOD\TeslaKeyStore.key`):
|
||||||
|
|
||||||
|
```
|
||||||
|
Start-Process vPOD\bin\Debug\net40\vPOD.exe -ArgumentList "-app","bt" # or rp
|
||||||
|
Start-Process Console\bin\Debug\net40\TeslaConsole.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
(net40 since the XP11 port — both exes run on the machine's 4.8 runtime.)
|
||||||
|
|
||||||
|
Drive the UI with System.Windows.Automation (UIA) — WinForms exposes
|
||||||
|
menus, buttons, checkboxes, and DataGridView rows/cells as real UIA elements:
|
||||||
|
|
||||||
|
- Menus: ExpandCollapsePattern on "Games", InvokePattern on the item.
|
||||||
|
- Pilots grid cells are named like `Enabled Row 1`, `Pilot Row 1`; real mouse
|
||||||
|
clicks at the cell's BoundingRectangle center behave exactly like a user
|
||||||
|
(needed for CellEndEdit paths — programmatic value sets bypass them).
|
||||||
|
Click another cell afterwards to commit a checkbox cell edit.
|
||||||
|
- Type into a grid cell: click it, then `[System.Windows.Forms.SendKeys]::SendWait()`.
|
||||||
|
- The WeifenLuo dock tabs are NOT UIA TabItems; switch documents with
|
||||||
|
Ctrl+Tab sent to the focused window.
|
||||||
|
- Screenshots: Graphics.CopyFromScreen of the element's BoundingRectangle
|
||||||
|
(coordinates are physical pixels; fine to pass straight to SetCursorPos).
|
||||||
|
|
||||||
|
Observe behavior:
|
||||||
|
|
||||||
|
- Game connection endpoint: `Get-NetTCPConnection -OwningProcess <consolePid>
|
||||||
|
-RemotePort 1501`. vPOD binds 0.0.0.0:1501, so any 127.x.x.x target lands on
|
||||||
|
it — distinguish targets by the netstat RemoteAddress, not by whether vPOD
|
||||||
|
answered.
|
||||||
|
- Mission content: vPOD's Current Egg pane shows the decoded egg (player IPs,
|
||||||
|
pilots, map) after clicking Load; its protocol log timestamps connects,
|
||||||
|
disconnects, and state transitions.
|
||||||
|
- Full lifecycle: enable a pod row + pilot name → Load → Run Mission → Stop
|
||||||
|
Mission. With the watchdog checkbox on, vPOD drops the connection after a
|
||||||
|
mission ends and comes back in WaitingForEgg — an observed reconnect there
|
||||||
|
is normal.
|
||||||
|
|
||||||
|
Gotchas: enabling a pod row only requests the connection once the cell edit
|
||||||
|
commits. A helper script with these UIA primitives from a previous session:
|
||||||
|
scratchpad `uia.ps1` (Get-Window / Find-ByName / Click-Elem / Screenshot) —
|
||||||
|
recreate from this recipe if gone. Close both apps when done
|
||||||
|
(`CloseMainWindow`, then Stop-Process stragglers).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Addendum: mission-close / egress-hold — decoded + disassembled (2026-07-10)
|
||||||
|
|
||||||
|
From the TeslaRel410 side. Verified against a live DOSBox BTL4OPT pod run
|
||||||
|
BY THIS .NET CONSOLE over the host bridge (it works). Full writeup:
|
||||||
|
TeslaRel410/emulator/NET-NOTES.md.
|
||||||
|
|
||||||
|
> **ISSUE CLOSED 2026-07-10 (won't-fix, operator decision).** The floor
|
||||||
|
> egress lamps no longer exist on any surviving cockpit, so the hold has
|
||||||
|
> no restoration value. FINAL, corrected finding (supersedes the "~30s"
|
||||||
|
> phrasing below): the pod's post-mission egress window is a compiled
|
||||||
|
> **~3 seconds** — the game exits at its stage-1 timer (Step-0 proven:
|
||||||
|
> a no-loop single-shot boot still held 3.9s then the game self-exited),
|
||||||
|
> and the deeper 30s timer is never reached. Byte-identical across all
|
||||||
|
> four BTL4OPT builds, so nothing regressed. **Console takeaway is
|
||||||
|
> unchanged and simple: send StopMission(RunBattleTech) promptly at your
|
||||||
|
> timer end; the pod handles its own (short) close. No egress delay to
|
||||||
|
> implement.** The rest of this file is preserved for the record.
|
||||||
|
|
||||||
|
## Bottom line for TeslaConsole
|
||||||
|
|
||||||
|
**The customer-egress window is a HARDCODED ~30s timer inside the GAME,
|
||||||
|
not the console.** You do NOT need to implement the egress delay. Just
|
||||||
|
send `Application__StopMissionMessage` promptly at mission end with
|
||||||
|
`RunBattleTechExitCodeID` (=3), and the pod runs the whole authentic
|
||||||
|
sequence itself: egress lamps ON -> ~30s hold -> lamps OFF -> exit ->
|
||||||
|
GO.BAT relaunch. The console's only timing responsibility is not to send
|
||||||
|
StopMission too EARLY (let the mission actually end first).
|
||||||
|
|
||||||
|
## The sequence (game side)
|
||||||
|
|
||||||
|
1. Mission end -> game fades to black hold, sends the wrap-up
|
||||||
|
(ConsolePlayerMechScoreUpdate / ConsoleBTTeamScoreUpdate /
|
||||||
|
RankAndScore). Its StateResponse to your 1/s StateQuery flips state
|
||||||
|
0 -> 2 (mission-over/hold). It waits INDEFINITELY here for you.
|
||||||
|
**CAUTION — "mission end" is NOT the pod's own clock.** Bench-confirmed
|
||||||
|
2026-07-09 (12s mission via this console): the pod does NOT self-end;
|
||||||
|
the egg `length=` only drives the cockpit clock DISPLAY, which hits 0
|
||||||
|
and counts back UP while play continues. The console is the game's
|
||||||
|
sole timekeeper — this hold state is only ever entered off the
|
||||||
|
console's close (or an in-game end condition), so in practice steps
|
||||||
|
1-2 collapse: the console's StopMission at ITS timer end triggers the
|
||||||
|
fade/wrap-up/lamps directly (see step 3).
|
||||||
|
2. You send `Application__StopMissionMessage(ExitCodeID)`.
|
||||||
|
3. `StopMissionMessageHandler` (BTL4OPT.EXE @0x47b864, disassembled):
|
||||||
|
- calls mission-shutdown/EndMission (0x44eeb4)
|
||||||
|
- turns the RIO egress lamps ON (routine @0x47bba8, flag=1; lamps
|
||||||
|
0x16/0x17/0x1e = the floor/entry cluster; the "LightsOut" string
|
||||||
|
@0x4fd5ac names this family)
|
||||||
|
- schedules the lights-out+exit for `now + 30.0*timebase + 0.5`
|
||||||
|
(30.0f constant @0x47b8e4) -- the customer-egress hold
|
||||||
|
4. ~30s later the timer fires -> lamps swept off -> game exits with the
|
||||||
|
exit code -> pod's BAT loop dispatches it (RunBattleTech = relaunch).
|
||||||
|
|
||||||
|
## Console message set + exit codes (from CODE/RP/MUNGA/APPMSG.HPP)
|
||||||
|
|
||||||
|
Messages: StateQuery, CheckLoad, RunMission, **StopMission**, KeyCommand,
|
||||||
|
SuspendMission, ResumeMission, LoadMission, Abort.
|
||||||
|
|
||||||
|
`ExitCodeID` (the game's exit status = what the pod's BAT loop runs next):
|
||||||
|
```
|
||||||
|
Null=0, Abort, RunRedPlanet, RunBattleTech, RunSinglePlayerRedPlanet,
|
||||||
|
RunSinglePlayerBattleTech, DisplayMainTestPattern, DisplayAuxTestPattern,
|
||||||
|
TestPlasmaDisplay, ResetRIO, RunAudioTest, RunNortonDiskDoctor,
|
||||||
|
CheckDiskUsage, RefreshRedPlanet, RefreshBattleTech, ChangeScreenMode,
|
||||||
|
SoftwareReset, ClearCrashlog, KillSpoolFile, RunRedPlanetCamera,
|
||||||
|
RunBattleTechCamera, RunRedPlanetMissionReview, RunBattleTechMissionReview
|
||||||
|
```
|
||||||
|
= a whole remote-operations menu the console UI could expose (RIO reset,
|
||||||
|
test patterns, plasma test, disk tools, camera/mission-review launches).
|
||||||
|
|
||||||
|
## Notes / open
|
||||||
|
|
||||||
|
- A configurable *pre*-StopMission delay is still fine to expose, but the
|
||||||
|
~30s customer window itself is the game's and is authentic at 30s.
|
||||||
|
- Measured lamp-on-to-sweep was only ~3.4s in two live runs; that sweep
|
||||||
|
was likely the teardown GeneralReset, with the real 30s timer firing
|
||||||
|
past the tap window. To confirm on the TeslaRel410 side: one mission
|
||||||
|
with a 90s+ RIO serial tap past mission end (btdis2.py found the
|
||||||
|
constant; a live tap confirms wall-time).
|
||||||
|
- Bonus authenticity: the floor lamp is ALSO entry lighting -- on from
|
||||||
|
game-ready until the mission drops (customer climbs in lit).
|
||||||
|
- Bench 2026-07-09 (12s mission): pod clock counted UP past 0 with play
|
||||||
|
continuing until the console's stop — confirms the console is the sole
|
||||||
|
timekeeper and step 1 never fires off the pod's own clock. If a
|
||||||
|
self-end path exists in the binary it wasn't reachable with our egg.
|
||||||
|
- Cross-ref: the BT411 up-port source (engine/MUNGA/APP.cpp ~1634) has a
|
||||||
|
TWO-stage StopMissionMessageHandler (stop#1 while Running -> EndingMission
|
||||||
|
+ lamps; stop#2 while Ending -> Stop()/exit; +30s LightsOut = lamps off
|
||||||
|
only). The shipping BTL4OPT.EXE disassembly above (one stop -> lamps +
|
||||||
|
scheduled exit at +30s) is authoritative for the DOSBox pod; the up-port
|
||||||
|
evidently diverges — worth knowing if BT411-built binaries ever drive a
|
||||||
|
real pod.
|
||||||
|
- A console-side "egress hold delay" (wait between console timer zero and
|
||||||
|
the StopMission send) was prototyped in TeslaConsole 2026-07-09 and
|
||||||
|
REVERTED: since the pod plays until stopped, that window is overtime
|
||||||
|
play, not lamps-on time. The game's own 30s window is the authentic
|
||||||
|
egress hold.
|
||||||
|
- CROSS-BUILD DISASM (TeslaRel410 side, 2026-07-10, stopmission_cmp3.py):
|
||||||
|
the StopMission close is BYTE-IDENTICAL across all four BTL4OPT builds
|
||||||
|
-- BTLIVE (May-96), BTRAVINE (Sep-96), BTDAVE, Rel410 -- so no build
|
||||||
|
regressed it (this settles the operator's "did Rel410 cut the hold?"
|
||||||
|
question: no). Each build's dispatched outer handler (Rel410 @0x47c2c4)
|
||||||
|
is the re-entrant two-stage one and carries BOTH a 3.0f and (via the
|
||||||
|
inner @0x47b864) a 30.0f constant. Confirms the up-port's two-stage
|
||||||
|
structure at the binary level. Whether 30s is honored live is still
|
||||||
|
open: our RIO tap saw only ~3.4s of lamp-on (the 3.0f stage) before the
|
||||||
|
exe recycled -- the 30s LightsOut may be pre-empted by teardown in the
|
||||||
|
loop-conf flow. Not console-actionable either way (prompt StopMission is
|
||||||
|
correct); noted for completeness.
|
||||||
@@ -8,9 +8,9 @@ using System.Runtime.InteropServices;
|
|||||||
[assembly: AssemblyCopyright("Copyright © 2009")]
|
[assembly: AssemblyCopyright("Copyright © 2009")]
|
||||||
[assembly: AssemblyConfiguration("")]
|
[assembly: AssemblyConfiguration("")]
|
||||||
[assembly: Guid("581ca4b6-a91c-4d24-b9b5-207f3b5da379")]
|
[assembly: Guid("581ca4b6-a91c-4d24-b9b5-207f3b5da379")]
|
||||||
[assembly: AssemblyFileVersion("4.11.4.1")]
|
[assembly: AssemblyFileVersion("4.11.4.3")]
|
||||||
[assembly: AssemblyTrademark("")]
|
[assembly: AssemblyTrademark("")]
|
||||||
[assembly: ComVisible(false)]
|
[assembly: ComVisible(false)]
|
||||||
[assembly: AssemblyTitle("Tesla Console")]
|
[assembly: AssemblyTitle("Tesla Console")]
|
||||||
[assembly: AssemblyDescription("All code and UI property of Virtual World Entertainment.\r\n\r\nDeveloped by Elsewhen Studios, LLC in association with VGCorps, LLC.\r\n\r\nElsewhen Studios and the Elsewhen Wormhole are trademarks of Elsewhen Studios, LLC\r\n\r\nIncludes the WeifenLuo DockingPane library. Copyright © 2007 Weifen Luo (email: weifenluo@yahoo.com). Licensed under the MIT License - details can be found in WeifenLuo.txt included in this installation.")]
|
[assembly: AssemblyDescription("All code and UI property of Virtual World Entertainment.\r\n\r\nDeveloped by Elsewhen Studios, LLC in association with VGCorps, LLC.\r\n\r\nElsewhen Studios and the Elsewhen Wormhole are trademarks of Elsewhen Studios, LLC\r\n\r\nIncludes the WeifenLuo DockingPane library. Copyright © 2007 Weifen Luo (email: weifenluo@yahoo.com). Licensed under the MIT License - details can be found in WeifenLuo.txt included in this installation.")]
|
||||||
[assembly: AssemblyVersion("4.11.4.1")]
|
[assembly: AssemblyVersion("4.11.4.3")]
|
||||||
|
|||||||
@@ -39,13 +39,14 @@ The console references these assemblies. Most are vendored as binaries under
|
|||||||
Two of these are no longer vendored binaries — they are built from source and
|
Two of these are no longer vendored binaries — they are built from source and
|
||||||
shared across the suite:
|
shared across the suite:
|
||||||
|
|
||||||
- `TeslaConsoleLaunchLib` ← `../Contract/Tesla.Contract.csproj`, a net48 project: the
|
- `TeslaConsoleLaunchLib` ← `../Contract/Tesla.Contract.csproj` (net40, like the
|
||||||
single source of truth for the Console↔Launcher RPC contract (wire types, the
|
whole suite since XP11): the single source of truth for the
|
||||||
|
Console↔Launcher RPC contract (wire types, the
|
||||||
`PodManagerConnection` client, and the framed-JSON `PodRpc` protocol), shared with the
|
`PodManagerConnection` client, and the framed-JSON `PodRpc` protocol), shared with the
|
||||||
Launcher Service. The assembly keeps the
|
Launcher. The assembly keeps the
|
||||||
`TeslaConsoleLaunchLib` name so the original-exe baseline still resolves in the
|
`TeslaConsoleLaunchLib` name so the original-exe baseline still resolves in the
|
||||||
differential tests; the wire no longer embeds assembly names (see RPC note below).
|
differential tests; the wire no longer embeds assembly names (see RPC note below).
|
||||||
- `TeslaSecureConfiguration` ← `../SecureConfig/Tesla.SecureConfig.csproj` (net48),
|
- `TeslaSecureConfiguration` ← `../SecureConfig/Tesla.SecureConfig.csproj` (net40),
|
||||||
the first-boot provisioning protocol (UDP beacons, OFB crypto, RSA key exchange).
|
the first-boot provisioning protocol (UDP beacons, OFB crypto, RSA key exchange).
|
||||||
|
|
||||||
The original `TeslaSecureConfiguration.dll` is retained under `lib/` as the baseline
|
The original `TeslaSecureConfiguration.dll` is retained under `lib/` as the baseline
|
||||||
@@ -55,14 +56,16 @@ decompiled to source the same way if full-source builds are needed.
|
|||||||
|
|
||||||
### Console ↔ Launcher RPC (no BinaryFormatter)
|
### Console ↔ Launcher RPC (no BinaryFormatter)
|
||||||
|
|
||||||
The pod-management channel (TCP 53290) runs **length-prefixed System.Text.Json**
|
The pod-management channel (TCP 53290) runs **length-prefixed JSON**
|
||||||
frames over the existing OFB-encrypted stream — see `Contract/PodRpcProtocol.cs`,
|
frames over the existing OFB-encrypted stream — see `Contract/PodRpcProtocol.cs`,
|
||||||
shared verbatim by both ends. This replaced the original `BinaryFormatter` +
|
shared verbatim by both ends (Newtonsoft.Json — net40 has no System.Text.Json).
|
||||||
|
This replaced the original `BinaryFormatter` +
|
||||||
serialized-`MethodBase` scheme (a remote-code-execution sink, and what had pinned the
|
serialized-`MethodBase` scheme (a remote-code-execution sink, and what had pinned the
|
||||||
Launcher to an old runtime); dispatch is now by method-name string. The Launcher
|
Launcher to an old runtime); dispatch is now by method-name string. The Launcher and
|
||||||
Service/Agent target **net48**, same as the Console. Note the Console still uses
|
the Console both target **net40** (XP11: XP SP3 through Windows 11). Note the Console
|
||||||
`BinaryFormatter` for *local* disk persistence (`Site` config, mission results) — that
|
still uses `BinaryFormatter` for *local* disk persistence (`Site` config, mission
|
||||||
is local file I/O on net48, not the network surface, and is intentionally left alone.
|
results) — that is local file I/O, not the network surface, and is intentionally
|
||||||
|
left alone.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -72,8 +75,10 @@ and still build and run.
|
|||||||
```
|
```
|
||||||
TeslaConsole/
|
TeslaConsole/
|
||||||
*.cs, TeslaConsole.*/ decompiled source (by namespace)
|
*.cs, TeslaConsole.*/ decompiled source (by namespace)
|
||||||
*.resx, app.ico embedded resources + icon
|
*.resx, app.ico string resources + icon
|
||||||
TeslaConsole.csproj net48 project
|
assets/icons/ UI images (raw originals, embedded as manifest resources —
|
||||||
|
the resx BinaryFormatter blobs cannot build for net40)
|
||||||
|
TeslaConsole.csproj net40 project (XP11: runs on XP SP3 through Windows 11)
|
||||||
RedPlanet/ runtime content (RPConfig.xml, RPStrings.xml) — copied to output
|
RedPlanet/ runtime content (RPConfig.xml, RPStrings.xml) — copied to output
|
||||||
images/ source art (pod art / maps / vehicles) — reference only
|
images/ source art (pod art / maps / vehicles) — reference only
|
||||||
installer_banner.bmp installer artwork — reference only
|
installer_banner.bmp installer artwork — reference only
|
||||||
@@ -84,14 +89,14 @@ TeslaConsole/
|
|||||||
## Building
|
## Building
|
||||||
|
|
||||||
Requirements: .NET SDK (6.0+) — the `Microsoft.NETFramework.ReferenceAssemblies`
|
Requirements: .NET SDK (6.0+) — the `Microsoft.NETFramework.ReferenceAssemblies`
|
||||||
NuGet package supplies the net48 reference assemblies, so a standalone Framework
|
NuGet package supplies the net40 reference assemblies, so a standalone Framework
|
||||||
targeting pack is **not** required.
|
targeting pack is **not** required.
|
||||||
|
|
||||||
```
|
```
|
||||||
dotnet build TeslaConsole.csproj -c Release
|
dotnet build TeslaConsole.csproj -c Release
|
||||||
```
|
```
|
||||||
|
|
||||||
Output: `bin/Release/net48/TeslaConsole.exe` (with `RedPlanet/` and all
|
Output: `bin/Release/net40/TeslaConsole.exe` (with `RedPlanet/` and all
|
||||||
dependency DLLs copied alongside it).
|
dependency DLLs copied alongside it).
|
||||||
|
|
||||||
## Runtime content
|
## Runtime content
|
||||||
|
|||||||
@@ -27,6 +27,15 @@
|
|||||||
|
|
||||||
To add a product, append a <Product> with one <Launch>. exe must live under
|
To add a product, append a <Product> with one <Launch>. exe must live under
|
||||||
the install directory the package extracts to (currently C:\Games\...).
|
the install directory the package extracts to (currently C:\Games\...).
|
||||||
|
|
||||||
|
Key convention: generate one fresh Guid for the product id; the first
|
||||||
|
<Launch> reuses it as its key, and each additional <Launch> increments the
|
||||||
|
LAST HEX DIGIT of that Guid (+1, +2, ..., wrapping F->0). Do NOT append
|
||||||
|
"-1"/"-2" to the string: keys are parsed as System.Guid and a suffixed
|
||||||
|
string silently collapses to Guid.Empty. Exception: Red Planet 4.11's LC/MR
|
||||||
|
keys predate the convention and are pinned to the original console's
|
||||||
|
hardcoded Guids (SiteManagement.RPLCAppGuid/RPMRAppGuid + the diff tests);
|
||||||
|
do not change them.
|
||||||
-->
|
-->
|
||||||
<AppCatalog>
|
<AppCatalog>
|
||||||
<Product id="7D241B1F-AB6D-4e08-9C20-12294E743D94"
|
<Product id="7D241B1F-AB6D-4e08-9C20-12294E743D94"
|
||||||
@@ -66,13 +75,13 @@
|
|||||||
args="-net 1501{res}"
|
args="-net 1501{res}"
|
||||||
autoRestart="true"
|
autoRestart="true"
|
||||||
hostType="GameClient" />
|
hostType="GameClient" />
|
||||||
<Launch key="D393711A-EDA0-48B2-82A0-89DF12B768AF"
|
<Launch key="F4C957FD-72F7-4C5F-8971-28095007E8D0"
|
||||||
displayName="BattleTech 4.11 LC"
|
displayName="BattleTech 4.11 LC"
|
||||||
exe="C:\Games\BT411\btl4.exe"
|
exe="C:\Games\BT411\btl4.exe"
|
||||||
args="-net 1501{res} -lc"
|
args="-net 1501{res} -lc"
|
||||||
autoRestart="true"
|
autoRestart="true"
|
||||||
hostType="LiveCamera" />
|
hostType="LiveCamera" />
|
||||||
<Launch key="2E9B8628-9C20-42FB-B070-E9C38D521082"
|
<Launch key="F4C957FD-72F7-4C5F-8971-28095007E8D1"
|
||||||
displayName="BattleTech 4.11 MR"
|
displayName="BattleTech 4.11 MR"
|
||||||
exe="C:\Games\BT411\btl4.exe"
|
exe="C:\Games\BT411\btl4.exe"
|
||||||
args="-net 1501{res} -mr"
|
args="-net 1501{res} -mr"
|
||||||
@@ -104,7 +113,7 @@
|
|||||||
name="RIOJoy"
|
name="RIOJoy"
|
||||||
menuText="RIOJoy..."
|
menuText="RIOJoy..."
|
||||||
hostTypeDialog="false">
|
hostTypeDialog="false">
|
||||||
<Launch key="FE83E212-45DF-48F9-848E-0B3CEE0692A3"
|
<Launch key="87FBC2E6-6359-4EF4-96A5-DF157823CFF6"
|
||||||
displayName="RIOJoy"
|
displayName="RIOJoy"
|
||||||
exe="C:\Games\RIOJoy\app\RioJoy.Tray.exe"
|
exe="C:\Games\RIOJoy\app\RioJoy.Tray.exe"
|
||||||
args=""
|
args=""
|
||||||
@@ -112,31 +121,52 @@
|
|||||||
hostType="None" />
|
hostType="None" />
|
||||||
</Product>
|
</Product>
|
||||||
|
|
||||||
<!-- vPOD - virtual pod / game-client stand-in for testing the game consoles
|
<!-- TeslaRel410 - the DOSBox-X preservation pods (C:\VWE\TeslaRel410 repo).
|
||||||
(Console\vPOD). Speaks Munga on 1501 like a real rpl4opt.exe/btl4.exe and
|
The package (emulator\dist\TeslaPod410.zip, built by deploy\package.ps1)
|
||||||
reports either ApplicationID (toggled live in its window). Deploy it to a
|
extracts to C:\Games (postinstall.bat + TeslaPod410\); pod-launch.exe is
|
||||||
pod exactly like a real client; the package (Console\vPOD\dist\vPOD.zip,
|
the single supervising entry point and the mode arg ("bt"/"rp") selects
|
||||||
built by pack.ps1) extracts to C:\Games\vPOD. -->
|
the game. Camera ship and live mission review are NOT separate modes in
|
||||||
<Product id="0041C870-6E5E-4F3B-9782-F94F2F76F21D"
|
4.10 (the console assigns the role per IP via the egg hostType), so the
|
||||||
name="vPOD (Virtual Pod)"
|
LC/MR entries boot identically and differ only by catalog role. No {res}
|
||||||
menuText="vPOD (Virtual Pod)..."
|
token: DOSBox output size is fixed per rig at install time. -->
|
||||||
|
<Product id="135019C7-2C2F-4C38-96BE-C7DB39994AB0"
|
||||||
|
name="TeslaRel410"
|
||||||
|
menuText="TeslaRel410..."
|
||||||
hostTypeDialog="true">
|
hostTypeDialog="true">
|
||||||
<Launch key="0041C870-6E5E-4F3B-9782-F94F2F76F21D"
|
<Launch key="135019C7-2C2F-4C38-96BE-C7DB39994AB0"
|
||||||
displayName="vPOD"
|
displayName="BT4.10"
|
||||||
exe="C:\Games\vPOD\vPOD.exe"
|
exe="C:\Games\TeslaPod410\pod-launch.exe"
|
||||||
args="-net 1501{res}"
|
args="bt"
|
||||||
autoRestart="true"
|
autoRestart="true"
|
||||||
hostType="GameClient" />
|
hostType="GameClient" />
|
||||||
<Launch key="EA0D4129-8950-428D-8399-E6A77D2D566A"
|
<Launch key="135019C7-2C2F-4C38-96BE-C7DB39994AB1"
|
||||||
displayName="vPOD LC"
|
displayName="BT4.10 LC"
|
||||||
exe="C:\Games\vPOD\vPOD.exe"
|
exe="C:\Games\TeslaPod410\pod-launch.exe"
|
||||||
args="-net 1501{res} -lc"
|
args="bt"
|
||||||
autoRestart="true"
|
autoRestart="true"
|
||||||
hostType="LiveCamera" />
|
hostType="LiveCamera" />
|
||||||
<Launch key="FC7CE34E-F4FE-4218-84CD-B13A6FA58E57"
|
<Launch key="135019C7-2C2F-4C38-96BE-C7DB39994AB2"
|
||||||
displayName="vPOD MR"
|
displayName="BT4.10 MR"
|
||||||
exe="C:\Games\vPOD\vPOD.exe"
|
exe="C:\Games\TeslaPod410\pod-launch.exe"
|
||||||
args="-net 1501{res} -mr"
|
args="bt"
|
||||||
|
autoRestart="true"
|
||||||
|
hostType="MissionReview" />
|
||||||
|
<Launch key="135019C7-2C2F-4C38-96BE-C7DB39994AB3"
|
||||||
|
displayName="RP4.10"
|
||||||
|
exe="C:\Games\TeslaPod410\pod-launch.exe"
|
||||||
|
args="rp"
|
||||||
|
autoRestart="true"
|
||||||
|
hostType="GameClient" />
|
||||||
|
<Launch key="135019C7-2C2F-4C38-96BE-C7DB39994AB4"
|
||||||
|
displayName="RP4.10 LC"
|
||||||
|
exe="C:\Games\TeslaPod410\pod-launch.exe"
|
||||||
|
args="rp"
|
||||||
|
autoRestart="true"
|
||||||
|
hostType="LiveCamera" />
|
||||||
|
<Launch key="135019C7-2C2F-4C38-96BE-C7DB39994AB5"
|
||||||
|
displayName="RP4.10 MR"
|
||||||
|
exe="C:\Games\TeslaPod410\pod-launch.exe"
|
||||||
|
args="rp"
|
||||||
autoRestart="true"
|
autoRestart="true"
|
||||||
hostType="MissionReview" />
|
hostType="MissionReview" />
|
||||||
</Product>
|
</Product>
|
||||||
|
|||||||
@@ -283,6 +283,14 @@ internal static class BTDefaults
|
|||||||
|
|
||||||
public static BattleDefaults NoReturn => sNoReturn;
|
public static BattleDefaults NoReturn => sNoReturn;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, the BattleTech game pages target each pod's DOSBox-X guest
|
||||||
|
/// (last octet +100) instead of the pod itself. A machine-level setting for
|
||||||
|
/// the emulator preservation pods, so it is stored once per defaults file
|
||||||
|
/// rather than per mode.
|
||||||
|
/// </summary>
|
||||||
|
public static bool DosBoxAddressShift { get; set; }
|
||||||
|
|
||||||
static BTDefaults()
|
static BTDefaults()
|
||||||
{
|
{
|
||||||
sDefaultsFilePath = Path.Combine(Program.GetCommonAppDataDirectory(), "BTDefaults.btd");
|
sDefaultsFilePath = Path.Combine(Program.GetCommonAppDataDirectory(), "BTDefaults.btd");
|
||||||
@@ -315,6 +323,14 @@ internal static class BTDefaults
|
|||||||
case "NoReturnDefaults":
|
case "NoReturnDefaults":
|
||||||
sNoReturn.Parse(childNode);
|
sNoReturn.Parse(childNode);
|
||||||
break;
|
break;
|
||||||
|
case "DosBoxAddressShift":
|
||||||
|
{
|
||||||
|
if (bool.TryParse(childNode.InnerText, out var result))
|
||||||
|
{
|
||||||
|
DosBoxAddressShift = result;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,6 +353,7 @@ internal static class BTDefaults
|
|||||||
xmlDocument.AppendChild(xmlDocument.CreateElement("BTDefaults"));
|
xmlDocument.AppendChild(xmlDocument.CreateElement("BTDefaults"));
|
||||||
sFreeForAll.Save(xmlDocument.DocumentElement);
|
sFreeForAll.Save(xmlDocument.DocumentElement);
|
||||||
sNoReturn.Save(xmlDocument.DocumentElement);
|
sNoReturn.Save(xmlDocument.DocumentElement);
|
||||||
|
xmlDocument.DocumentElement.AppendChild(xmlDocument.CreateElement("DosBoxAddressShift")).InnerText = DosBoxAddressShift.ToString();
|
||||||
string directoryName = Path.GetDirectoryName(filePath);
|
string directoryName = Path.GetDirectoryName(filePath);
|
||||||
if (!Directory.Exists(directoryName))
|
if (!Directory.Exists(directoryName))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ public class BTDefaultsDialog : Form
|
|||||||
|
|
||||||
private NumericUpDown mNoReturnLaunchDelay;
|
private NumericUpDown mNoReturnLaunchDelay;
|
||||||
|
|
||||||
|
private Label mDosBoxLabel;
|
||||||
|
|
||||||
|
private CheckBox mDosBoxShift;
|
||||||
|
|
||||||
private TableLayoutPanel mButtonsTable;
|
private TableLayoutPanel mButtonsTable;
|
||||||
|
|
||||||
private Button mCancel;
|
private Button mCancel;
|
||||||
@@ -124,6 +128,7 @@ public class BTDefaultsDialog : Form
|
|||||||
BTScenario scenario = BTConfig.FreeForAll;
|
BTScenario scenario = BTConfig.FreeForAll;
|
||||||
BuildColumn(scenario, BTDefaults.FreeForAll, mFfaMap, mFfaWeather, mFfaTimeOfDay, mFfaAdvancedDamage, mFfaMissionLength, mFfaVehicle, mFfaCamo, mFfaPatch, mFfaBadge, mFfaExperience, mFfaLaunchDelay);
|
BuildColumn(scenario, BTDefaults.FreeForAll, mFfaMap, mFfaWeather, mFfaTimeOfDay, mFfaAdvancedDamage, mFfaMissionLength, mFfaVehicle, mFfaCamo, mFfaPatch, mFfaBadge, mFfaExperience, mFfaLaunchDelay);
|
||||||
BuildColumn(scenario, BTDefaults.NoReturn, mNoReturnMap, mNoReturnWeather, mNoReturnTimeOfDay, mNoReturnAdvancedDamage, mNoReturnMissionLength, mNoReturnVehicle, mNoReturnCamo, mNoReturnPatch, mNoReturnBadge, mNoReturnExperience, mNoReturnLaunchDelay);
|
BuildColumn(scenario, BTDefaults.NoReturn, mNoReturnMap, mNoReturnWeather, mNoReturnTimeOfDay, mNoReturnAdvancedDamage, mNoReturnMissionLength, mNoReturnVehicle, mNoReturnCamo, mNoReturnPatch, mNoReturnBadge, mNoReturnExperience, mNoReturnLaunchDelay);
|
||||||
|
mDosBoxShift.Checked = BTDefaults.DosBoxAddressShift;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void BuildColumn(BTScenario scenario, BTDefaults.BattleDefaults defaults, ComboBox map, ComboBox weather, ComboBox timeOfDay, ComboBox advancedDamage, MaskedTextBox missionLength, ComboBox vehicle, ComboBox camo, ComboBox patch, ComboBox badge, ComboBox experience, NumericUpDown launchDelay)
|
private static void BuildColumn(BTScenario scenario, BTDefaults.BattleDefaults defaults, ComboBox map, ComboBox weather, ComboBox timeOfDay, ComboBox advancedDamage, MaskedTextBox missionLength, ComboBox vehicle, ComboBox camo, ComboBox patch, ComboBox badge, ComboBox experience, NumericUpDown launchDelay)
|
||||||
@@ -208,6 +213,7 @@ public class BTDefaultsDialog : Form
|
|||||||
{
|
{
|
||||||
SaveColumn(BTDefaults.FreeForAll, mFfaMap, mFfaWeather, mFfaTimeOfDay, mFfaAdvancedDamage, mFfaMissionLength, mFfaVehicle, mFfaCamo, mFfaPatch, mFfaBadge, mFfaExperience, mFfaLaunchDelay);
|
SaveColumn(BTDefaults.FreeForAll, mFfaMap, mFfaWeather, mFfaTimeOfDay, mFfaAdvancedDamage, mFfaMissionLength, mFfaVehicle, mFfaCamo, mFfaPatch, mFfaBadge, mFfaExperience, mFfaLaunchDelay);
|
||||||
SaveColumn(BTDefaults.NoReturn, mNoReturnMap, mNoReturnWeather, mNoReturnTimeOfDay, mNoReturnAdvancedDamage, mNoReturnMissionLength, mNoReturnVehicle, mNoReturnCamo, mNoReturnPatch, mNoReturnBadge, mNoReturnExperience, mNoReturnLaunchDelay);
|
SaveColumn(BTDefaults.NoReturn, mNoReturnMap, mNoReturnWeather, mNoReturnTimeOfDay, mNoReturnAdvancedDamage, mNoReturnMissionLength, mNoReturnVehicle, mNoReturnCamo, mNoReturnPatch, mNoReturnBadge, mNoReturnExperience, mNoReturnLaunchDelay);
|
||||||
|
BTDefaults.DosBoxAddressShift = mDosBoxShift.Checked;
|
||||||
BTDefaults.Save();
|
BTDefaults.Save();
|
||||||
Hide();
|
Hide();
|
||||||
}
|
}
|
||||||
@@ -274,6 +280,8 @@ public class BTDefaultsDialog : Form
|
|||||||
this.mNoReturnExperience = new System.Windows.Forms.ComboBox();
|
this.mNoReturnExperience = new System.Windows.Forms.ComboBox();
|
||||||
this.mFfaLaunchDelay = new System.Windows.Forms.NumericUpDown();
|
this.mFfaLaunchDelay = new System.Windows.Forms.NumericUpDown();
|
||||||
this.mNoReturnLaunchDelay = new System.Windows.Forms.NumericUpDown();
|
this.mNoReturnLaunchDelay = new System.Windows.Forms.NumericUpDown();
|
||||||
|
this.mDosBoxLabel = new System.Windows.Forms.Label();
|
||||||
|
this.mDosBoxShift = new System.Windows.Forms.CheckBox();
|
||||||
this.mButtonsTable = new System.Windows.Forms.TableLayoutPanel();
|
this.mButtonsTable = new System.Windows.Forms.TableLayoutPanel();
|
||||||
this.mCancel = new System.Windows.Forms.Button();
|
this.mCancel = new System.Windows.Forms.Button();
|
||||||
this.mOK = new System.Windows.Forms.Button();
|
this.mOK = new System.Windows.Forms.Button();
|
||||||
@@ -321,17 +329,19 @@ public class BTDefaultsDialog : Form
|
|||||||
this.mMainTable.Controls.Add(this.mLaunchDelayLabel, 0, 11);
|
this.mMainTable.Controls.Add(this.mLaunchDelayLabel, 0, 11);
|
||||||
this.mMainTable.Controls.Add(this.mFfaLaunchDelay, 1, 11);
|
this.mMainTable.Controls.Add(this.mFfaLaunchDelay, 1, 11);
|
||||||
this.mMainTable.Controls.Add(this.mNoReturnLaunchDelay, 2, 11);
|
this.mMainTable.Controls.Add(this.mNoReturnLaunchDelay, 2, 11);
|
||||||
this.mMainTable.Controls.Add(this.mButtonsTable, 0, 12);
|
this.mMainTable.Controls.Add(this.mDosBoxLabel, 0, 12);
|
||||||
|
this.mMainTable.Controls.Add(this.mDosBoxShift, 1, 12);
|
||||||
|
this.mMainTable.Controls.Add(this.mButtonsTable, 0, 13);
|
||||||
this.mMainTable.Dock = System.Windows.Forms.DockStyle.Fill;
|
this.mMainTable.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||||
this.mMainTable.Location = new System.Drawing.Point(3, 3);
|
this.mMainTable.Location = new System.Drawing.Point(3, 3);
|
||||||
this.mMainTable.Name = "mMainTable";
|
this.mMainTable.Name = "mMainTable";
|
||||||
this.mMainTable.RowCount = 13;
|
this.mMainTable.RowCount = 14;
|
||||||
for (int i = 0; i < 12; i++)
|
for (int i = 0; i < 13; i++)
|
||||||
{
|
{
|
||||||
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||||
}
|
}
|
||||||
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100f));
|
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100f));
|
||||||
this.mMainTable.Size = new System.Drawing.Size(478, 384);
|
this.mMainTable.Size = new System.Drawing.Size(478, 414);
|
||||||
this.mMainTable.TabIndex = 0;
|
this.mMainTable.TabIndex = 0;
|
||||||
this.mFfaHeaderLabel.Anchor = System.Windows.Forms.AnchorStyles.None;
|
this.mFfaHeaderLabel.Anchor = System.Windows.Forms.AnchorStyles.None;
|
||||||
this.mFfaHeaderLabel.AutoSize = true;
|
this.mFfaHeaderLabel.AutoSize = true;
|
||||||
@@ -352,6 +362,7 @@ public class BTDefaultsDialog : Form
|
|||||||
ConfigureRowLabel(this.mBadgeLabel, "mBadgeLabel", "Badge:");
|
ConfigureRowLabel(this.mBadgeLabel, "mBadgeLabel", "Badge:");
|
||||||
ConfigureRowLabel(this.mExperienceLabel, "mExperienceLabel", "Experience:");
|
ConfigureRowLabel(this.mExperienceLabel, "mExperienceLabel", "Experience:");
|
||||||
ConfigureRowLabel(this.mLaunchDelayLabel, "mLaunchDelayLabel", "Autotranslocate Delay:");
|
ConfigureRowLabel(this.mLaunchDelayLabel, "mLaunchDelayLabel", "Autotranslocate Delay:");
|
||||||
|
ConfigureRowLabel(this.mDosBoxLabel, "mDosBoxLabel", "DOSBox Build:");
|
||||||
ConfigureOptionCombo(this.mFfaMap, "mFfaMap");
|
ConfigureOptionCombo(this.mFfaMap, "mFfaMap");
|
||||||
ConfigureOptionCombo(this.mNoReturnMap, "mNoReturnMap");
|
ConfigureOptionCombo(this.mNoReturnMap, "mNoReturnMap");
|
||||||
ConfigureOptionCombo(this.mFfaWeather, "mFfaWeather");
|
ConfigureOptionCombo(this.mFfaWeather, "mFfaWeather");
|
||||||
@@ -378,6 +389,12 @@ public class BTDefaultsDialog : Form
|
|||||||
this.mFfaLaunchDelay.Name = "mFfaLaunchDelay";
|
this.mFfaLaunchDelay.Name = "mFfaLaunchDelay";
|
||||||
this.mNoReturnLaunchDelay.Dock = System.Windows.Forms.DockStyle.Fill;
|
this.mNoReturnLaunchDelay.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||||
this.mNoReturnLaunchDelay.Name = "mNoReturnLaunchDelay";
|
this.mNoReturnLaunchDelay.Name = "mNoReturnLaunchDelay";
|
||||||
|
this.mMainTable.SetColumnSpan(this.mDosBoxShift, 2);
|
||||||
|
this.mDosBoxShift.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||||
|
this.mDosBoxShift.AutoSize = true;
|
||||||
|
this.mDosBoxShift.Name = "mDosBoxShift";
|
||||||
|
this.mDosBoxShift.Text = "Shift all pod IPs +100 (DOSBox-X preservation build)";
|
||||||
|
this.mDosBoxShift.UseVisualStyleBackColor = true;
|
||||||
this.mButtonsTable.ColumnCount = 2;
|
this.mButtonsTable.ColumnCount = 2;
|
||||||
this.mMainTable.SetColumnSpan(this.mButtonsTable, 3);
|
this.mMainTable.SetColumnSpan(this.mButtonsTable, 3);
|
||||||
this.mButtonsTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100f));
|
this.mButtonsTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100f));
|
||||||
@@ -408,7 +425,7 @@ public class BTDefaultsDialog : Form
|
|||||||
base.AcceptButton = this.mOK;
|
base.AcceptButton = this.mOK;
|
||||||
base.AutoScaleDimensions = new System.Drawing.SizeF(6f, 13f);
|
base.AutoScaleDimensions = new System.Drawing.SizeF(6f, 13f);
|
||||||
base.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
base.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||||
base.ClientSize = new System.Drawing.Size(484, 390);
|
base.ClientSize = new System.Drawing.Size(484, 420);
|
||||||
base.Controls.Add(this.mMainTable);
|
base.Controls.Add(this.mMainTable);
|
||||||
base.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
|
base.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
|
||||||
base.MaximizeBox = false;
|
base.MaximizeBox = false;
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ internal class BTGame : DockContent
|
|||||||
|
|
||||||
private Timer tmrAutoTranslocate;
|
private Timer tmrAutoTranslocate;
|
||||||
|
|
||||||
|
private readonly bool mDosBoxAddressShift = BTDefaults.DosBoxAddressShift;
|
||||||
|
|
||||||
private string GameStatusText
|
private string GameStatusText
|
||||||
{
|
{
|
||||||
set
|
set
|
||||||
@@ -394,6 +396,11 @@ internal class BTGame : DockContent
|
|||||||
return ((KeyValuePair<string, string>)cell.Value).Key;
|
return ((KeyValuePair<string, string>)cell.Value).Key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string MissionAddress(Pod pod)
|
||||||
|
{
|
||||||
|
return (mDosBoxAddressShift ? DosBox.ShiftAddress(pod.IPAddress) : pod.IPAddress).ToString();
|
||||||
|
}
|
||||||
|
|
||||||
private void NetworkScan(object sender, EventArgs e)
|
private void NetworkScan(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (mCurrentState == BTGameState.Run && mRequestedState == BTGameState.Run && !mProcessingMissionEndStateChangeReq)
|
if (mCurrentState == BTGameState.Run && mRequestedState == BTGameState.Run && !mProcessingMissionEndStateChangeReq)
|
||||||
@@ -747,14 +754,14 @@ internal class BTGame : DockContent
|
|||||||
if (pod.HostType == HostType.GameMachineHostType)
|
if (pod.HostType == HostType.GameMachineHostType)
|
||||||
{
|
{
|
||||||
string value = (string)item.Cells[mPilotColumn.Index].Value;
|
string value = (string)item.Cells[mPilotColumn.Index].Value;
|
||||||
BTPlayer bTPlayer = new BTPlayer(pod.IPAddress.ToString(), value, (item.Cells[mVehicleColumn.Index].Value is string) ? ((string)item.Cells[mVehicleColumn.Index].Value) : ((BTVehicle)item.Cells[mVehicleColumn.Index].Value).Key, GetStringOrKey(item.Cells[mColorColumn.Index]), GetStringOrKey(item.Cells[mPatchColumn.Index]), GetStringOrKey(item.Cells[mBadgeColumn.Index]), GetStringOrKey(item.Cells[mExperienceColumn.Index]), role, mAdvancedDamage.Checked);
|
BTPlayer bTPlayer = new BTPlayer(MissionAddress(pod), value, (item.Cells[mVehicleColumn.Index].Value is string) ? ((string)item.Cells[mVehicleColumn.Index].Value) : ((BTVehicle)item.Cells[mVehicleColumn.Index].Value).Key, GetStringOrKey(item.Cells[mColorColumn.Index]), GetStringOrKey(item.Cells[mPatchColumn.Index]), GetStringOrKey(item.Cells[mBadgeColumn.Index]), GetStringOrKey(item.Cells[mExperienceColumn.Index]), role, mAdvancedDamage.Checked);
|
||||||
bTMission.BattlePlayers.Add(bTPlayer);
|
bTMission.BattlePlayers.Add(bTPlayer);
|
||||||
mMissionPlayers.Add(num++, bTPlayer);
|
mMissionPlayers.Add(num++, bTPlayer);
|
||||||
PilotNameCache.PilotNames.Add(value);
|
PilotNameCache.PilotNames.Add(value);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
bTMission.Cameras.Add(new BTCamera(pod.IPAddress.ToString(), pod.HostType));
|
bTMission.Cameras.Add(new BTCamera(MissionAddress(pod), pod.HostType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mEggFileMessages = bTMission.ToEggFileMessages();
|
mEggFileMessages = bTMission.ToEggFileMessages();
|
||||||
@@ -1077,6 +1084,7 @@ internal class BTGame : DockContent
|
|||||||
{
|
{
|
||||||
if (flag.Value)
|
if (flag.Value)
|
||||||
{
|
{
|
||||||
|
((Pod)dataGridViewRow.Tag).MungaGame.DosBoxAddressShift = mDosBoxAddressShift;
|
||||||
((Pod)dataGridViewRow.Tag).MungaGame.MakeRequested(this);
|
((Pod)dataGridViewRow.Tag).MungaGame.MakeRequested(this);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using System.CodeDom.Compiler;
|
using System.CodeDom.Compiler;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
using System.Resources;
|
using System.Resources;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
@@ -17,6 +19,15 @@ internal class Resources
|
|||||||
|
|
||||||
private static CultureInfo resourceCulture;
|
private static CultureInfo resourceCulture;
|
||||||
|
|
||||||
|
// XP11: the images that used to live in the .resx as BinaryFormatter blobs now
|
||||||
|
// ship as raw embedded files (logical name "TeslaConsole.Icons.<file>", original
|
||||||
|
// bytes — see Console\assets\icons\). Building those blobs requires
|
||||||
|
// System.Resources.Extensions' DeserializingResourceReader at runtime, which is
|
||||||
|
// net461+ and cannot load on the XP-compatible net40. Strings stay in the .resx.
|
||||||
|
// Instances are cached like ResourceManager.GetObject cached them: one shared
|
||||||
|
// object per name.
|
||||||
|
private static readonly Dictionary<string, object> imageCache = new Dictionary<string, object>();
|
||||||
|
|
||||||
[EditorBrowsable(EditorBrowsableState.Advanced)]
|
[EditorBrowsable(EditorBrowsableState.Advanced)]
|
||||||
internal static ResourceManager ResourceManager
|
internal static ResourceManager ResourceManager
|
||||||
{
|
{
|
||||||
@@ -43,262 +54,108 @@ internal class Resources
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static Bitmap Add
|
internal static Bitmap EmbeddedBitmap(string file)
|
||||||
{
|
{
|
||||||
get
|
lock (imageCache)
|
||||||
{
|
{
|
||||||
object @object = ResourceManager.GetObject("Add", resourceCulture);
|
object image;
|
||||||
return (Bitmap)@object;
|
if (!imageCache.TryGetValue(file, out image))
|
||||||
|
{
|
||||||
|
// Deliberately not disposed: GDI+ decodes lazily (e.g. the animated
|
||||||
|
// GIF's frames), so the stream must outlive the Bitmap. It is an
|
||||||
|
// UnmanagedMemoryStream over the mapped assembly image — no handle.
|
||||||
|
Stream stream = typeof(Resources).Assembly.GetManifestResourceStream("TeslaConsole.Icons." + file);
|
||||||
|
image = new Bitmap(stream);
|
||||||
|
imageCache.Add(file, image);
|
||||||
|
}
|
||||||
|
return (Bitmap)image;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static Bitmap Blank
|
internal static Icon EmbeddedIcon(string file)
|
||||||
{
|
{
|
||||||
get
|
lock (imageCache)
|
||||||
{
|
{
|
||||||
object @object = ResourceManager.GetObject("Blank", resourceCulture);
|
object icon;
|
||||||
return (Bitmap)@object;
|
if (!imageCache.TryGetValue(file, out icon))
|
||||||
|
{
|
||||||
|
using (Stream stream = typeof(Resources).Assembly.GetManifestResourceStream("TeslaConsole.Icons." + file))
|
||||||
|
{
|
||||||
|
icon = new Icon(stream);
|
||||||
|
}
|
||||||
|
imageCache.Add(file, icon);
|
||||||
|
}
|
||||||
|
return (Icon)icon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static Bitmap DeleteHS
|
internal static Bitmap Add => EmbeddedBitmap("Add.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("DeleteHS", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap Error
|
internal static Bitmap Blank => EmbeddedBitmap("Blank.png");
|
||||||
{
|
|
||||||
get
|
internal static Bitmap DeleteHS => EmbeddedBitmap("DeleteHS.png");
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("Error", resourceCulture);
|
internal static Bitmap Error => EmbeddedBitmap("Error.bmp");
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string ErrorReportsDir => ResourceManager.GetString("ErrorReportsDir", resourceCulture);
|
internal static string ErrorReportsDir => ResourceManager.GetString("ErrorReportsDir", resourceCulture);
|
||||||
|
|
||||||
internal static Bitmap install
|
internal static Bitmap install => EmbeddedBitmap("install.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("install", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap openHS
|
internal static Bitmap openHS => EmbeddedBitmap("openHS.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("openHS", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap Play
|
internal static Bitmap Play => EmbeddedBitmap("Play.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("Play", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap PodBad16
|
internal static Bitmap PodBad16 => EmbeddedBitmap("PodBad16.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodBad16", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap PodBad32
|
internal static Bitmap PodBad32 => EmbeddedBitmap("PodBad32.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodBad32", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string PodBadImageKey => ResourceManager.GetString("PodBadImageKey", resourceCulture);
|
internal static string PodBadImageKey => ResourceManager.GetString("PodBadImageKey", resourceCulture);
|
||||||
|
|
||||||
internal static Bitmap PodGo16
|
internal static Bitmap PodGo16 => EmbeddedBitmap("PodGo16.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodGo16", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap PodGo32
|
internal static Bitmap PodGo32 => EmbeddedBitmap("PodGo32.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodGo32", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string PodGoImageKey => ResourceManager.GetString("PodGoImageKey", resourceCulture);
|
internal static string PodGoImageKey => ResourceManager.GetString("PodGoImageKey", resourceCulture);
|
||||||
|
|
||||||
internal static Bitmap PodOffline16
|
internal static Bitmap PodOffline16 => EmbeddedBitmap("PodOffline16.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodOffline16", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap PodOffline32
|
internal static Bitmap PodOffline32 => EmbeddedBitmap("PodOffline32.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodOffline32", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string PodOfflineImageKey => ResourceManager.GetString("PodOfflineImageKey", resourceCulture);
|
internal static string PodOfflineImageKey => ResourceManager.GetString("PodOfflineImageKey", resourceCulture);
|
||||||
|
|
||||||
internal static Bitmap PodOfflineQuestion32
|
internal static Bitmap PodOfflineQuestion32 => EmbeddedBitmap("PodOfflineQuestion32.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodOfflineQuestion32", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap PodOnline16
|
internal static Bitmap PodOnline16 => EmbeddedBitmap("PodOnline16.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodOnline16", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap PodOnline32
|
internal static Bitmap PodOnline32 => EmbeddedBitmap("PodOnline32.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodOnline32", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string PodOnlineImageKey => ResourceManager.GetString("PodOnlineImageKey", resourceCulture);
|
internal static string PodOnlineImageKey => ResourceManager.GetString("PodOnlineImageKey", resourceCulture);
|
||||||
|
|
||||||
internal static Bitmap PodQuestion16
|
internal static Bitmap PodQuestion16 => EmbeddedBitmap("PodQuestion16.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodQuestion16", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap PodQuestion32
|
internal static Bitmap PodQuestion32 => EmbeddedBitmap("PodQuestion32.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodQuestion32", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string PodQuestionImageKey => ResourceManager.GetString("PodQuestionImageKey", resourceCulture);
|
internal static string PodQuestionImageKey => ResourceManager.GetString("PodQuestionImageKey", resourceCulture);
|
||||||
|
|
||||||
internal static Bitmap PodRun16
|
internal static Bitmap PodRun16 => EmbeddedBitmap("PodRun16.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodRun16", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap PodRun32
|
internal static Bitmap PodRun32 => EmbeddedBitmap("PodRun32.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("PodRun32", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string PodRunImageKey => ResourceManager.GetString("PodRunImageKey", resourceCulture);
|
internal static string PodRunImageKey => ResourceManager.GetString("PodRunImageKey", resourceCulture);
|
||||||
|
|
||||||
internal static Bitmap Power
|
internal static Bitmap Power => EmbeddedBitmap("Power.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("Power", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap RefreshDoc
|
internal static Bitmap RefreshDoc => EmbeddedBitmap("RefreshDoc.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("RefreshDoc", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap saveHS
|
internal static Bitmap saveHS => EmbeddedBitmap("saveHS.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("saveHS", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap square_throbber
|
internal static Bitmap square_throbber => EmbeddedBitmap("square_throbber.gif");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("square_throbber", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap Stop
|
internal static Bitmap Stop => EmbeddedBitmap("Stop.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("Stop", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Icon swirl
|
internal static Icon swirl => EmbeddedIcon("swirl.ico");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("swirl", resourceCulture);
|
|
||||||
return (Icon)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Bitmap WebRefreshHH
|
internal static Bitmap WebRefreshHH => EmbeddedBitmap("WebRefreshHH.png");
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
object @object = ResourceManager.GetObject("WebRefreshHH", resourceCulture);
|
|
||||||
return (Bitmap)@object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal Resources()
|
internal Resources()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -352,6 +352,14 @@ internal static class RPDefaults
|
|||||||
|
|
||||||
public static FootballDefaults Football => sFootball;
|
public static FootballDefaults Football => sFootball;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, the Red Planet game pages target each pod's DOSBox-X guest
|
||||||
|
/// (last octet +100) instead of the pod itself. A machine-level setting for
|
||||||
|
/// the emulator preservation pods, so it is stored once per defaults file
|
||||||
|
/// rather than per scenario.
|
||||||
|
/// </summary>
|
||||||
|
public static bool DosBoxAddressShift { get; set; }
|
||||||
|
|
||||||
static RPDefaults()
|
static RPDefaults()
|
||||||
{
|
{
|
||||||
sDefaultsFilePath = Path.Combine(Program.GetCommonAppDataDirectory(), "RPDefaults.rpd");
|
sDefaultsFilePath = Path.Combine(Program.GetCommonAppDataDirectory(), "RPDefaults.rpd");
|
||||||
@@ -384,6 +392,14 @@ internal static class RPDefaults
|
|||||||
case "FootballDefaults":
|
case "FootballDefaults":
|
||||||
sFootball.Parse(childNode);
|
sFootball.Parse(childNode);
|
||||||
break;
|
break;
|
||||||
|
case "DosBoxAddressShift":
|
||||||
|
{
|
||||||
|
if (bool.TryParse(childNode.InnerText, out var result))
|
||||||
|
{
|
||||||
|
DosBoxAddressShift = result;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,6 +422,7 @@ internal static class RPDefaults
|
|||||||
xmlDocument.AppendChild(xmlDocument.CreateElement("RPDefaults"));
|
xmlDocument.AppendChild(xmlDocument.CreateElement("RPDefaults"));
|
||||||
sDeathRace.Save(xmlDocument.DocumentElement);
|
sDeathRace.Save(xmlDocument.DocumentElement);
|
||||||
sFootball.Save(xmlDocument.DocumentElement);
|
sFootball.Save(xmlDocument.DocumentElement);
|
||||||
|
xmlDocument.DocumentElement.AppendChild(xmlDocument.CreateElement("DosBoxAddressShift")).InnerText = DosBoxAddressShift.ToString();
|
||||||
string directoryName = Path.GetDirectoryName(filePath);
|
string directoryName = Path.GetDirectoryName(filePath);
|
||||||
if (!Directory.Exists(directoryName))
|
if (!Directory.Exists(directoryName))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ public class RPDefaultsDialog : Form
|
|||||||
|
|
||||||
private NumericUpDown mFootballLaunchDelay;
|
private NumericUpDown mFootballLaunchDelay;
|
||||||
|
|
||||||
|
private Label mDosBoxLabel;
|
||||||
|
|
||||||
|
private CheckBox mDosBoxShift;
|
||||||
|
|
||||||
private static RPDefaultsDialog mSingletonDialog;
|
private static RPDefaultsDialog mSingletonDialog;
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
@@ -123,6 +127,8 @@ public class RPDefaultsDialog : Form
|
|||||||
this.label11 = new System.Windows.Forms.Label();
|
this.label11 = new System.Windows.Forms.Label();
|
||||||
this.mDeathraceLaunchDelay = new System.Windows.Forms.NumericUpDown();
|
this.mDeathraceLaunchDelay = new System.Windows.Forms.NumericUpDown();
|
||||||
this.mFootballLaunchDelay = new System.Windows.Forms.NumericUpDown();
|
this.mFootballLaunchDelay = new System.Windows.Forms.NumericUpDown();
|
||||||
|
this.mDosBoxLabel = new System.Windows.Forms.Label();
|
||||||
|
this.mDosBoxShift = new System.Windows.Forms.CheckBox();
|
||||||
this.mMainTable.SuspendLayout();
|
this.mMainTable.SuspendLayout();
|
||||||
this.mButtonsTable.SuspendLayout();
|
this.mButtonsTable.SuspendLayout();
|
||||||
((System.ComponentModel.ISupportInitialize)this.mDeathraceLaunchDelay).BeginInit();
|
((System.ComponentModel.ISupportInitialize)this.mDeathraceLaunchDelay).BeginInit();
|
||||||
@@ -141,7 +147,7 @@ public class RPDefaultsDialog : Form
|
|||||||
this.mMainTable.Controls.Add(this.label9, 0, 8);
|
this.mMainTable.Controls.Add(this.label9, 0, 8);
|
||||||
this.mMainTable.Controls.Add(this.label1, 1, 0);
|
this.mMainTable.Controls.Add(this.label1, 1, 0);
|
||||||
this.mMainTable.Controls.Add(this.label10, 2, 0);
|
this.mMainTable.Controls.Add(this.label10, 2, 0);
|
||||||
this.mMainTable.Controls.Add(this.mButtonsTable, 0, 10);
|
this.mMainTable.Controls.Add(this.mButtonsTable, 0, 11);
|
||||||
this.mMainTable.Controls.Add(this.label5, 0, 6);
|
this.mMainTable.Controls.Add(this.label5, 0, 6);
|
||||||
this.mMainTable.Controls.Add(this.mDeathRaceVehicle, 1, 6);
|
this.mMainTable.Controls.Add(this.mDeathRaceVehicle, 1, 6);
|
||||||
this.mMainTable.Controls.Add(this.mFootballVehicle, 2, 6);
|
this.mMainTable.Controls.Add(this.mFootballVehicle, 2, 6);
|
||||||
@@ -162,10 +168,13 @@ public class RPDefaultsDialog : Form
|
|||||||
this.mMainTable.Controls.Add(this.mFootballWeather, 2, 2);
|
this.mMainTable.Controls.Add(this.mFootballWeather, 2, 2);
|
||||||
this.mMainTable.Controls.Add(this.label11, 0, 9);
|
this.mMainTable.Controls.Add(this.label11, 0, 9);
|
||||||
this.mMainTable.Controls.Add(this.mDeathraceLaunchDelay, 1, 9);
|
this.mMainTable.Controls.Add(this.mDeathraceLaunchDelay, 1, 9);
|
||||||
|
this.mMainTable.Controls.Add(this.mDosBoxLabel, 0, 10);
|
||||||
|
this.mMainTable.Controls.Add(this.mDosBoxShift, 1, 10);
|
||||||
this.mMainTable.Dock = System.Windows.Forms.DockStyle.Fill;
|
this.mMainTable.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||||
this.mMainTable.Location = new System.Drawing.Point(3, 3);
|
this.mMainTable.Location = new System.Drawing.Point(3, 3);
|
||||||
this.mMainTable.Name = "mMainTable";
|
this.mMainTable.Name = "mMainTable";
|
||||||
this.mMainTable.RowCount = 11;
|
this.mMainTable.RowCount = 12;
|
||||||
|
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||||
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||||
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||||
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||||
@@ -177,7 +186,7 @@ public class RPDefaultsDialog : Form
|
|||||||
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||||
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||||
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100f));
|
this.mMainTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100f));
|
||||||
this.mMainTable.Size = new System.Drawing.Size(478, 298);
|
this.mMainTable.Size = new System.Drawing.Size(478, 328);
|
||||||
this.mMainTable.TabIndex = 0;
|
this.mMainTable.TabIndex = 0;
|
||||||
this.mFootballPosition.Anchor = System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
|
this.mFootballPosition.Anchor = System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
|
||||||
this.mFootballPosition.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
this.mFootballPosition.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||||
@@ -406,10 +415,26 @@ public class RPDefaultsDialog : Form
|
|||||||
this.mFootballLaunchDelay.Name = "mFootballLaunchDelay";
|
this.mFootballLaunchDelay.Name = "mFootballLaunchDelay";
|
||||||
this.mFootballLaunchDelay.Size = new System.Drawing.Size(173, 20);
|
this.mFootballLaunchDelay.Size = new System.Drawing.Size(173, 20);
|
||||||
this.mFootballLaunchDelay.TabIndex = 32;
|
this.mFootballLaunchDelay.TabIndex = 32;
|
||||||
|
this.mDosBoxLabel.Anchor = System.Windows.Forms.AnchorStyles.Right;
|
||||||
|
this.mDosBoxLabel.AutoSize = true;
|
||||||
|
this.mDosBoxLabel.Location = new System.Drawing.Point(35, 258);
|
||||||
|
this.mDosBoxLabel.Name = "mDosBoxLabel";
|
||||||
|
this.mDosBoxLabel.Size = new System.Drawing.Size(82, 13);
|
||||||
|
this.mDosBoxLabel.TabIndex = 29;
|
||||||
|
this.mDosBoxLabel.Text = "DOSBox Build:";
|
||||||
|
this.mMainTable.SetColumnSpan(this.mDosBoxShift, 2);
|
||||||
|
this.mDosBoxShift.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||||
|
this.mDosBoxShift.AutoSize = true;
|
||||||
|
this.mDosBoxShift.Location = new System.Drawing.Point(123, 256);
|
||||||
|
this.mDosBoxShift.Name = "mDosBoxShift";
|
||||||
|
this.mDosBoxShift.Size = new System.Drawing.Size(268, 17);
|
||||||
|
this.mDosBoxShift.TabIndex = 33;
|
||||||
|
this.mDosBoxShift.Text = "Shift all pod IPs +100 (DOSBox-X preservation build)";
|
||||||
|
this.mDosBoxShift.UseVisualStyleBackColor = true;
|
||||||
base.AcceptButton = this.mOK;
|
base.AcceptButton = this.mOK;
|
||||||
base.AutoScaleDimensions = new System.Drawing.SizeF(6f, 13f);
|
base.AutoScaleDimensions = new System.Drawing.SizeF(6f, 13f);
|
||||||
base.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
base.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||||
base.ClientSize = new System.Drawing.Size(484, 304);
|
base.ClientSize = new System.Drawing.Size(484, 334);
|
||||||
base.Controls.Add(this.mMainTable);
|
base.Controls.Add(this.mMainTable);
|
||||||
base.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
|
base.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
|
||||||
base.MaximizeBox = false;
|
base.MaximizeBox = false;
|
||||||
@@ -457,6 +482,7 @@ public class RPDefaultsDialog : Form
|
|||||||
BuildGenericOption(mFootballPosition, RPConfig.Football.Positions, RPDefaults.Football.PositionKey);
|
BuildGenericOption(mFootballPosition, RPConfig.Football.Positions, RPDefaults.Football.PositionKey);
|
||||||
BuildDelayControl(mDeathraceLaunchDelay, RPDefaults.DeathRace.LaunchDelay);
|
BuildDelayControl(mDeathraceLaunchDelay, RPDefaults.DeathRace.LaunchDelay);
|
||||||
BuildDelayControl(mFootballLaunchDelay, RPDefaults.Football.LaunchDelay);
|
BuildDelayControl(mFootballLaunchDelay, RPDefaults.Football.LaunchDelay);
|
||||||
|
mDosBoxShift.Checked = RPDefaults.DosBoxAddressShift;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void BuildScoreCompressionOptions(ComboBox comboBox, bool defaultValue)
|
private static void BuildScoreCompressionOptions(ComboBox comboBox, bool defaultValue)
|
||||||
@@ -577,6 +603,7 @@ public class RPDefaultsDialog : Form
|
|||||||
RPDefaults.Football.PositionKey = ExtractSelectedKey<string>(mFootballPosition);
|
RPDefaults.Football.PositionKey = ExtractSelectedKey<string>(mFootballPosition);
|
||||||
RPDefaults.DeathRace.LaunchDelay = (int)mDeathraceLaunchDelay.Value;
|
RPDefaults.DeathRace.LaunchDelay = (int)mDeathraceLaunchDelay.Value;
|
||||||
RPDefaults.Football.LaunchDelay = (int)mFootballLaunchDelay.Value;
|
RPDefaults.Football.LaunchDelay = (int)mFootballLaunchDelay.Value;
|
||||||
|
RPDefaults.DosBoxAddressShift = mDosBoxShift.Checked;
|
||||||
RPDefaults.Save();
|
RPDefaults.Save();
|
||||||
Hide();
|
Hide();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ public class RPGame : DockContent
|
|||||||
|
|
||||||
private Timer tmrAutoTranslocate;
|
private Timer tmrAutoTranslocate;
|
||||||
|
|
||||||
|
private readonly bool mDosBoxAddressShift = RPDefaults.DosBoxAddressShift;
|
||||||
|
|
||||||
private string GameStatusText
|
private string GameStatusText
|
||||||
{
|
{
|
||||||
set
|
set
|
||||||
@@ -421,6 +423,11 @@ public class RPGame : DockContent
|
|||||||
return ((KeyValuePair<string, string>)cell.Value).Key;
|
return ((KeyValuePair<string, string>)cell.Value).Key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string MissionAddress(Pod pod)
|
||||||
|
{
|
||||||
|
return (mDosBoxAddressShift ? DosBox.ShiftAddress(pod.IPAddress) : pod.IPAddress).ToString();
|
||||||
|
}
|
||||||
|
|
||||||
private void NetworkScan(object sender, EventArgs e)
|
private void NetworkScan(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (mCurrentState == RPGameState.Run && mRequestedState == RPGameState.Run && !mProcessingMissionEndStateChangeReq)
|
if (mCurrentState == RPGameState.Run && mRequestedState == RPGameState.Run && !mProcessingMissionEndStateChangeReq)
|
||||||
@@ -791,7 +798,7 @@ public class RPGame : DockContent
|
|||||||
{
|
{
|
||||||
RPTeam rPTeam = ((item.Cells[mColorColumn.Index].Value is RPTeam) ? ((RPTeam)item.Cells[mColorColumn.Index].Value) : RPConfig.Football.Teams[(string)item.Cells[mColorColumn.Index].Value]);
|
RPTeam rPTeam = ((item.Cells[mColorColumn.Index].Value is RPTeam) ? ((RPTeam)item.Cells[mColorColumn.Index].Value) : RPConfig.Football.Teams[(string)item.Cells[mColorColumn.Index].Value]);
|
||||||
string stringOrKey = GetStringOrKey(item.Cells[mBadgeColumn.Index]);
|
string stringOrKey = GetStringOrKey(item.Cells[mBadgeColumn.Index]);
|
||||||
RPFootballPlayer rPFootballPlayer = new RPFootballPlayer(pod.IPAddress.ToString(), value = (string)item.Cells[mPilotColumn.Index].Value, (item.Cells[mVehicleColumn.Index].Value is string) ? ((string)item.Cells[mVehicleColumn.Index].Value) : ((RPVehicle)item.Cells[mVehicleColumn.Index].Value).Key, rPTeam.Key, stringOrKey, (stringOrKey == "runner") ? rPTeam.RunnerColor : rPTeam.TeamColor);
|
RPFootballPlayer rPFootballPlayer = new RPFootballPlayer(MissionAddress(pod), value = (string)item.Cells[mPilotColumn.Index].Value, (item.Cells[mVehicleColumn.Index].Value is string) ? ((string)item.Cells[mVehicleColumn.Index].Value) : ((RPVehicle)item.Cells[mVehicleColumn.Index].Value).Key, rPTeam.Key, stringOrKey, (stringOrKey == "runner") ? rPTeam.RunnerColor : rPTeam.TeamColor);
|
||||||
if (!dictionary.ContainsKey(rPFootballPlayer.TeamKey))
|
if (!dictionary.ContainsKey(rPFootballPlayer.TeamKey))
|
||||||
{
|
{
|
||||||
dictionary[rPFootballPlayer.TeamKey] = new List<RPFootballPlayer>();
|
dictionary[rPFootballPlayer.TeamKey] = new List<RPFootballPlayer>();
|
||||||
@@ -800,7 +807,7 @@ public class RPGame : DockContent
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
RPRacePlayer rPRacePlayer = new RPRacePlayer(pod.IPAddress.ToString(), value = (string)item.Cells[mPilotColumn.Index].Value, (item.Cells[mVehicleColumn.Index].Value is string) ? ((string)item.Cells[mVehicleColumn.Index].Value) : ((RPVehicle)item.Cells[mVehicleColumn.Index].Value).Key, GetStringOrKey(item.Cells[mColorColumn.Index]), GetStringOrKey(item.Cells[mBadgeColumn.Index]));
|
RPRacePlayer rPRacePlayer = new RPRacePlayer(MissionAddress(pod), value = (string)item.Cells[mPilotColumn.Index].Value, (item.Cells[mVehicleColumn.Index].Value is string) ? ((string)item.Cells[mVehicleColumn.Index].Value) : ((RPVehicle)item.Cells[mVehicleColumn.Index].Value).Key, GetStringOrKey(item.Cells[mColorColumn.Index]), GetStringOrKey(item.Cells[mBadgeColumn.Index]));
|
||||||
((RPRaceMission)rPMission).RacePlayers.Add(rPRacePlayer);
|
((RPRaceMission)rPMission).RacePlayers.Add(rPRacePlayer);
|
||||||
mMissionPlayers.Add(num++, rPRacePlayer);
|
mMissionPlayers.Add(num++, rPRacePlayer);
|
||||||
}
|
}
|
||||||
@@ -808,7 +815,7 @@ public class RPGame : DockContent
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
rPMission.Cameras.Add(new RPCamera(pod.IPAddress.ToString(), pod.HostType));
|
rPMission.Cameras.Add(new RPCamera(MissionAddress(pod), pod.HostType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mFootballMode)
|
if (mFootballMode)
|
||||||
@@ -1187,6 +1194,7 @@ public class RPGame : DockContent
|
|||||||
{
|
{
|
||||||
if (flag.Value)
|
if (flag.Value)
|
||||||
{
|
{
|
||||||
|
((Pod)dataGridViewRow.Tag).MungaGame.DosBoxAddressShift = mDosBoxAddressShift;
|
||||||
((Pod)dataGridViewRow.Tag).MungaGame.MakeRequested(this);
|
((Pod)dataGridViewRow.Tag).MungaGame.MakeRequested(this);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
<AssemblyName>TeslaConsole</AssemblyName>
|
<AssemblyName>TeslaConsole</AssemblyName>
|
||||||
<GenerateAssemblyInfo>False</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>False</GenerateAssemblyInfo>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<UseWindowsForms>True</UseWindowsForms>
|
<!-- net40 (XP11): the newest .NET Framework that installs on Windows XP SP3;
|
||||||
<TargetFramework>net48</TargetFramework>
|
net40 assemblies load in-place on the 4.8 runtime in Win10/11, so this ONE
|
||||||
|
exe covers XP SP3 through Windows 11 — same deal as the Launcher. The
|
||||||
|
original console was net20, so the decompiled core needs nothing newer.
|
||||||
|
WinForms comes via plain framework references (UseWindowsForms is not
|
||||||
|
wired up for net40). -->
|
||||||
|
<TargetFramework>net40</TargetFramework>
|
||||||
<LangVersion>Preview</LangVersion>
|
<LangVersion>Preview</LangVersion>
|
||||||
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
||||||
<ApplicationIcon>app.ico</ApplicationIcon>
|
<ApplicationIcon>app.ico</ApplicationIcon>
|
||||||
@@ -12,17 +17,21 @@
|
|||||||
<!-- Decompiled from the original net20 TeslaConsole.exe; legacy WinForms 2.0 sources -->
|
<!-- Decompiled from the original net20 TeslaConsole.exe; legacy WinForms 2.0 sources -->
|
||||||
<Nullable>disable</Nullable>
|
<Nullable>disable</Nullable>
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
<!-- .resx files embed BinaryFormatter-serialized bitmaps (non-string resources) -->
|
|
||||||
<GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources>
|
|
||||||
<!-- CS0649: decompiled WinForms designer 'components' fields are never assigned -->
|
<!-- CS0649: decompiled WinForms designer 'components' fields are never assigned -->
|
||||||
<NoWarn>$(NoWarn);CS0649</NoWarn>
|
<NoWarn>$(NoWarn);CS0649</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- net48 reference assemblies so the project builds without a full targeting pack installed -->
|
<!-- .NET Framework reference assemblies so the project builds without a full targeting pack installed -->
|
||||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||||
<!-- Required to read the pre-serialized binary resources above -->
|
</ItemGroup>
|
||||||
<PackageReference Include="System.Resources.Extensions" Version="6.0.0" />
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- The UI images, embedded as their raw original bytes (extracted 1:1 from the
|
||||||
|
old .resx BinaryFormatter blobs). Loaded by Properties.Resources.EmbeddedBitmap/
|
||||||
|
EmbeddedIcon: the .resx blob route needs System.Resources.Extensions at
|
||||||
|
runtime, which is net461+ and cannot load on net40/XP. -->
|
||||||
|
<EmbeddedResource Include="assets\icons\*.*" LogicalName="TeslaConsole.Icons.%(Filename)%(Extension)" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -45,13 +54,11 @@
|
|||||||
<Compile Remove="tests\**" />
|
<Compile Remove="tests\**" />
|
||||||
<None Remove="tests\**" />
|
<None Remove="tests\**" />
|
||||||
<Content 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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Reference Include="System.Windows.Forms" />
|
||||||
|
<Reference Include="System.Drawing" />
|
||||||
<Reference Include="System.Configuration.Install" />
|
<Reference Include="System.Configuration.Install" />
|
||||||
<Reference Include="System.Xml" />
|
<Reference Include="System.Xml" />
|
||||||
<Reference Include="System.ServiceProcess" />
|
<Reference Include="System.ServiceProcess" />
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ public static class AppRegistry
|
|||||||
|
|
||||||
private static readonly Dictionary<Guid, ProductDefinition> mById = new Dictionary<Guid, ProductDefinition>();
|
private static readonly Dictionary<Guid, ProductDefinition> mById = new Dictionary<Guid, ProductDefinition>();
|
||||||
|
|
||||||
public static IReadOnlyList<ProductDefinition> Products => mProducts;
|
// IList, not IReadOnlyList: the read-only interfaces are net45+ and the
|
||||||
|
// XP11 console targets net40. Callers only enumerate/index it.
|
||||||
|
public static IList<ProductDefinition> Products => mProducts;
|
||||||
|
|
||||||
public static string CatalogPath =>
|
public static string CatalogPath =>
|
||||||
Path.Combine(Path.GetDirectoryName(Application.ExecutablePath), "RedPlanet\\Apps.xml");
|
Path.Combine(Path.GetDirectoryName(Application.ExecutablePath), "RedPlanet\\Apps.xml");
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace TeslaConsole;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Support for the DOSBox-X preservation pods. The emulator that runs the
|
||||||
|
/// original DOS build of a game is bridged onto the pod network with its last
|
||||||
|
/// octet enumerated 100 above the host pod's address, so when a game page is
|
||||||
|
/// driving the emulated version it must target host+100 instead of the
|
||||||
|
/// address stored in the site config. Only the game traffic shifts — the
|
||||||
|
/// launcher and provisioning services still run on the host itself.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DosBox
|
||||||
|
{
|
||||||
|
public const int AddressOffset = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the address with its last octet raised by <see cref="AddressOffset"/>.
|
||||||
|
/// The octet wraps modulo 256; real site configs keep host octets at or
|
||||||
|
/// below 155 so the shifted address stays inside the subnet.
|
||||||
|
/// </summary>
|
||||||
|
public static IPAddress ShiftAddress(IPAddress address)
|
||||||
|
{
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
bytes[bytes.Length - 1] = (byte)(bytes[bytes.Length - 1] + AddressOffset);
|
||||||
|
return new IPAddress(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ public class MungaGame
|
|||||||
|
|
||||||
private MungaConnectionState mState;
|
private MungaConnectionState mState;
|
||||||
|
|
||||||
|
private bool mDosBoxAddressShift;
|
||||||
|
|
||||||
private readonly List<object> mRequestors = new List<object>();
|
private readonly List<object> mRequestors = new List<object>();
|
||||||
|
|
||||||
private Form mOwner;
|
private Form mOwner;
|
||||||
@@ -109,6 +111,37 @@ public class MungaGame
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, the game connection targets the pod's DOSBox-X guest
|
||||||
|
/// (last octet +100) instead of the pod itself. Changing the value while
|
||||||
|
/// connected drops the connection; it reconnects at the new address as
|
||||||
|
/// long as the game is still requested.
|
||||||
|
/// </summary>
|
||||||
|
internal bool DosBoxAddressShift
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (mInternalLock)
|
||||||
|
{
|
||||||
|
return mDosBoxAddressShift;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set
|
||||||
|
{
|
||||||
|
lock (mInternalLock)
|
||||||
|
{
|
||||||
|
if (mDosBoxAddressShift != value)
|
||||||
|
{
|
||||||
|
mDosBoxAddressShift = value;
|
||||||
|
if (mState != MungaConnectionState.Disconnected)
|
||||||
|
{
|
||||||
|
Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsOwned
|
public bool IsOwned
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -262,7 +295,7 @@ public class MungaGame
|
|||||||
State = MungaConnectionState.Connecting;
|
State = MungaConnectionState.Connecting;
|
||||||
ShutdownSocket();
|
ShutdownSocket();
|
||||||
mSocket = new MungaSocket();
|
mSocket = new MungaSocket();
|
||||||
mSocket.BeginConnect(mPod.IPAddress, 1501, delegate(IAsyncResult ar)
|
mSocket.BeginConnect(mDosBoxAddressShift ? DosBox.ShiftAddress(mPod.IPAddress) : mPod.IPAddress, 1501, delegate(IAsyncResult ar)
|
||||||
{
|
{
|
||||||
lock (mInternalLock)
|
lock (mInternalLock)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -617,6 +617,39 @@ public class PodInfo : Component
|
|||||||
asyncControlState.asyncOp.PostOperationCompleted(installProductDelegates.OnCompleted, arg);
|
asyncControlState.asyncOp.PostOperationCompleted(installProductDelegates.OnCompleted, arg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the management connection if needed. A connection the launcher has
|
||||||
|
/// dropped still reports IsOpen (Connected is stale until an I/O fails), and
|
||||||
|
/// the launcher drops sessions idle for ~30s — easily hit while the operator
|
||||||
|
/// sits in the install dialogs — so probe an "open" connection with a Ping
|
||||||
|
/// and reconnect when it turns out to be dead.
|
||||||
|
/// </summary>
|
||||||
|
private void EnsureConnectionAlive()
|
||||||
|
{
|
||||||
|
if (mConnection.IsOpen)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mConnection.Ping(DateTime.Now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mConnection.Close();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mConnection.IsOpen)
|
||||||
|
{
|
||||||
|
mConnection.Open(new IPEndPoint(mPod.IPAddress, ManagePort), mPod.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void InstallProductWorker(string filePath, SendOrPostCallback progressCallback, AsyncOperation asyncOp, SendOrPostCallback completionMethodDelegate)
|
private void InstallProductWorker(string filePath, SendOrPostCallback progressCallback, AsyncOperation asyncOp, SendOrPostCallback completionMethodDelegate)
|
||||||
{
|
{
|
||||||
Exception ex = null;
|
Exception ex = null;
|
||||||
@@ -624,10 +657,7 @@ public class PodInfo : Component
|
|||||||
{
|
{
|
||||||
for (int i = 0; i < 3; i++)
|
for (int i = 0; i < 3; i++)
|
||||||
{
|
{
|
||||||
if (!mConnection.IsOpen)
|
EnsureConnectionAlive();
|
||||||
{
|
|
||||||
mConnection.Open(new IPEndPoint(mPod.IPAddress, 53290), mPod.Key);
|
|
||||||
}
|
|
||||||
Guid guid = mConnection.InstallProduct(filePath);
|
Guid guid = mConnection.InstallProduct(filePath);
|
||||||
if (guid == Guid.Empty)
|
if (guid == Guid.Empty)
|
||||||
{
|
{
|
||||||
@@ -709,10 +739,7 @@ public class PodInfo : Component
|
|||||||
Exception ex = null;
|
Exception ex = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!mConnection.IsOpen)
|
EnsureConnectionAlive();
|
||||||
{
|
|
||||||
mConnection.Open(new IPEndPoint(mPod.IPAddress, 53290), mPod.Key);
|
|
||||||
}
|
|
||||||
mConnection.UninstallApp(launchKey);
|
mConnection.UninstallApp(launchKey);
|
||||||
mAllApps.Expire();
|
mAllApps.Expire();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1138,12 +1138,27 @@ public class SiteManagement : Form
|
|||||||
{
|
{
|
||||||
Tuple<PodData, Guid> tuple = e.UserState as Tuple<PodData, Guid>;
|
Tuple<PodData, Guid> tuple = e.UserState as Tuple<PodData, Guid>;
|
||||||
PodData a = tuple.A;
|
PodData a = tuple.A;
|
||||||
|
Exception error = e.Error;
|
||||||
|
// Register the launch entries only when the transfer succeeded, and never
|
||||||
|
// let a pod that dropped mid-install take the console down (AddApp throws
|
||||||
|
// when the connection is gone).
|
||||||
|
if (error == null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
foreach (LaunchData appData in BuildLaunchData(tuple.B, a))
|
foreach (LaunchData appData in BuildLaunchData(tuple.B, a))
|
||||||
{
|
{
|
||||||
a.Conn.AddApp(appData);
|
a.Conn.AddApp(appData);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Program.LogException(ex, "Registering launch entries failed.");
|
||||||
|
error = ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
a.OperationInProgress = false;
|
a.OperationInProgress = false;
|
||||||
a.Error = e.Error;
|
a.Error = error;
|
||||||
dgvPods.InvalidateCell(colOperationProgress.Index, mPods.IndexOf(a));
|
dgvPods.InvalidateCell(colOperationProgress.Index, mPods.IndexOf(a));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ internal class SitePanel : DockContent
|
|||||||
this.tsbManageSite.Text = "Manage Site";
|
this.tsbManageSite.Text = "Manage Site";
|
||||||
this.tsbManageSite.Click += new System.EventHandler(tsbManageSite_Click);
|
this.tsbManageSite.Click += new System.EventHandler(tsbManageSite_Click);
|
||||||
this.mPowerDropDown.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[5] { this.mRestartAllPods, this.mShutdownAllPods, this.mPowerSeparator, this.mRestartAllCheckedPods, this.mShutdownAllCheckedPods });
|
this.mPowerDropDown.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[5] { this.mRestartAllPods, this.mShutdownAllPods, this.mPowerSeparator, this.mRestartAllCheckedPods, this.mShutdownAllCheckedPods });
|
||||||
this.mPowerDropDown.Image = (System.Drawing.Image)resources.GetObject("mPowerDropDown.Image");
|
this.mPowerDropDown.Image = TeslaConsole.Properties.Resources.EmbeddedBitmap("SitePanel.mPowerDropDown.Image.png");
|
||||||
this.mPowerDropDown.ImageTransparentColor = System.Drawing.Color.Magenta;
|
this.mPowerDropDown.ImageTransparentColor = System.Drawing.Color.Magenta;
|
||||||
this.mPowerDropDown.Name = "mPowerDropDown";
|
this.mPowerDropDown.Name = "mPowerDropDown";
|
||||||
this.mPowerDropDown.Size = new System.Drawing.Size(69, 22);
|
this.mPowerDropDown.Size = new System.Drawing.Size(69, 22);
|
||||||
@@ -302,7 +302,7 @@ internal class SitePanel : DockContent
|
|||||||
this.lblPodName.Text = "Mistress Quickly";
|
this.lblPodName.Text = "Mistress Quickly";
|
||||||
this.picRefresh.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
|
this.picRefresh.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
|
||||||
this.picRefresh.Cursor = System.Windows.Forms.Cursors.Hand;
|
this.picRefresh.Cursor = System.Windows.Forms.Cursors.Hand;
|
||||||
this.picRefresh.Image = (System.Drawing.Image)resources.GetObject("picRefresh.Image");
|
this.picRefresh.Image = TeslaConsole.Properties.Resources.EmbeddedBitmap("SitePanel.picRefresh.Image.png");
|
||||||
this.picRefresh.Location = new System.Drawing.Point(308, 3);
|
this.picRefresh.Location = new System.Drawing.Point(308, 3);
|
||||||
this.picRefresh.Name = "picRefresh";
|
this.picRefresh.Name = "picRefresh";
|
||||||
this.picRefresh.Size = new System.Drawing.Size(32, 32);
|
this.picRefresh.Size = new System.Drawing.Size(32, 32);
|
||||||
@@ -1516,7 +1516,11 @@ internal class SitePanel : DockContent
|
|||||||
for (int i = 0; i < rVolumeItems.Length; i++)
|
for (int i = 0; i < rVolumeItems.Length; i++)
|
||||||
{
|
{
|
||||||
mnuVolume.DropDownItems.Add(rVolumeItems[i]);
|
mnuVolume.DropDownItems.Add(rVolumeItems[i]);
|
||||||
((ToolStripMenuItem)mnuVolume.DropDownItems[i]).Checked = i + 1 == num / 10;
|
// Item i displays i*10 ("mute" at 0), so the reported level maps to
|
||||||
|
// index num/10 directly. The original console checked i+1 here — the
|
||||||
|
// mark sat one step below the actual volume, and mute (0) never got
|
||||||
|
// a mark at all. Original bug (4.11.3), fixed 2026-07-11.
|
||||||
|
((ToolStripMenuItem)mnuVolume.DropDownItems[i]).Checked = i == num / 10;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ public class TeslaConsoleForm : Form
|
|||||||
this.mRPPrintPreviewDialog.AutoScrollMinSize = new System.Drawing.Size(0, 0);
|
this.mRPPrintPreviewDialog.AutoScrollMinSize = new System.Drawing.Size(0, 0);
|
||||||
this.mRPPrintPreviewDialog.ClientSize = new System.Drawing.Size(400, 300);
|
this.mRPPrintPreviewDialog.ClientSize = new System.Drawing.Size(400, 300);
|
||||||
this.mRPPrintPreviewDialog.Enabled = true;
|
this.mRPPrintPreviewDialog.Enabled = true;
|
||||||
this.mRPPrintPreviewDialog.Icon = (System.Drawing.Icon)resources.GetObject("mRPPrintPreviewDialog.Icon");
|
this.mRPPrintPreviewDialog.Icon = TeslaConsole.Properties.Resources.EmbeddedIcon("TeslaConsoleForm.mRPPrintPreviewDialog.Icon.ico");
|
||||||
this.mRPPrintPreviewDialog.Name = "printPreviewDialog1";
|
this.mRPPrintPreviewDialog.Name = "printPreviewDialog1";
|
||||||
this.mRPPrintPreviewDialog.Visible = false;
|
this.mRPPrintPreviewDialog.Visible = false;
|
||||||
this.mRPPrintDialog.UseEXDialog = true;
|
this.mRPPrintDialog.UseEXDialog = true;
|
||||||
@@ -773,7 +773,7 @@ public class TeslaConsoleForm : Form
|
|||||||
base.ClientSize = new System.Drawing.Size(1168, 591);
|
base.ClientSize = new System.Drawing.Size(1168, 591);
|
||||||
base.Controls.Add(this.mMainDockPanel);
|
base.Controls.Add(this.mMainDockPanel);
|
||||||
base.Controls.Add(this.mMenu);
|
base.Controls.Add(this.mMenu);
|
||||||
base.Icon = (System.Drawing.Icon)resources.GetObject("$this.Icon");
|
base.Icon = TeslaConsole.Properties.Resources.EmbeddedIcon("TeslaConsoleForm.$this.Icon.ico");
|
||||||
base.IsMdiContainer = true;
|
base.IsMdiContainer = true;
|
||||||
base.MainMenuStrip = this.mMenu;
|
base.MainMenuStrip = this.mMenu;
|
||||||
base.Name = "TeslaConsoleForm";
|
base.Name = "TeslaConsoleForm";
|
||||||
|
|||||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 938 B |
|
After Width: | Height: | Size: 693 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 314 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 454 B |
|
After Width: | Height: | Size: 345 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 501 B |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 997 B |
|
After Width: | Height: | Size: 772 B |
|
After Width: | Height: | Size: 700 B |
|
After Width: | Height: | Size: 404 B |
|
After Width: | Height: | Size: 138 KiB |
@@ -2,9 +2,10 @@
|
|||||||
:: =============================================================================
|
:: =============================================================================
|
||||||
:: TeslaConsole - Build ^& Package
|
:: TeslaConsole - Build ^& Package
|
||||||
:: =============================================================================
|
:: =============================================================================
|
||||||
:: Publishes the console (net48, framework-dependent) into TeslaConsole\App and
|
:: Publishes the console (net40, framework-dependent - XP11: runs on XP SP3
|
||||||
:: assembles the installable package (App\ + install.bat) next to it. net48 is
|
:: through Windows 11) into TeslaConsole\App and assembles the installable
|
||||||
:: in-box on Windows 10/11, so the target control PC needs no runtime install.
|
:: package (App\ + install.bat) next to it. The 4.x runtime is in-box on
|
||||||
|
:: Windows 10/11; an XP-era control PC needs dotNetFx40_Full_x86_x64.exe once.
|
||||||
::
|
::
|
||||||
:: Requirements: .NET SDK (6.0+) to drive the build; internet for first restore.
|
:: Requirements: .NET SDK (6.0+) to drive the build; internet for first restore.
|
||||||
::
|
::
|
||||||
@@ -24,7 +25,7 @@ set ZIP=%ROOT%dist\TeslaConsole-pkg.zip
|
|||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ============================================================
|
echo ============================================================
|
||||||
echo TeslaConsole - Build ^& Package (net48, framework-dependent)
|
echo TeslaConsole - Build ^& Package (net40, framework-dependent)
|
||||||
echo Output : %BUILD_DIR%
|
echo Output : %BUILD_DIR%
|
||||||
echo ============================================================
|
echo ============================================================
|
||||||
echo.
|
echo.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ namespace TeslaConsole.DiffTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Locates the two assemblies under comparison:
|
/// Locates the two assemblies under comparison:
|
||||||
/// * Original - original/TeslaConsole.exe (the lost-source reference baseline)
|
/// * Original - original/TeslaConsole.exe (the lost-source reference baseline)
|
||||||
/// * Recovered - bin/Release/net48/TeslaConsole.exe (freshly built reconstruction)
|
/// * Recovered - bin/Release/net40/TeslaConsole.exe (freshly built reconstruction;
|
||||||
|
/// net40 since XP11 — loads fine in this net48 test host, both are CLR4)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AssemblyPaths
|
public static class AssemblyPaths
|
||||||
{
|
{
|
||||||
@@ -39,8 +40,8 @@ namespace TeslaConsole.DiffTests
|
|||||||
|
|
||||||
private static string FindRecoveredExe()
|
private static string FindRecoveredExe()
|
||||||
{
|
{
|
||||||
string release = Path.Combine(RepoRoot, "bin", "Release", "net48", "TeslaConsole.exe");
|
string release = Path.Combine(RepoRoot, "bin", "Release", "net40", "TeslaConsole.exe");
|
||||||
string debug = Path.Combine(RepoRoot, "bin", "Debug", "net48", "TeslaConsole.exe");
|
string debug = Path.Combine(RepoRoot, "bin", "Debug", "net40", "TeslaConsole.exe");
|
||||||
|
|
||||||
// Test whichever build is freshest, so a stale config never silently wins.
|
// Test whichever build is freshest, so a stale config never silently wins.
|
||||||
string best = null;
|
string best = null;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ namespace TeslaConsole.DiffTests
|
|||||||
Assert.Contains("TeslaConsole", _fx.Original.AssemblyFullName);
|
Assert.Contains("TeslaConsole", _fx.Original.AssemblyFullName);
|
||||||
Assert.Contains("TeslaConsole", _fx.Recovered.AssemblyFullName);
|
Assert.Contains("TeslaConsole", _fx.Recovered.AssemblyFullName);
|
||||||
Assert.Contains("4.11.3.37076", _fx.Original.AssemblyFullName);
|
Assert.Contains("4.11.3.37076", _fx.Original.AssemblyFullName);
|
||||||
Assert.Contains("4.11.4.1", _fx.Recovered.AssemblyFullName);
|
Assert.Contains("4.11.4.3", _fx.Recovered.AssemblyFullName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- RPStrings.GetTimeString: mm:ss formatting with 0.5s rounding ----
|
// ---- RPStrings.GetTimeString: mm:ss formatting with 0.5s rounding ----
|
||||||
|
|||||||
@@ -25,15 +25,15 @@ namespace TeslaConsole.DiffTests
|
|||||||
=> _fx.Recovered.Run("CatalogEntry", new[] { _catalog, launchKey, w, h });
|
=> _fx.Recovered.Run("CatalogEntry", new[] { _catalog, launchKey, w, h });
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Catalog_Has_Five_Products_And_Eleven_Entries()
|
public void Catalog_Has_Five_Products_And_Fourteen_Entries()
|
||||||
=> Assert.Equal("products=5;entries=11",
|
=> Assert.Equal("products=5;entries=14",
|
||||||
_fx.Recovered.Run("CatalogSummary", new[] { _catalog }));
|
_fx.Recovered.Run("CatalogSummary", new[] { _catalog }));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RioJoy_Matches_Expected()
|
public void RioJoy_Matches_Expected()
|
||||||
=> Assert.Equal(
|
=> Assert.Equal(
|
||||||
@"RIOJoy|fe83e212-45df-48f9-848e-0b3cee0692a3|C:\Games\RIOJoy\app\RioJoy.Tray.exe||C:\Games\RIOJoy\app|True",
|
@"RIOJoy|87fbc2e6-6359-4ef4-96a5-df157823cff6|C:\Games\RIOJoy\app\RioJoy.Tray.exe||C:\Games\RIOJoy\app|True",
|
||||||
Entry("FE83E212-45DF-48F9-848E-0B3CEE0692A3"));
|
Entry("87FBC2E6-6359-4EF4-96A5-DF157823CFF6"));
|
||||||
|
|
||||||
// Each expected string is "DisplayName|LaunchKey|Exe|Args|WorkingDirectory|AutoRestart"
|
// Each expected string is "DisplayName|LaunchKey|Exe|Args|WorkingDirectory|AutoRestart"
|
||||||
// and matches exactly what the old hardcoded PodInfo_InstallProductCompleted emitted.
|
// and matches exactly what the old hardcoded PodInfo_InstallProductCompleted emitted.
|
||||||
@@ -92,39 +92,60 @@ namespace TeslaConsole.DiffTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void BattleTech_LiveCamera_Matches_Expected()
|
public void BattleTech_LiveCamera_Matches_Expected()
|
||||||
=> Assert.Equal(
|
=> Assert.Equal(
|
||||||
@"BattleTech 4.11 LC|d393711a-eda0-48b2-82a0-89df12b768af|C:\Games\BT411\btl4.exe|-net 1501 -lc|C:\Games\BT411|True",
|
@"BattleTech 4.11 LC|f4c957fd-72f7-4c5f-8971-28095007e8d0|C:\Games\BT411\btl4.exe|-net 1501 -lc|C:\Games\BT411|True",
|
||||||
Entry("D393711A-EDA0-48B2-82A0-89DF12B768AF"));
|
Entry("F4C957FD-72F7-4C5F-8971-28095007E8D0"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BattleTech_MissionReview_Matches_Expected()
|
public void BattleTech_MissionReview_Matches_Expected()
|
||||||
=> Assert.Equal(
|
=> Assert.Equal(
|
||||||
@"BattleTech 4.11 MR|2e9b8628-9c20-42fb-b070-e9c38d521082|C:\Games\BT411\btl4.exe|-net 1501 -mr|C:\Games\BT411|True",
|
@"BattleTech 4.11 MR|f4c957fd-72f7-4c5f-8971-28095007e8d1|C:\Games\BT411\btl4.exe|-net 1501 -mr|C:\Games\BT411|True",
|
||||||
Entry("2E9B8628-9C20-42FB-B070-E9C38D521082"));
|
Entry("F4C957FD-72F7-4C5F-8971-28095007E8D1"));
|
||||||
|
|
||||||
// vPOD (Virtual Pod) — the game-client stand-in for testing the consoles.
|
// TeslaRel410 — the DOSBox-X preservation pods. All six entries launch
|
||||||
|
// pod-launch.exe; the mode arg ("bt"/"rp") selects the game, LC/MR boot
|
||||||
|
// identically (the console assigns the role via the egg hostType), and
|
||||||
|
// {res} is intentionally absent (output size is fixed per rig).
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VPod_GameClient_Matches_Expected()
|
public void Rel410_BT_GameClient_Matches_Expected()
|
||||||
=> Assert.Equal(
|
=> Assert.Equal(
|
||||||
@"vPOD|0041c870-6e5e-4f3b-9782-f94f2f76f21d|C:\Games\vPOD\vPOD.exe|-net 1501|C:\Games\vPOD|True",
|
@"BT4.10|135019c7-2c2f-4c38-96be-c7db39994ab0|C:\Games\TeslaPod410\pod-launch.exe|bt|C:\Games\TeslaPod410|True",
|
||||||
Entry("0041C870-6E5E-4F3B-9782-F94F2F76F21D"));
|
Entry("135019C7-2C2F-4C38-96BE-C7DB39994AB0"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VPod_GameClient_With_Resolution_Matches_Expected()
|
public void Rel410_BT_Resolution_Choice_Has_No_Effect()
|
||||||
=> Assert.Equal(
|
=> Assert.Equal(
|
||||||
@"vPOD|0041c870-6e5e-4f3b-9782-f94f2f76f21d|C:\Games\vPOD\vPOD.exe|-net 1501 -res 1024 768|C:\Games\vPOD|True",
|
@"BT4.10|135019c7-2c2f-4c38-96be-c7db39994ab0|C:\Games\TeslaPod410\pod-launch.exe|bt|C:\Games\TeslaPod410|True",
|
||||||
Entry("0041C870-6E5E-4F3B-9782-F94F2F76F21D", "1024", "768"));
|
Entry("135019C7-2C2F-4C38-96BE-C7DB39994AB0", "1024", "768"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VPod_LiveCamera_Matches_Expected()
|
public void Rel410_BT_LiveCamera_Matches_Expected()
|
||||||
=> Assert.Equal(
|
=> Assert.Equal(
|
||||||
@"vPOD LC|ea0d4129-8950-428d-8399-e6a77d2d566a|C:\Games\vPOD\vPOD.exe|-net 1501 -lc|C:\Games\vPOD|True",
|
@"BT4.10 LC|135019c7-2c2f-4c38-96be-c7db39994ab1|C:\Games\TeslaPod410\pod-launch.exe|bt|C:\Games\TeslaPod410|True",
|
||||||
Entry("EA0D4129-8950-428D-8399-E6A77D2D566A"));
|
Entry("135019C7-2C2F-4C38-96BE-C7DB39994AB1"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VPod_MissionReview_Matches_Expected()
|
public void Rel410_BT_MissionReview_Matches_Expected()
|
||||||
=> Assert.Equal(
|
=> Assert.Equal(
|
||||||
@"vPOD MR|fc7ce34e-f4fe-4218-84cd-b13a6fa58e57|C:\Games\vPOD\vPOD.exe|-net 1501 -mr|C:\Games\vPOD|True",
|
@"BT4.10 MR|135019c7-2c2f-4c38-96be-c7db39994ab2|C:\Games\TeslaPod410\pod-launch.exe|bt|C:\Games\TeslaPod410|True",
|
||||||
Entry("FC7CE34E-F4FE-4218-84CD-B13A6FA58E57"));
|
Entry("135019C7-2C2F-4C38-96BE-C7DB39994AB2"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rel410_RP_GameClient_Matches_Expected()
|
||||||
|
=> Assert.Equal(
|
||||||
|
@"RP4.10|135019c7-2c2f-4c38-96be-c7db39994ab3|C:\Games\TeslaPod410\pod-launch.exe|rp|C:\Games\TeslaPod410|True",
|
||||||
|
Entry("135019C7-2C2F-4C38-96BE-C7DB39994AB3"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rel410_RP_LiveCamera_Matches_Expected()
|
||||||
|
=> Assert.Equal(
|
||||||
|
@"RP4.10 LC|135019c7-2c2f-4c38-96be-c7db39994ab4|C:\Games\TeslaPod410\pod-launch.exe|rp|C:\Games\TeslaPod410|True",
|
||||||
|
Entry("135019C7-2C2F-4C38-96BE-C7DB39994AB4"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rel410_RP_MissionReview_Matches_Expected()
|
||||||
|
=> Assert.Equal(
|
||||||
|
@"RP4.10 MR|135019c7-2c2f-4c38-96be-c7db39994ab5|C:\Games\TeslaPod410\pod-launch.exe|rp|C:\Games\TeslaPod410|True",
|
||||||
|
Entry("135019C7-2C2F-4C38-96BE-C7DB39994AB5"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
|
||||||
using Tesla.Net;
|
using Tesla.Net;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -29,8 +28,8 @@ namespace TeslaConsole.DiffTests
|
|||||||
var req = PodRpc.ReadRequest(ms);
|
var req = PodRpc.ReadRequest(ms);
|
||||||
Assert.Equal("KillApp", req.Method);
|
Assert.Equal("KillApp", req.Method);
|
||||||
Assert.Equal(2, req.Args.Count);
|
Assert.Equal(2, req.Args.Count);
|
||||||
Assert.Equal(Key, req.Args[0].GetGuid());
|
Assert.Equal(Key, req.Args[0].ToObject<Guid>(PodRpc.JsonOptions));
|
||||||
Assert.Equal(4242, req.Args[1].GetInt32());
|
Assert.Equal(4242, req.Args[1].ToObject<int>(PodRpc.JsonOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -150,7 +149,7 @@ namespace TeslaConsole.DiffTests
|
|||||||
ms.Position = 0;
|
ms.Position = 0;
|
||||||
var resp = PodRpc.ReadResponse(ms);
|
var resp = PodRpc.ReadResponse(ms);
|
||||||
Assert.Null(resp.Error);
|
Assert.Null(resp.Error);
|
||||||
return resp.Result.Deserialize<T>(PodRpc.JsonOptions);
|
return resp.Result.ToObject<T>(PodRpc.JsonOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ dotnet test tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj
|
|||||||
```
|
```
|
||||||
|
|
||||||
A project reference builds the reconstruction first, and the suite always tests
|
A project reference builds the reconstruction first, and the suite always tests
|
||||||
the most recently built `bin/{Debug,Release}/net48/TeslaConsole.exe`.
|
the most recently built `bin/{Debug,Release}/net40/TeslaConsole.exe` (net40 since
|
||||||
|
the XP11 port; the net48 test host loads it fine — both are CLR4, so the whole
|
||||||
|
process runs the net40/Newtonsoft stack that ships).
|
||||||
|
|
||||||
## Scope / limitations
|
## Scope / limitations
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="System.Drawing" />
|
<Reference Include="System.Drawing" />
|
||||||
<Reference Include="System.Xml" />
|
<Reference Include="System.Xml" />
|
||||||
|
<!-- Zip building for the VPodLauncherServerTests InstallProduct round-trip -->
|
||||||
|
<Reference Include="System.IO.Compression" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -48,11 +50,16 @@
|
|||||||
</ProjectReference>
|
</ProjectReference>
|
||||||
<!-- The source-built wire contract (emits TeslaConsoleLaunchLib.dll). Referenced
|
<!-- The source-built wire contract (emits TeslaConsoleLaunchLib.dll). Referenced
|
||||||
directly so WireContractCompatTests can construct Tesla.Net types and compare
|
directly so WireContractCompatTests can construct Tesla.Net types and compare
|
||||||
their BinaryFormatter output against the original vendored DLL. -->
|
their BinaryFormatter output against the original vendored DLL. net40 (like
|
||||||
|
everything under test since XP11); loads fine in this net48 host — both are
|
||||||
|
CLR4 — so the suite exercises the exact Newtonsoft stack that ships. -->
|
||||||
<ProjectReference Include="..\..\..\Contract\Tesla.Contract.csproj" />
|
<ProjectReference Include="..\..\..\Contract\Tesla.Contract.csproj" />
|
||||||
<!-- The source-built secure-config (emits TeslaSecureConfiguration.dll), for
|
<!-- The source-built secure-config (emits TeslaSecureConfiguration.dll), for
|
||||||
SecureConfigCompatTests' byte-identity checks vs the original vendored DLL. -->
|
SecureConfigCompatTests' byte-identity checks vs the original vendored DLL. -->
|
||||||
<ProjectReference Include="..\..\..\SecureConfig\Tesla.SecureConfig.csproj" />
|
<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" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Threading;
|
||||||
|
using Tesla.Net;
|
||||||
|
using VPod;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeslaConsole.DiffTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end loopback exercise of vPOD's virtual launcher: the REAL
|
||||||
|
/// console-side client (PodManagerConnection) against vPOD's LauncherRpcServer
|
||||||
|
/// on an ephemeral port. Covers the OFB/CONF handshake, the ILauncherService
|
||||||
|
/// dispatch surface, and the out-of-band InstallProduct transfer — including
|
||||||
|
/// the 99%-complete convention the console's install retry loop depends on.
|
||||||
|
/// This validates both ends: the client the console ships and the server vPOD
|
||||||
|
/// uses to stand in for a pod's TeslaLauncher service.
|
||||||
|
/// </summary>
|
||||||
|
public class VPodLauncherServerTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string mDataDir;
|
||||||
|
private readonly VirtualLauncher mLauncher;
|
||||||
|
private readonly LauncherRpcServer mServer;
|
||||||
|
private readonly PodManagerConnection mClient;
|
||||||
|
private readonly byte[] mKey;
|
||||||
|
private readonly int mPort;
|
||||||
|
|
||||||
|
public VPodLauncherServerTests()
|
||||||
|
{
|
||||||
|
mDataDir = Path.Combine(Path.GetTempPath(), "vpod-test-" + Guid.NewGuid().ToString("N"));
|
||||||
|
// Isolated games root: vPOD's default is the real C:\Games (launcher parity).
|
||||||
|
mLauncher = new VirtualLauncher(mDataDir, Path.Combine(mDataDir, "Games"));
|
||||||
|
mKey = new byte[32];
|
||||||
|
new Random(1234).NextBytes(mKey);
|
||||||
|
mPort = GetFreePort();
|
||||||
|
mServer = new LauncherRpcServer(mLauncher, mPort);
|
||||||
|
mServer.Start(mKey);
|
||||||
|
mClient = new PodManagerConnection();
|
||||||
|
mClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), mKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { mClient.Close(); } catch { }
|
||||||
|
mServer.Stop();
|
||||||
|
try { Directory.Delete(mDataDir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handshake_And_Ping_RoundTrip()
|
||||||
|
{
|
||||||
|
Assert.True(mClient.IsOpen);
|
||||||
|
var now = new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
Assert.Equal(now, mClient.Ping(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handshake_With_Wrong_Key_Is_Rejected()
|
||||||
|
{
|
||||||
|
var wrongKey = new byte[32];
|
||||||
|
new Random(9999).NextBytes(wrongKey);
|
||||||
|
using (var badClient = new PodManagerConnection())
|
||||||
|
{
|
||||||
|
Assert.Throws<IOException>(
|
||||||
|
() => badClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), wrongKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InstallApp_Appears_In_GetInstalledApps_And_FullUpdate()
|
||||||
|
{
|
||||||
|
var app = SampleApp("Red Planet 4.11");
|
||||||
|
mClient.InstallApp(app);
|
||||||
|
|
||||||
|
var installed = mClient.GetInstalledApps();
|
||||||
|
Assert.Single(installed);
|
||||||
|
Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey);
|
||||||
|
Assert.Equal(app.ExeFile, installed[0].ExeFile);
|
||||||
|
Assert.Equal(app.Arguments, installed[0].Arguments);
|
||||||
|
Assert.True(installed[0].AutoRestart);
|
||||||
|
|
||||||
|
var launchable = mClient.GetLaunchableApps();
|
||||||
|
Assert.Single(launchable);
|
||||||
|
Assert.Equal("Red Planet 4.11", launchable[0].DisplayName);
|
||||||
|
|
||||||
|
var full = mClient.FullUpdate();
|
||||||
|
Assert.Single(full.InstalledApps);
|
||||||
|
Assert.Empty(full.LaunchedApps);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LaunchApp_Simulates_Pids_And_Kill_Removes_Them()
|
||||||
|
{
|
||||||
|
var app = SampleApp("BattleTech 4.11");
|
||||||
|
mClient.InstallApp(app);
|
||||||
|
|
||||||
|
int pid = mClient.LaunchApp(app.LaunchPair.LaunchKey);
|
||||||
|
Assert.True(pid > 0);
|
||||||
|
var launched = mClient.GetLaunchedApps();
|
||||||
|
Assert.Single(launched);
|
||||||
|
Assert.Equal(pid, launched[0].ProcessId);
|
||||||
|
Assert.Equal(app.LaunchPair.LaunchKey, launched[0].LaunchKey);
|
||||||
|
|
||||||
|
mClient.KillApp(app.LaunchPair.LaunchKey, pid);
|
||||||
|
Assert.Empty(mClient.GetLaunchedApps());
|
||||||
|
|
||||||
|
mClient.LaunchApp(app.LaunchPair.LaunchKey);
|
||||||
|
mClient.LaunchApp(app.LaunchPair.LaunchKey);
|
||||||
|
mClient.KillAllApps();
|
||||||
|
Assert.Empty(mClient.GetLaunchedApps());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VolumeLevel_RoundTrips()
|
||||||
|
{
|
||||||
|
mClient.VolumeLevel = 0.25f;
|
||||||
|
Assert.Equal(0.25f, mClient.VolumeLevel);
|
||||||
|
Assert.Equal(0.25f, mClient.FullUpdate().VolumeLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InstallProduct_Streams_Extracts_And_Completes_At_99()
|
||||||
|
{
|
||||||
|
string zipPath = MakeTestZip("TestGame", "readme.txt", "hello vpod");
|
||||||
|
|
||||||
|
Guid callId = mClient.InstallProduct(zipPath);
|
||||||
|
Assert.NotEqual(Guid.Empty, callId);
|
||||||
|
|
||||||
|
var progress = PollUntilCompleted(callId);
|
||||||
|
// 99, not 100: the console's InstallProductWorker only breaks its
|
||||||
|
// 3-attempt retry loop on exactly 99.
|
||||||
|
Assert.Equal(99, progress.PercentComplete);
|
||||||
|
Assert.Equal("Complete", progress.Status);
|
||||||
|
|
||||||
|
string extracted = Path.Combine(mDataDir, "Games", "TestGame", "readme.txt");
|
||||||
|
Assert.True(File.Exists(extracted), "expected extracted file at " + extracted);
|
||||||
|
Assert.Equal("hello vpod", File.ReadAllText(extracted));
|
||||||
|
|
||||||
|
// The main connection survived the concurrent out-of-band transfer.
|
||||||
|
Assert.True(mClient.IsOpen);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
mClient.Ping(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UninstallApp_Removes_Registration_And_Product_Directory()
|
||||||
|
{
|
||||||
|
string zipPath = MakeTestZip("TestGame", "game.exe", "not really an exe");
|
||||||
|
Guid callId = mClient.InstallProduct(zipPath);
|
||||||
|
PollUntilCompleted(callId);
|
||||||
|
|
||||||
|
string productDir = Path.Combine(mDataDir, "Games", "TestGame");
|
||||||
|
var app = new LaunchData
|
||||||
|
{
|
||||||
|
LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Test Game" },
|
||||||
|
WorkingDirectory = productDir,
|
||||||
|
ExeFile = Path.Combine(productDir, "game.exe"),
|
||||||
|
Arguments = "",
|
||||||
|
AutoRestart = false
|
||||||
|
};
|
||||||
|
mClient.InstallApp(app);
|
||||||
|
Assert.Single(mClient.GetInstalledApps());
|
||||||
|
Assert.True(Directory.Exists(productDir));
|
||||||
|
|
||||||
|
mClient.UninstallApp(app.LaunchPair.LaunchKey);
|
||||||
|
Assert.Empty(mClient.GetInstalledApps());
|
||||||
|
Assert.False(Directory.Exists(productDir), "product directory should be cleaned up");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Shutdown_Raises_ShutdownRequested()
|
||||||
|
{
|
||||||
|
bool? restartArg = null;
|
||||||
|
using (var signalled = new ManualResetEventSlim())
|
||||||
|
{
|
||||||
|
mLauncher.ShutdownRequested += restart =>
|
||||||
|
{
|
||||||
|
restartArg = restart;
|
||||||
|
signalled.Set();
|
||||||
|
};
|
||||||
|
mClient.Shutdown(doRestart: true);
|
||||||
|
Assert.True(signalled.Wait(TimeSpan.FromSeconds(5)), "ShutdownRequested was not raised");
|
||||||
|
Assert.True(restartArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearStore_Wipes_Apps_And_Requests_Reprovisioning()
|
||||||
|
{
|
||||||
|
mClient.InstallApp(SampleApp("Doomed App"));
|
||||||
|
using (var signalled = new ManualResetEventSlim())
|
||||||
|
{
|
||||||
|
mLauncher.ReprovisionRequested += signalled.Set;
|
||||||
|
mClient.ClearStore(); // one-way: no response frame
|
||||||
|
Assert.True(signalled.Wait(TimeSpan.FromSeconds(5)), "ReprovisionRequested was not raised");
|
||||||
|
}
|
||||||
|
Assert.Empty(mLauncher.GetInstalledApps());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RealLaunch_Starts_And_Kills_Real_Processes()
|
||||||
|
{
|
||||||
|
mLauncher.RealLaunch = true;
|
||||||
|
var app = new LaunchData
|
||||||
|
{
|
||||||
|
LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Real Pinger" },
|
||||||
|
WorkingDirectory = Environment.SystemDirectory,
|
||||||
|
ExeFile = Path.Combine(Environment.SystemDirectory, "ping.exe"),
|
||||||
|
Arguments = "-n 60 127.0.0.1",
|
||||||
|
AutoRestart = false
|
||||||
|
};
|
||||||
|
mClient.InstallApp(app);
|
||||||
|
|
||||||
|
int pid = mClient.LaunchApp(app.LaunchPair.LaunchKey);
|
||||||
|
Assert.True(pid > 0);
|
||||||
|
using (var real = Process.GetProcessById(pid)) // throws if not actually running
|
||||||
|
{
|
||||||
|
Assert.False(real.HasExited);
|
||||||
|
var launched = mClient.GetLaunchedApps();
|
||||||
|
Assert.Single(launched);
|
||||||
|
Assert.Equal(pid, launched[0].ProcessId);
|
||||||
|
|
||||||
|
mClient.KillAllOfType(app.LaunchPair.LaunchKey);
|
||||||
|
Assert.True(real.WaitForExit(5000), "real process was not terminated");
|
||||||
|
}
|
||||||
|
Assert.Empty(mClient.GetLaunchedApps());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RealLaunch_Watchdog_Restarts_AutoRestart_Apps_That_Exit_On_Their_Own()
|
||||||
|
{
|
||||||
|
mLauncher.RealLaunch = true;
|
||||||
|
mLauncher.RealAutoRestart = true;
|
||||||
|
var app = new LaunchData
|
||||||
|
{
|
||||||
|
LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Short Pinger" },
|
||||||
|
WorkingDirectory = Environment.SystemDirectory,
|
||||||
|
ExeFile = Path.Combine(Environment.SystemDirectory, "ping.exe"),
|
||||||
|
Arguments = "-n 3 127.0.0.1", // exits on its own after ~2 s
|
||||||
|
AutoRestart = true
|
||||||
|
};
|
||||||
|
mClient.InstallApp(app);
|
||||||
|
int firstPid = mClient.LaunchApp(app.LaunchPair.LaunchKey);
|
||||||
|
Assert.True(firstPid > 0);
|
||||||
|
|
||||||
|
// The app exits by itself; the watchdog must bring up a NEW pid
|
||||||
|
// (exit ~2 s + the Agent's 2 s restart delay).
|
||||||
|
int restartedPid = 0;
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
var launched = mClient.GetLaunchedApps();
|
||||||
|
if (launched.Length == 1 && launched[0].ProcessId != firstPid)
|
||||||
|
{
|
||||||
|
restartedPid = launched[0].ProcessId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Thread.Sleep(100);
|
||||||
|
}
|
||||||
|
Assert.True(restartedPid > 0, "watchdog did not restart the exited app");
|
||||||
|
|
||||||
|
// Turning the watchdog off ends the cycle: the current instance
|
||||||
|
// exits on its own and nothing relaunches it.
|
||||||
|
mLauncher.RealAutoRestart = false;
|
||||||
|
deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
|
||||||
|
while (DateTime.UtcNow < deadline && mClient.GetLaunchedApps().Length > 0)
|
||||||
|
{
|
||||||
|
Thread.Sleep(200);
|
||||||
|
}
|
||||||
|
Assert.Empty(mClient.GetLaunchedApps());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RealLaunch_Missing_Exe_Surfaces_The_Agents_Error()
|
||||||
|
{
|
||||||
|
mLauncher.RealLaunch = true;
|
||||||
|
var app = SampleApp("Not Installed Yet"); // ExeFile doesn't exist on disk
|
||||||
|
mClient.InstallApp(app);
|
||||||
|
|
||||||
|
var ex = Assert.ThrowsAny<Exception>(() => mClient.LaunchApp(app.LaunchPair.LaunchKey));
|
||||||
|
Assert.Contains("executable not found", ex.Message);
|
||||||
|
Assert.Empty(mClient.GetLaunchedApps());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dropped_Session_Still_Reports_Open_Until_Probed_Then_Reconnect_Works()
|
||||||
|
{
|
||||||
|
// The launcher (and vPOD) drops sessions idle >30s. Simulate the drop
|
||||||
|
// server-side and pin the premise PodInfo.EnsureConnectionAlive relies
|
||||||
|
// on: the client socket still REPORTS open until an I/O fails...
|
||||||
|
mServer.Stop();
|
||||||
|
mServer.Start(mKey);
|
||||||
|
Thread.Sleep(100); // let the FIN arrive
|
||||||
|
Assert.True(mClient.IsOpen, "a dropped-but-unused connection should still report open (the stale state)");
|
||||||
|
|
||||||
|
// ...the cheap Ping probe is what exposes the dead connection...
|
||||||
|
Assert.ThrowsAny<Exception>(() => mClient.Ping(DateTime.UtcNow));
|
||||||
|
|
||||||
|
// ...and close + reopen (EnsureConnectionAlive's recovery) restores service.
|
||||||
|
mClient.Close();
|
||||||
|
mClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), mKey);
|
||||||
|
var now = new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
Assert.Equal(now, mClient.Ping(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Installed_Apps_Persist_Across_Launcher_Restart()
|
||||||
|
{
|
||||||
|
var app = SampleApp("Persistent App");
|
||||||
|
mClient.InstallApp(app);
|
||||||
|
|
||||||
|
var reloaded = new VirtualLauncher(mDataDir, Path.Combine(mDataDir, "Games"));
|
||||||
|
var installed = reloaded.GetInstalledApps();
|
||||||
|
Assert.Single(installed);
|
||||||
|
Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey);
|
||||||
|
Assert.Equal("Persistent App", installed[0].LaunchPair.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static LaunchData SampleApp(string name)
|
||||||
|
{
|
||||||
|
return new LaunchData
|
||||||
|
{
|
||||||
|
LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = name },
|
||||||
|
WorkingDirectory = @"C:\Games\Sample",
|
||||||
|
ExeFile = @"C:\Games\Sample\game.exe",
|
||||||
|
Arguments = "-net 1501 -res 1920 1080",
|
||||||
|
AutoRestart = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string MakeTestZip(string folder, string fileName, string content)
|
||||||
|
{
|
||||||
|
string zipPath = Path.Combine(mDataDir, "product-" + Guid.NewGuid().ToString("N") + ".zip");
|
||||||
|
using (var fs = File.Create(zipPath))
|
||||||
|
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
|
||||||
|
{
|
||||||
|
var entry = zip.CreateEntry(folder + "/" + fileName);
|
||||||
|
using (var writer = new StreamWriter(entry.Open()))
|
||||||
|
{
|
||||||
|
writer.Write(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private OutOfBandProgress PollUntilCompleted(Guid callId)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
var progress = mClient.GetOutOfBandProgress(callId);
|
||||||
|
if (progress.IsCompleted)
|
||||||
|
{
|
||||||
|
return progress;
|
||||||
|
}
|
||||||
|
Thread.Sleep(50);
|
||||||
|
}
|
||||||
|
throw new TimeoutException("Install did not complete in time.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetFreePort()
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Threading;
|
||||||
|
using Tesla;
|
||||||
|
using VPod;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeslaConsole.DiffTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end loopback exercise of vPOD's pod-side SecureConfig provisioning
|
||||||
|
/// against the REAL console-side implementation (Tesla.PodConfigurationServer,
|
||||||
|
/// the exact code the console runs behind Manage Site's "Configure" button):
|
||||||
|
/// RQST beacon reception, the AES "RPLY" config broadcast, and the OFB+RSA
|
||||||
|
/// session-key exchange must all interoperate.
|
||||||
|
///
|
||||||
|
/// Uses the protocol's fixed ports (UDP 53291/53292, TCP 53292) and real UDP
|
||||||
|
/// broadcasts on loopback — do not run while a console or another vPOD is
|
||||||
|
/// provisioning on this machine.
|
||||||
|
/// </summary>
|
||||||
|
public class VPodProvisioningTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Console_Provisions_VPod_And_Both_Hold_The_Same_Session_Key()
|
||||||
|
{
|
||||||
|
byte[] mac = PodProvisioning.MacForHost(7);
|
||||||
|
var provisioning = new PodProvisioning(mac);
|
||||||
|
|
||||||
|
byte[] beaconMac = null;
|
||||||
|
string beaconRequestId = null;
|
||||||
|
using (var beaconSeen = new ManualResetEventSlim())
|
||||||
|
using (var podProvisioned = new ManualResetEventSlim())
|
||||||
|
{
|
||||||
|
byte[] podKey = null;
|
||||||
|
provisioning.ConfigReceived += _ => { };
|
||||||
|
provisioning.Provisioned += key =>
|
||||||
|
{
|
||||||
|
podKey = key;
|
||||||
|
podProvisioned.Set();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The console side: listens on UDP 53291 for RQST beacons; the
|
||||||
|
// delegate fires on vPOD's first beacon (sent at Start).
|
||||||
|
PodConfigurationServer consoleServer;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
consoleServer = new PodConfigurationServer(53291, 53292, (m, id) =>
|
||||||
|
{
|
||||||
|
beaconMac = m;
|
||||||
|
beaconRequestId = id;
|
||||||
|
beaconSeen.Set();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (System.Net.Sockets.SocketException)
|
||||||
|
{
|
||||||
|
// UDP 53291 already bound — a real TeslaConsole is running on
|
||||||
|
// this machine. The protocol ports are fixed, so the test can't
|
||||||
|
// run; treat as inconclusive rather than failing the suite.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
provisioning.Start();
|
||||||
|
Assert.True(beaconSeen.Wait(TimeSpan.FromSeconds(15)),
|
||||||
|
"console never received vPOD's RQST beacon");
|
||||||
|
Assert.Equal(mac, beaconMac);
|
||||||
|
Assert.Equal(provisioning.RequestId, beaconRequestId);
|
||||||
|
|
||||||
|
// The console side of Configure: broadcast the AES-encrypted
|
||||||
|
// network config and run the RSA key exchange against the pod.
|
||||||
|
byte[] consoleKey = consoleServer.SendEncryptionKey(
|
||||||
|
provisioning.Passphrase,
|
||||||
|
IPAddress.Loopback,
|
||||||
|
IPAddress.Parse("255.255.255.0"),
|
||||||
|
IPAddress.Any,
|
||||||
|
IPAddress.Any,
|
||||||
|
"vpod-test",
|
||||||
|
TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
Assert.True(podProvisioned.Wait(TimeSpan.FromSeconds(15)),
|
||||||
|
"vPOD never completed provisioning");
|
||||||
|
Assert.NotNull(consoleKey);
|
||||||
|
Assert.Equal(32, consoleKey.Length);
|
||||||
|
Assert.Equal(consoleKey, podKey);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
provisioning.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,69 +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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- **Power On / Power Off** — Power Off closes the TCP listener so the console
|
|
||||||
cannot connect, mimicking a pod with no game client running; Power On reopens
|
|
||||||
it. **Reset** returns a live pod 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]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `-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).
|
|
||||||
|
|
||||||
## 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,514 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Drawing;
|
|
||||||
using System.Text;
|
|
||||||
using System.Windows.Forms;
|
|
||||||
using Munga.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.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class VPodForm : Form
|
|
||||||
{
|
|
||||||
private readonly PodArguments mOptions;
|
|
||||||
private readonly MungaPodServer mServer;
|
|
||||||
private readonly PodSimulator mSimulator;
|
|
||||||
|
|
||||||
private Label mListeningLabel;
|
|
||||||
private Label mConnectionLabel;
|
|
||||||
private Label mStateLabel;
|
|
||||||
private Label mEggSummaryLabel;
|
|
||||||
private RadioButton mRedPlanetRadio;
|
|
||||||
private RadioButton mBattleTechRadio;
|
|
||||||
private Button mPowerButton;
|
|
||||||
private Button mPowerOffButton;
|
|
||||||
private Button mResetButton;
|
|
||||||
private CheckBox mRestartCheckbox;
|
|
||||||
private Timer mRestartTimer;
|
|
||||||
private SplitContainer mSplit;
|
|
||||||
private TextBox mEggBox;
|
|
||||||
private TextBox mLogBox;
|
|
||||||
|
|
||||||
// How long the "watchdog" waits before relaunching the exited game.
|
|
||||||
private const int WatchdogRestartMs = 1500;
|
|
||||||
|
|
||||||
public VPodForm(PodArguments options)
|
|
||||||
{
|
|
||||||
mOptions = options;
|
|
||||||
mServer = new MungaPodServer(options.Port);
|
|
||||||
mSimulator = new PodSimulator(mServer, options.Application, options.HostId);
|
|
||||||
BuildUi();
|
|
||||||
|
|
||||||
mServer.Log += OnLog;
|
|
||||||
mServer.ConnectionChanged += OnConnectionChanged;
|
|
||||||
mServer.MessageReceived += OnMessageReceived;
|
|
||||||
mSimulator.Log += OnLog;
|
|
||||||
mSimulator.StateChanged += OnStateChanged;
|
|
||||||
mSimulator.EggReceived += OnEggReceived;
|
|
||||||
mSimulator.EggProgress += OnEggProgress;
|
|
||||||
mSimulator.EndMissionExit += OnEndMissionExit;
|
|
||||||
|
|
||||||
mRestartTimer = new Timer { Interval = WatchdogRestartMs };
|
|
||||||
mRestartTimer.Tick += OnRestartTimerTick;
|
|
||||||
|
|
||||||
Load += OnFormLoad;
|
|
||||||
FormClosing += OnFormClosing;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildUi()
|
|
||||||
{
|
|
||||||
Text = $"vPOD - virtual pod (port {mOptions.Port}, host {mOptions.HostId})";
|
|
||||||
ClientSize = new Size(920, 560);
|
|
||||||
MinimumSize = new Size(760, 460);
|
|
||||||
Font = new Font("Segoe UI", 9f);
|
|
||||||
|
|
||||||
// ---- status panel ----
|
|
||||||
GroupBox statusGroup = new GroupBox
|
|
||||||
{
|
|
||||||
Text = "Pod Status",
|
|
||||||
Dock = DockStyle.Top,
|
|
||||||
Height = 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 = "—"
|
|
||||||
};
|
|
||||||
|
|
||||||
// game toggle
|
|
||||||
GroupBox gameGroup = new GroupBox
|
|
||||||
{
|
|
||||||
Text = "Mimicking Game",
|
|
||||||
Location = new Point(560, 20),
|
|
||||||
Size = new Size(330, 130)
|
|
||||||
};
|
|
||||||
mRedPlanetRadio = new RadioButton
|
|
||||||
{
|
|
||||||
Text = "Red Planet (RPL4)",
|
|
||||||
Location = new Point(18, 28),
|
|
||||||
AutoSize = true,
|
|
||||||
Checked = mOptions.Application == ApplicationID.RPL4
|
|
||||||
};
|
|
||||||
mBattleTechRadio = new RadioButton
|
|
||||||
{
|
|
||||||
Text = "BattleTech (BTL4)",
|
|
||||||
Location = new Point(18, 56),
|
|
||||||
AutoSize = true,
|
|
||||||
Checked = mOptions.Application == ApplicationID.BTL4
|
|
||||||
};
|
|
||||||
mRedPlanetRadio.CheckedChanged += GameToggleChanged;
|
|
||||||
mBattleTechRadio.CheckedChanged += GameToggleChanged;
|
|
||||||
mPowerButton = new Button { Text = "Power On", Location = new Point(12, 90), Size = new Size(94, 28) };
|
|
||||||
mPowerOffButton = new Button { Text = "Power Off", 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) };
|
|
||||||
mPowerButton.Click += PowerOnClicked;
|
|
||||||
mPowerOffButton.Click += PowerOffClicked;
|
|
||||||
mResetButton.Click += (s, e) => mSimulator.Reset();
|
|
||||||
gameGroup.Controls.Add(mRedPlanetRadio);
|
|
||||||
gameGroup.Controls.Add(mBattleTechRadio);
|
|
||||||
gameGroup.Controls.Add(mPowerButton);
|
|
||||||
gameGroup.Controls.Add(mPowerOffButton);
|
|
||||||
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(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);
|
|
||||||
|
|
||||||
Controls.Add(split);
|
|
||||||
Controls.Add(statusGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
if (StartServer())
|
|
||||||
{
|
|
||||||
mSimulator.PowerOn();
|
|
||||||
SetPoweredState(on: true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
SetPoweredState(on: false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnFormClosing(object sender, FormClosingEventArgs e)
|
|
||||||
{
|
|
||||||
mServer.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PowerOnClicked(object sender, EventArgs e)
|
|
||||||
{
|
|
||||||
mRestartTimer.Stop(); // cancel any pending watchdog restart
|
|
||||||
if (!mServer.IsListening && !StartServer())
|
|
||||||
{
|
|
||||||
return; // couldn't bind the port; stay powered off
|
|
||||||
}
|
|
||||||
mSimulator.PowerOn();
|
|
||||||
SetPoweredState(on: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Powers the pod off by closing the TCP listener, so the console can no
|
|
||||||
/// longer connect — the same condition as a pod with no game client running.
|
|
||||||
/// </summary>
|
|
||||||
private void PowerOffClicked(object sender, EventArgs e)
|
|
||||||
{
|
|
||||||
mRestartTimer.Stop();
|
|
||||||
mServer.Stop();
|
|
||||||
SetPoweredState(on: 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;
|
|
||||||
mPowerButton.Enabled = true;
|
|
||||||
mPowerOffButton.Enabled = false;
|
|
||||||
mResetButton.Enabled = false;
|
|
||||||
mRestartTimer.Stop();
|
|
||||||
mRestartTimer.Start();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnRestartTimerTick(object sender, EventArgs e)
|
|
||||||
{
|
|
||||||
mRestartTimer.Stop();
|
|
||||||
if (StartServer())
|
|
||||||
{
|
|
||||||
OnLog("Watchdog restarted the game.");
|
|
||||||
mSimulator.PowerOn();
|
|
||||||
SetPoweredState(on: true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
SetPoweredState(on: 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 on/off state in the buttons and, when off, the status labels.</summary>
|
|
||||||
private void SetPoweredState(bool on)
|
|
||||||
{
|
|
||||||
mPowerButton.Enabled = !on;
|
|
||||||
mPowerOffButton.Enabled = on;
|
|
||||||
mResetButton.Enabled = on;
|
|
||||||
if (!on)
|
|
||||||
{
|
|
||||||
UpdateListeningLabel(false);
|
|
||||||
UpdateConnectionLabel(null);
|
|
||||||
mStateLabel.Text = "Powered Off";
|
|
||||||
mStateLabel.ForeColor = Color.Gray;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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}%";
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 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,44 +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 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>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tesla.Contract — Console-side RPC client (net48 only)
|
// Tesla.Contract — Console-side RPC client
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Opens an OFB-encrypted TCP connection to the pod (port 53290) and dispatches
|
// Opens an OFB-encrypted TCP connection to the pod (port 53290) and dispatches
|
||||||
// ILauncherService calls as framed JSON RpcRequest / RpcResponse pairs (see
|
// ILauncherService calls as framed JSON RpcRequest / RpcResponse pairs (see
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
// unchanged.
|
// unchanged.
|
||||||
//
|
//
|
||||||
// Depends on Tesla.PodConfigurationServer (Tesla.SecureConfig) for the crypto
|
// Depends on Tesla.PodConfigurationServer (Tesla.SecureConfig) for the crypto
|
||||||
// handshake, so it is compiled for net48 only. The Launcher is the server end.
|
// handshake. Results deserialize with Newtonsoft.Json (see the serializer note
|
||||||
|
// in PodRpcProtocol.cs).
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
@@ -16,8 +17,8 @@ using System.IO;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace Tesla.Net
|
namespace Tesla.Net
|
||||||
{
|
{
|
||||||
@@ -129,14 +130,14 @@ namespace Tesla.Net
|
|||||||
throw new Exception("Server function threw an exception: " + response.Error);
|
throw new Exception("Server function threw an exception: " + response.Error);
|
||||||
}
|
}
|
||||||
if (resultType == null
|
if (resultType == null
|
||||||
|| response.Result.ValueKind == JsonValueKind.Null
|
|| response.Result == null
|
||||||
|| response.Result.ValueKind == JsonValueKind.Undefined)
|
|| response.Result.Type == JTokenType.Null)
|
||||||
{
|
{
|
||||||
return resultType != null && resultType.IsValueType
|
return resultType != null && resultType.IsValueType
|
||||||
? Activator.CreateInstance(resultType)
|
? Activator.CreateInstance(resultType)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
return response.Result.Deserialize(resultType, PodRpc.JsonOptions);
|
return response.Result.ToObject(resultType, PodRpc.JsonOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (IOException innerException)
|
catch (IOException innerException)
|
||||||
|
|||||||
@@ -14,26 +14,34 @@
|
|||||||
// Dispatch is by method NAME (RpcRequest.Method) — the old serialized-MethodBase
|
// Dispatch is by method NAME (RpcRequest.Method) — the old serialized-MethodBase
|
||||||
// + SerializationBinder + MethodInfoProxy machinery is gone. Both ends share this
|
// + SerializationBinder + MethodInfoProxy machinery is gone. Both ends share this
|
||||||
// one file, so the request/response shape cannot drift.
|
// one file, so the request/response shape cannot drift.
|
||||||
|
//
|
||||||
|
// Serializer: Newtonsoft.Json — System.Text.Json has no net40 target, and since
|
||||||
|
// XP11 the whole suite (Console, Launcher, vPOD) is net40. The protocol briefly
|
||||||
|
// had an STJ leg for the net48 Console era; it wrote shape-identical JSON
|
||||||
|
// (PascalCase member names, fields included, Guids as strings, ISO-8601 dates),
|
||||||
|
// so anything that captured wire traffic then still matches what this writes.
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace Tesla.Net
|
namespace Tesla.Net
|
||||||
{
|
{
|
||||||
/// <summary>One RPC call: a method name plus its arguments as JSON elements.</summary>
|
/// <summary>One RPC call: a method name plus its arguments as JSON tokens.</summary>
|
||||||
public sealed class RpcRequest
|
public sealed class RpcRequest
|
||||||
{
|
{
|
||||||
public string Method { get; set; }
|
public string Method { get; set; }
|
||||||
public List<JsonElement> Args { get; set; }
|
public List<JToken> Args { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One RPC result: the return value as JSON, or an error message.</summary>
|
/// <summary>One RPC result: the return value as JSON, or an error message.</summary>
|
||||||
public sealed class RpcResponse
|
public sealed class RpcResponse
|
||||||
{
|
{
|
||||||
public JsonElement Result { get; set; } // JsonValueKind.Null for void / null
|
public JToken Result { get; set; } // JSON null for void / null
|
||||||
public string Error { get; set; } // null on success
|
public string Error { get; set; } // null on success
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +53,9 @@ namespace Tesla.Net
|
|||||||
/// streamed out-of-band, not framed), guarding against hostile lengths.</summary>
|
/// streamed out-of-band, not framed), guarding against hostile lengths.</summary>
|
||||||
public const int MaxFrameBytes = 16 * 1024 * 1024;
|
public const int MaxFrameBytes = 16 * 1024 * 1024;
|
||||||
|
|
||||||
public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
|
// Newtonsoft serializes public fields of the wire types by default, and
|
||||||
{
|
// writes ISO-8601 dates / string Guids — no special options needed.
|
||||||
// The Tesla.Net wire types (LaunchData, LaunchPair, ...) expose public
|
public static readonly JsonSerializer JsonOptions = JsonSerializer.CreateDefault();
|
||||||
// FIELDS, which System.Text.Json ignores unless this is set.
|
|
||||||
IncludeFields = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Framing ──────────────────────────────────────────────────────────
|
// ── Framing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -90,31 +95,37 @@ namespace Tesla.Net
|
|||||||
|
|
||||||
public static void WriteRequest(Stream stream, string method, object[] args)
|
public static void WriteRequest(Stream stream, string method, object[] args)
|
||||||
{
|
{
|
||||||
var req = new RpcRequest { Method = method, Args = new List<JsonElement>() };
|
var req = new RpcRequest { Method = method, Args = new List<JToken>() };
|
||||||
if (args != null)
|
if (args != null)
|
||||||
foreach (var a in args)
|
foreach (var a in args)
|
||||||
req.Args.Add(JsonSerializer.SerializeToElement(a, JsonOptions));
|
req.Args.Add(a == null ? JValue.CreateNull() : JToken.FromObject(a, JsonOptions));
|
||||||
WriteFrame(stream, JsonSerializer.SerializeToUtf8Bytes(req, JsonOptions));
|
WriteFrame(stream, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(req)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static RpcRequest ReadRequest(Stream stream)
|
public static RpcRequest ReadRequest(Stream stream)
|
||||||
=> JsonSerializer.Deserialize<RpcRequest>(ReadFrame(stream), JsonOptions);
|
=> JsonConvert.DeserializeObject<RpcRequest>(
|
||||||
|
Encoding.UTF8.GetString(ReadFrame(stream)), ReadSettings);
|
||||||
|
|
||||||
|
// Keep date-looking strings as raw strings so an echoed argument (Ping)
|
||||||
|
// goes back byte-identical instead of reformatted through DateTime.
|
||||||
|
private static readonly JsonSerializerSettings ReadSettings =
|
||||||
|
new JsonSerializerSettings { DateParseHandling = DateParseHandling.None };
|
||||||
|
|
||||||
// ── Response ─────────────────────────────────────────────────────────
|
// ── Response ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public static void WriteResponse(Stream stream, object result, string error)
|
public static void WriteResponse(Stream stream, object result, string error)
|
||||||
{
|
{
|
||||||
|
object payload = error == null ? result : null;
|
||||||
var resp = new RpcResponse
|
var resp = new RpcResponse
|
||||||
{
|
{
|
||||||
// Always a valid element (JSON null when there is no result): a
|
Result = payload == null ? JValue.CreateNull() : JToken.FromObject(payload, JsonOptions),
|
||||||
// default(JsonElement) is ValueKind.Undefined and is not serializable.
|
|
||||||
Result = JsonSerializer.SerializeToElement(error == null ? result : null, JsonOptions),
|
|
||||||
Error = error,
|
Error = error,
|
||||||
};
|
};
|
||||||
WriteFrame(stream, JsonSerializer.SerializeToUtf8Bytes(resp, JsonOptions));
|
WriteFrame(stream, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(resp)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static RpcResponse ReadResponse(Stream stream)
|
public static RpcResponse ReadResponse(Stream stream)
|
||||||
=> JsonSerializer.Deserialize<RpcResponse>(ReadFrame(stream), JsonOptions);
|
=> JsonConvert.DeserializeObject<RpcResponse>(
|
||||||
|
Encoding.UTF8.GetString(ReadFrame(stream)), ReadSettings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- net48 only: both consumers (Console and Launcher) target .NET Framework 4.8.
|
<!-- net40 only (XP11): the oldest framework installable on Windows XP SP3,
|
||||||
(Was multi-targeted net48;net8.0-windows while the Launcher was on net8.) -->
|
and net40 assemblies run in-place on the 4.8 runtime — so the whole
|
||||||
<TargetFramework>net48</TargetFramework>
|
suite (Console, Launcher, vPOD, and this contract they share) covers
|
||||||
|
XP SP3 through Windows 11 with one flavor. A net48/System.Text.Json leg
|
||||||
|
existed while the Console was net48; it was dropped 2026-07-11 when the
|
||||||
|
last consumer moved to net40 (the JSON on the wire is unchanged). -->
|
||||||
|
<TargetFramework>net40</TargetFramework>
|
||||||
|
|
||||||
<!-- CRITICAL: the output assembly MUST be named TeslaConsoleLaunchLib at
|
<!-- CRITICAL: the output assembly MUST be named TeslaConsoleLaunchLib at
|
||||||
version 1.0.0.0. BinaryFormatter embeds the assembly name in the wire
|
version 1.0.0.0. BinaryFormatter embeds the assembly name in the wire
|
||||||
@@ -24,24 +28,23 @@
|
|||||||
<NoWarn>$(NoWarn);SYSLIB0011</NoWarn>
|
<NoWarn>$(NoWarn);SYSLIB0011</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- net48 reference assemblies so the project builds without a full targeting pack,
|
<!-- .NET Framework reference assemblies so the project builds without a full
|
||||||
plus System.Text.Json (built into the net8 shared framework, a package on net48). -->
|
targeting pack (resolves per-TFM, covers net40 and net48). -->
|
||||||
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- The TCP/OFB client (Client/**) is net48-only: it depends on the crypto-stream
|
<!-- JSON serializer: Newtonsoft.Json (still ships lib/net40; System.Text.Json
|
||||||
handshake in TeslaSecureConfiguration.dll. The Launcher (net6) is the SERVER
|
never had a net40 target). -->
|
||||||
end of this protocol and never references these classes, so they are excluded
|
<ItemGroup>
|
||||||
from the net6.0-windows build (which carries only the wire data types). -->
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<ItemGroup Condition="'$(TargetFramework)' != 'net48'">
|
|
||||||
<Compile Remove="Client\**\*.cs" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
|
<ItemGroup>
|
||||||
<!-- Source-built secure-config (PodConfigurationServer.NegotiateCryptoStreams),
|
<!-- Source-built secure-config (PodConfigurationServer.NegotiateCryptoStreams),
|
||||||
emitting assembly TeslaSecureConfiguration. net48-only, same as Client/**. -->
|
emitting assembly TeslaSecureConfiguration; needed by the TCP/OFB client
|
||||||
|
under Client/**. The Launcher never touches the Client types; its package
|
||||||
|
just carries the extra dll. -->
|
||||||
<ProjectReference Include="..\SecureConfig\Tesla.SecureConfig.csproj" />
|
<ProjectReference Include="..\SecureConfig\Tesla.SecureConfig.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// TeslaLauncher — Shared IPC Models
|
|
||||||
// =============================================================================
|
|
||||||
// IPC types (Tesla.Launcher.Shared): JSON messages between the Windows Service
|
|
||||||
// and the user-session Agent over a Named Pipe. These never touch the Console
|
|
||||||
// TCP connection.
|
|
||||||
//
|
|
||||||
// The BinaryFormatter wire types (Tesla.Net: LaunchData, InvokeCommand, ...)
|
|
||||||
// formerly replicated here by hand now live in the shared Tesla.Contract project
|
|
||||||
// (assembly TeslaConsoleLaunchLib), referenced by the Service. This file is also
|
|
||||||
// compiled into the Agent, which uses only the IPC types below.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
namespace Tesla.Launcher.Shared
|
|
||||||
{
|
|
||||||
/// <summary>Command forwarded from the Windows Service to the Userspace Agent.</summary>
|
|
||||||
public sealed class IpcMessage
|
|
||||||
{
|
|
||||||
public string Command { get; set; }
|
|
||||||
public string LaunchKey { get; set; }
|
|
||||||
public string PayloadJson { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Response from the Userspace Agent back to the Windows Service.</summary>
|
|
||||||
public sealed class IpcResponse
|
|
||||||
{
|
|
||||||
public bool Success { get; set; }
|
|
||||||
public string Message { get; set; }
|
|
||||||
public object Data { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// TeslaLauncher — MiniZip
|
||||||
|
// =============================================================================
|
||||||
|
// Minimal ZIP extractor for net40: System.IO.Compression.ZipFile/ZipArchive are
|
||||||
|
// net45+, so they do not exist on the XP-compatible framework. This reads the
|
||||||
|
// archive via the central directory and supports exactly what the Console's
|
||||||
|
// install packages use:
|
||||||
|
// - compression methods 0 (stored) and 8 (deflate; DeflateStream is net20+)
|
||||||
|
// - UTF-8 (general-purpose flag bit 11) or ANSI/CP437 entry names
|
||||||
|
// - ZIP64 sizes/offsets/entry counts (archives > 4 GB or > 65535 entries)
|
||||||
|
// CRC is not verified — archives arrive from our own Console over the
|
||||||
|
// OFB-encrypted link, and the install already fails loudly on truncation.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Tesla.Launcher
|
||||||
|
{
|
||||||
|
internal static class MiniZip
|
||||||
|
{
|
||||||
|
/// <summary>Extracts every entry of <paramref name="zipPath"/> under
|
||||||
|
/// <paramref name="destDir"/> (existing files overwritten), reporting
|
||||||
|
/// (entriesDone, entriesTotal) after each entry. Entries that would
|
||||||
|
/// escape the destination directory (zip-slip) are skipped.</summary>
|
||||||
|
public static void ExtractToDirectory(string zipPath, string destDir, Action<int, int> onEntry)
|
||||||
|
{
|
||||||
|
using (var fs = new FileStream(zipPath, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||||
|
{
|
||||||
|
var entries = ReadCentralDirectory(fs);
|
||||||
|
var destRoot = Path.GetFullPath(destDir).TrimEnd('\\');
|
||||||
|
int done = 0;
|
||||||
|
foreach (var e in entries)
|
||||||
|
{
|
||||||
|
ExtractEntry(fs, e, destRoot);
|
||||||
|
done++;
|
||||||
|
if (onEntry != null) onEntry(done, entries.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Entry
|
||||||
|
{
|
||||||
|
public string Name;
|
||||||
|
public int Method;
|
||||||
|
public long CompressedSize;
|
||||||
|
public long LocalHeaderOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Central directory ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static List<Entry> ReadCentralDirectory(FileStream fs)
|
||||||
|
{
|
||||||
|
// End-of-central-directory record: fixed 22 bytes + up to 64K comment.
|
||||||
|
long fileLen = fs.Length;
|
||||||
|
int maxScan = (int)Math.Min(fileLen, 22 + 65535);
|
||||||
|
var tail = new byte[maxScan];
|
||||||
|
fs.Seek(fileLen - maxScan, SeekOrigin.Begin);
|
||||||
|
ReadExact(fs, tail, maxScan);
|
||||||
|
|
||||||
|
int eocd = -1;
|
||||||
|
for (int i = maxScan - 22; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (tail[i] == 0x50 && tail[i + 1] == 0x4B &&
|
||||||
|
tail[i + 2] == 0x05 && tail[i + 3] == 0x06) { eocd = i; break; }
|
||||||
|
}
|
||||||
|
if (eocd < 0) throw new IOException("Not a ZIP archive (no end-of-central-directory record).");
|
||||||
|
|
||||||
|
long eocdPos = fileLen - maxScan + eocd;
|
||||||
|
long totalEntries = BitConverter.ToUInt16(tail, eocd + 10);
|
||||||
|
long cdOffset = BitConverter.ToUInt32(tail, eocd + 16);
|
||||||
|
|
||||||
|
// ZIP64: any maxed-out field means the real values live in the
|
||||||
|
// ZIP64 EOCD record, found via the locator 20 bytes before EOCD.
|
||||||
|
if (totalEntries == 0xFFFF || cdOffset == 0xFFFFFFFF)
|
||||||
|
{
|
||||||
|
long locPos = eocdPos - 20;
|
||||||
|
if (locPos >= 0)
|
||||||
|
{
|
||||||
|
var loc = new byte[20];
|
||||||
|
fs.Seek(locPos, SeekOrigin.Begin);
|
||||||
|
ReadExact(fs, loc, 20);
|
||||||
|
if (loc[0] == 0x50 && loc[1] == 0x4B && loc[2] == 0x06 && loc[3] == 0x07)
|
||||||
|
{
|
||||||
|
long z64Pos = BitConverter.ToInt64(loc, 8);
|
||||||
|
var z64 = new byte[56];
|
||||||
|
fs.Seek(z64Pos, SeekOrigin.Begin);
|
||||||
|
ReadExact(fs, z64, 56);
|
||||||
|
if (!(z64[0] == 0x50 && z64[1] == 0x4B && z64[2] == 0x06 && z64[3] == 0x06))
|
||||||
|
throw new IOException("Corrupt ZIP64 end-of-central-directory record.");
|
||||||
|
totalEntries = BitConverter.ToInt64(z64, 32);
|
||||||
|
cdOffset = BitConverter.ToInt64(z64, 48);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = new List<Entry>((int)totalEntries);
|
||||||
|
fs.Seek(cdOffset, SeekOrigin.Begin);
|
||||||
|
var br = new BinaryReader(fs); // not disposed — would close fs
|
||||||
|
|
||||||
|
for (long i = 0; i < totalEntries; i++)
|
||||||
|
{
|
||||||
|
if (br.ReadUInt32() != 0x02014B50)
|
||||||
|
throw new IOException("Corrupt central directory (bad entry signature).");
|
||||||
|
|
||||||
|
br.ReadUInt16(); // version made by
|
||||||
|
br.ReadUInt16(); // version needed
|
||||||
|
ushort flags = br.ReadUInt16();
|
||||||
|
ushort method = br.ReadUInt16();
|
||||||
|
br.ReadUInt32(); // DOS mod time/date
|
||||||
|
br.ReadUInt32(); // CRC-32
|
||||||
|
long comp = br.ReadUInt32();
|
||||||
|
long uncomp = br.ReadUInt32();
|
||||||
|
ushort nameLen = br.ReadUInt16();
|
||||||
|
ushort extraLen = br.ReadUInt16();
|
||||||
|
ushort commentLen = br.ReadUInt16();
|
||||||
|
br.ReadUInt16(); // disk number start
|
||||||
|
br.ReadUInt16(); // internal attributes
|
||||||
|
br.ReadUInt32(); // external attributes
|
||||||
|
long lho = br.ReadUInt32();
|
||||||
|
|
||||||
|
byte[] nameBytes = br.ReadBytes(nameLen);
|
||||||
|
byte[] extra = br.ReadBytes(extraLen);
|
||||||
|
if (commentLen > 0) br.ReadBytes(commentLen);
|
||||||
|
|
||||||
|
// ZIP64 extended-information extra field (id 0x0001): 8-byte
|
||||||
|
// values present, in order, only for headers that were 0xFFFFFFFF.
|
||||||
|
int p = 0;
|
||||||
|
while (p + 4 <= extra.Length)
|
||||||
|
{
|
||||||
|
ushort id = BitConverter.ToUInt16(extra, p);
|
||||||
|
ushort sz = BitConverter.ToUInt16(extra, p + 2);
|
||||||
|
if (id == 0x0001)
|
||||||
|
{
|
||||||
|
int q = p + 4;
|
||||||
|
if (uncomp == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { uncomp = BitConverter.ToInt64(extra, q); q += 8; }
|
||||||
|
if (comp == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { comp = BitConverter.ToInt64(extra, q); q += 8; }
|
||||||
|
if (lho == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { lho = BitConverter.ToInt64(extra, q); }
|
||||||
|
}
|
||||||
|
p += 4 + sz;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool utf8 = (flags & 0x0800) != 0;
|
||||||
|
var name = (utf8 ? Encoding.UTF8 : Encoding.Default).GetString(nameBytes);
|
||||||
|
|
||||||
|
list.Add(new Entry
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Method = method,
|
||||||
|
CompressedSize = comp,
|
||||||
|
LocalHeaderOffset = lho
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry extraction ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void ExtractEntry(FileStream fs, Entry e, string destRoot)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(e.Name)) return;
|
||||||
|
|
||||||
|
string destPath;
|
||||||
|
try { destPath = Path.GetFullPath(Path.Combine(destRoot, e.Name.Replace('/', '\\'))); }
|
||||||
|
catch { return; } // illegal characters in name — skip
|
||||||
|
|
||||||
|
// Zip-slip protection: never write outside the destination root.
|
||||||
|
if (!destPath.StartsWith(destRoot + "\\", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !destPath.Equals(destRoot, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (e.Name.EndsWith("/") || e.Name.EndsWith("\\"))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(destPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local header: its name/extra lengths can differ from the central
|
||||||
|
// directory's, so read them from the local header itself.
|
||||||
|
fs.Seek(e.LocalHeaderOffset, SeekOrigin.Begin);
|
||||||
|
var lh = new byte[30];
|
||||||
|
ReadExact(fs, lh, 30);
|
||||||
|
if (!(lh[0] == 0x50 && lh[1] == 0x4B && lh[2] == 0x03 && lh[3] == 0x04))
|
||||||
|
throw new IOException("Corrupt local header for entry '" + e.Name + "'.");
|
||||||
|
int lNameLen = BitConverter.ToUInt16(lh, 26);
|
||||||
|
int lExtraLen = BitConverter.ToUInt16(lh, 28);
|
||||||
|
fs.Seek(lNameLen + lExtraLen, SeekOrigin.Current);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(destPath));
|
||||||
|
using (var outFs = File.Create(destPath))
|
||||||
|
{
|
||||||
|
if (e.Method == 0) // stored
|
||||||
|
{
|
||||||
|
CopyExactly(fs, outFs, e.CompressedSize);
|
||||||
|
}
|
||||||
|
else if (e.Method == 8) // deflate
|
||||||
|
{
|
||||||
|
var limited = new LimitedReadStream(fs, e.CompressedSize);
|
||||||
|
using (var inflate = new DeflateStream(limited, CompressionMode.Decompress, leaveOpen: true))
|
||||||
|
inflate.CopyTo(outFs);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new IOException(string.Format(
|
||||||
|
"Unsupported compression method {0} for entry '{1}'.", e.Method, e.Name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyExactly(Stream src, Stream dst, long count)
|
||||||
|
{
|
||||||
|
var buffer = new byte[65536];
|
||||||
|
while (count > 0)
|
||||||
|
{
|
||||||
|
int n = src.Read(buffer, 0, (int)Math.Min(buffer.Length, count));
|
||||||
|
if (n == 0) throw new IOException("Unexpected end of archive.");
|
||||||
|
dst.Write(buffer, 0, n);
|
||||||
|
count -= n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReadExact(Stream stream, byte[] buf, int count)
|
||||||
|
{
|
||||||
|
int off = 0;
|
||||||
|
while (off < count)
|
||||||
|
{
|
||||||
|
int n = stream.Read(buf, off, count - off);
|
||||||
|
if (n == 0) throw new IOException("Unexpected end of archive.");
|
||||||
|
off += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Caps reads at N bytes of the underlying stream so DeflateStream's
|
||||||
|
/// read-ahead cannot consume the next entry's bytes. Does not own the inner
|
||||||
|
/// stream.</summary>
|
||||||
|
private sealed class LimitedReadStream : Stream
|
||||||
|
{
|
||||||
|
private readonly Stream _inner;
|
||||||
|
private long _remaining;
|
||||||
|
|
||||||
|
public LimitedReadStream(Stream inner, long limit)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
_remaining = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int Read(byte[] buffer, int offset, int count)
|
||||||
|
{
|
||||||
|
if (_remaining <= 0) return 0;
|
||||||
|
int n = _inner.Read(buffer, offset, (int)Math.Min(count, _remaining));
|
||||||
|
_remaining -= n;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanRead => true;
|
||||||
|
public override bool CanWrite => false;
|
||||||
|
public override bool CanSeek => false;
|
||||||
|
public override void Flush() { }
|
||||||
|
public override long Length => throw new NotSupportedException();
|
||||||
|
public override long Position
|
||||||
|
{
|
||||||
|
get => throw new NotSupportedException();
|
||||||
|
set => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||||
|
public override void SetLength(long value) => throw new NotSupportedException();
|
||||||
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,33 @@
|
|||||||
# TeslaLauncher
|
# TeslaLauncher (XP11 — single binary)
|
||||||
|
|
||||||
.NET Framework 4.8 (framework-dependent) rewrite of the original Elsewhen Studios LLC software (Windows 2000 / .NET Framework 2.0). net48 ships in Windows 10/11, so the pod needs no separate runtime install and the package stays small (~3.7 MB).
|
.NET Framework **4.0** (framework-dependent) rewrite of the original Elsewhen Studios LLC
|
||||||
|
software. One `TeslaLauncher.exe` runs on **Windows XP SP3 through Windows 11**:
|
||||||
|
net40 is the newest framework XP can install, and net40 assemblies load in-place on the
|
||||||
|
4.8 runtime built into Windows 10/11. XP pods need the .NET 4.0 redistributable
|
||||||
|
(installed automatically by `install.bat` when `dotnet40\` is in the package);
|
||||||
|
Win10/11 pods need nothing extra.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
TeslaLauncher has three components that work together:
|
One userland tray application — no Windows Service, no IPC.
|
||||||
|
|
||||||
### TeslaLauncherService (Windows Service, Session 0)
|
The original Elsewhen software was a single service (on Win2k/XP a service could still
|
||||||
- Runs at boot before any user logs in
|
touch the desktop). The modern rewrite split it into Service + Agent purely to work
|
||||||
|
around Vista+ **Session 0 isolation**. XP11 closes the loop: everything runs in the
|
||||||
|
auto-logged-in kiosk session, where the desktop, audio, and game processes live anyway.
|
||||||
|
|
||||||
|
### TeslaLauncher (WinForms tray app, user session)
|
||||||
- Listens on **TCP 53290** for OFB-encrypted framed-JSON RPC from TeslaConsole
|
- Listens on **TCP 53290** for OFB-encrypted framed-JSON RPC from TeslaConsole
|
||||||
- Forwards commands to the Agent via Named Pipe (`TeslaLauncherIPC`)
|
- Handles first-boot network configuration (SecureConfig) and shows the
|
||||||
- Handles first-boot network configuration (SecureConfig)
|
Request ID / Passphrase on screen + COM2 plasma
|
||||||
- Handles game file transfers from the Console (InstallProduct)
|
- Handles game file transfers from the Console (InstallProduct → `C:\Games`,
|
||||||
|
`postinstall.bat`, `pre-uninstall.bat` on uninstall)
|
||||||
|
- Launches/kills/watches simulation apps, controls volume, manages `LaunchApps.xml`
|
||||||
|
- Registers with WER for restart-after-crash (Vista+; no-op on XP)
|
||||||
|
|
||||||
### TeslaLauncherAgent (WinForms tray app, user session)
|
Requires the kiosk account (`Firestorm`) to be in **Administrators** — SecureConfig's
|
||||||
- Runs in the logged-in user's desktop session
|
`netsh`/hostname writes and product `postinstall.bat` driver installs need the admin
|
||||||
- Executes commands that require desktop access: launching/killing apps, volume control
|
token (UAC is disabled by the installer on modern Windows, so no prompts).
|
||||||
- Manages `LaunchApps.xml` (installed games registry)
|
|
||||||
- On first boot, displays SecureConfig Request ID and Passphrase
|
|
||||||
|
|
||||||
### SecureConfig (first-boot protocol)
|
### SecureConfig (first-boot protocol)
|
||||||
- Assigns a temporary IP and broadcasts a UDP beacon so the Console can discover the pod
|
- Assigns a temporary IP and broadcasts a UDP beacon so the Console can discover the pod
|
||||||
@@ -26,29 +36,25 @@ TeslaLauncher has three components that work together:
|
|||||||
- TCP handshake establishes an OFB-encrypted session with RSA key exchange
|
- TCP handshake establishes an OFB-encrypted session with RSA key exchange
|
||||||
- Session key is saved for all subsequent Console connections
|
- Session key is saved for all subsequent Console connections
|
||||||
|
|
||||||
|
Uses the old-style `netsh interface ip` commands throughout — they update the live
|
||||||
|
TCP/IP stack immediately and are the only form XP understands.
|
||||||
|
|
||||||
## Communication Flow
|
## Communication Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
TeslaConsole ──TCP 53290 (OFB + framed JSON)──> TeslaLauncherService
|
TeslaConsole ──TCP 53290 (OFB + framed JSON)──> TeslaLauncher.exe (user session)
|
||||||
│
|
|
||||||
Named Pipe (JSON)
|
|
||||||
│
|
|
||||||
v
|
|
||||||
TeslaLauncherAgent
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `TeslaLauncherService.cs` | Windows Service implementation |
|
| `TeslaLauncher.cs` | The whole launcher: tray, TCP listener, RPC dispatch, install, processes, volume |
|
||||||
| `TeslaLauncherService.csproj` | Service project (net48, generic host + Windows service) |
|
| `TeslaLauncher.csproj` | net40 WinForms exe project |
|
||||||
| `TeslaLauncherAgent.cs` | Userspace Agent implementation |
|
| `MiniZip.cs` | Central-directory ZIP extractor (net40 has no `ZipFile`; stored + deflate + ZIP64) |
|
||||||
| `TeslaLauncherAgent.csproj` | Agent project (WinForms, net48) |
|
| `SecureConfig.cs` | First-boot secure configuration protocol + OFB duplex stream |
|
||||||
| `LaunchModels_Shared.cs` | Service↔Agent IPC types (Tesla.Launcher.Shared). Wire types (Tesla.Net) now come from `../Contract/Tesla.Contract.csproj` |
|
| `build.bat` | Builds + assembles the package |
|
||||||
| `SecureConfig.cs` | First-boot secure configuration protocol |
|
| `install.bat` | Dual-OS installer (XP SP3 and Win10/11 code paths; run as Administrator) |
|
||||||
| `build.bat` | Builds both components |
|
|
||||||
| `install.bat` | Installs on a cockpit PC (run as Administrator) |
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -57,34 +63,47 @@ Requirements:
|
|||||||
- Internet access for NuGet restore (first build only)
|
- Internet access for NuGet restore (first build only)
|
||||||
|
|
||||||
```
|
```
|
||||||
build.bat :: build both components + assemble the package
|
build.bat :: build + assemble the package
|
||||||
build.bat /service :: build Service only
|
|
||||||
build.bat /agent :: build Agent only
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Output goes to `dist\TeslaLauncher\` (with `Service\` and `Agent\` subdirectories plus
|
Output goes to `dist\TeslaLauncher\` (with `App\` plus `install.bat` and redist
|
||||||
`install.bat`) and `dist\TeslaLauncher-podpkg.zip`, mirroring `Console\dist\`. The projects
|
folders) and `dist\TeslaLauncher-podpkg.zip`. The project is published in place
|
||||||
are published in place (framework-dependent net48) — they reference `../Contract`, so they
|
(framework-dependent net40) — it references `../Contract`, so it cannot be staged
|
||||||
cannot be staged into a temp folder. Each folder holds the exe plus its dependency DLLs;
|
into a temp folder. `App\` holds the exe plus `Newtonsoft.Json.dll` and
|
||||||
the target pod needs only .NET Framework 4.8 (built into Windows 10/11), no bundled runtime.
|
`TeslaConsoleLaunchLib.dll` (the net40 leg of the shared contract).
|
||||||
|
|
||||||
|
### Bench-testing switches
|
||||||
|
|
||||||
|
```
|
||||||
|
TeslaLauncher.exe /skipconfig :: skip the DHCP SecureConfig gate
|
||||||
|
TeslaLauncher.exe /port:53291 :: listen on a non-standard port
|
||||||
|
```
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. Copy the `TeslaLauncher\` folder to each cockpit PC
|
1. Copy the `TeslaLauncher\` folder to each cockpit PC (XP SP3 or Win10/11)
|
||||||
2. Run `TeslaLauncher\install.bat` as Administrator
|
2. Run `TeslaLauncher\install.bat` as Administrator
|
||||||
|
|
||||||
The installer:
|
The installer detects the OS and branches where the tooling differs:
|
||||||
- Registers the Service (delayed auto-start)
|
|
||||||
- Configures the Agent for auto-login startup
|
| Step | XP SP3 | Windows 10/11 |
|
||||||
- Installs OpenAL and DirectX runtimes
|
|------|--------|---------------|
|
||||||
- Enables SMB1 file sharing
|
| .NET | installs 4.0 redist from `dotnet40\` if missing | 4.8 built in — nothing |
|
||||||
- Creates `C:\Games` with appropriate permissions
|
| ACLs | `cacls` | `icacls` |
|
||||||
- Resets network adapters to DHCP for SecureConfig
|
| Firewall off | `netsh firewall` | `netsh advfirewall` |
|
||||||
|
| SMB1 / DirectPlay | native — skipped | `dism /Enable-Feature` |
|
||||||
|
| DHCP reset | `netsh interface ip set address ... dhcp` | PowerShell `Set-NetIPInterface` |
|
||||||
|
| Notifications / UAC | n/a | policy keys + `EnableLUA=0` |
|
||||||
|
| UltraVNC | `UltraVNC_x86_Setup.exe` (if bundled) | `UltraVNC_x64_Setup.exe` |
|
||||||
|
|
||||||
|
Common to both: auto-login (`Firestorm`), HKLM Run key for the launcher
|
||||||
|
(**no service registration**), `C:\Games` + data dir creation, shares, workgroup,
|
||||||
|
power settings, reboot.
|
||||||
|
|
||||||
## First Boot
|
## First Boot
|
||||||
|
|
||||||
1. Cockpit boots with DHCP (unconfigured state)
|
1. Cockpit boots with DHCP (unconfigured state), auto-logs into the kiosk account
|
||||||
2. Service runs SecureConfig: broadcasts beacon, displays codes on screen
|
2. Launcher runs SecureConfig: broadcasts beacon, displays codes on screen + plasma
|
||||||
3. Console operator sees the pod's Request ID and enters the Passphrase
|
3. Console operator sees the pod's Request ID and enters the Passphrase
|
||||||
4. Console sends encrypted network configuration
|
4. Console sends encrypted network configuration
|
||||||
5. Pod applies the configuration and is ready for normal operation
|
5. Pod applies the configuration and is ready for normal operation
|
||||||
@@ -100,21 +119,27 @@ The Console connects to each configured pod on TCP 53290 and can:
|
|||||||
|
|
||||||
## Key Paths
|
## Key Paths
|
||||||
|
|
||||||
|
`<CommonAppData>` is `C:\ProgramData` on Vista+, and
|
||||||
|
`C:\Documents and Settings\All Users\Application Data` on XP — the launcher and
|
||||||
|
installer both resolve it per-OS; nothing hardcodes `C:\ProgramData` anymore.
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `C:\ProgramData\TeslaLauncher\TeslaKeyStore.key` | Session key (32 bytes) |
|
| `<CommonAppData>\TeslaLauncher\TeslaKeyStore.key` | Session key (32 bytes) |
|
||||||
| `C:\ProgramData\TeslaLauncher\LaunchApps.xml` | Installed games registry |
|
| `<CommonAppData>\TeslaLauncher\LaunchApps.xml` | Installed games registry (same XML shape as the two-process Agent wrote) |
|
||||||
| `C:\ProgramData\TeslaLauncher\configuring.json` | Transient: SecureConfig codes for Agent display |
|
| `<CommonAppData>\TeslaLauncher\podconf.log` | Launcher log (was next to the exe pre-XP11) |
|
||||||
|
| `<CommonAppData>\TeslaLauncher\configuring.json` | Transient: SecureConfig codes (kept for external diagnostics) |
|
||||||
| `C:\Games\` | Game installation directory |
|
| `C:\Games\` | Game installation directory |
|
||||||
|
|
||||||
## Wire Protocol
|
## Wire Protocol
|
||||||
|
|
||||||
The Console talks to the Service with **length-prefixed System.Text.Json frames**
|
The Console talks to the launcher with **length-prefixed JSON frames** over the
|
||||||
over the OFB-encrypted TCP stream (dispatch by method name) — see
|
OFB-encrypted TCP stream (dispatch by method name) — see
|
||||||
`../Contract/PodRpcProtocol.cs`, shared by both ends. This replaced the original
|
`../Contract/PodRpcProtocol.cs`, shared by both ends. Since the whole suite went
|
||||||
`BinaryFormatter` + serialized-`MethodBase` scheme. The `Tesla.Net` wire types now
|
net40 (XP11), both ends serialize with Newtonsoft.Json and the Contract is
|
||||||
live in `../Contract/Tesla.Contract.csproj`, the single source of truth shared with
|
net40-only (its former net48/System.Text.Json leg wrote shape-identical JSON and
|
||||||
the Console.
|
was dropped once the Console moved to net40). The request reader keeps date
|
||||||
|
strings raw so a Ping echo returns byte-identical.
|
||||||
|
|
||||||
The Service-to-Agent IPC uses length-prefixed JSON over a Named Pipe, with flat types
|
Volume on XP falls back from CoreAudio (Vista+) to `nircmd.exe` / winmm
|
||||||
that avoid the nested struct layout of the wire format.
|
`waveOutSetVolume`.
|
||||||
|
|||||||
@@ -416,7 +416,10 @@ namespace TeslaSecureConfig
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
(_adapterIndex, _adapterId, _adapterName) = FindFirstEthernetAdapter();
|
var adapter = FindFirstEthernetAdapter();
|
||||||
|
_adapterIndex = adapter.Index;
|
||||||
|
_adapterId = adapter.Id;
|
||||||
|
_adapterName = adapter.Name;
|
||||||
}
|
}
|
||||||
_log = logger ?? (s => Debug.WriteLine(s));
|
_log = logger ?? (s => Debug.WriteLine(s));
|
||||||
}
|
}
|
||||||
@@ -845,23 +848,23 @@ namespace TeslaSecureConfig
|
|||||||
Log("Secure connection to console negotiated.");
|
Log("Secure connection to console negotiated.");
|
||||||
|
|
||||||
// ── 4. RSA key exchange ─────────────────────────────────
|
// ── 4. RSA key exchange ─────────────────────────────────
|
||||||
using var rsa = RSA.Create(Proto.RsaKeySize);
|
// net40: RSA.Create(int) and RSAEncryptionPadding are net46+.
|
||||||
|
// RSACryptoServiceProvider.Decrypt(data, false) is the same
|
||||||
|
// PKCS#1 v1.5 padding, and works on XP SP3's CAPI.
|
||||||
|
using var rsa = new RSACryptoServiceProvider(Proto.RsaKeySize);
|
||||||
|
|
||||||
Log("Sending console final key.");
|
Log("Sending console final key.");
|
||||||
using (var bw = new BinaryWriter(ofb, Encoding.UTF8, leaveOpen: true))
|
// net40 BinaryWriter/Reader have no leaveOpen overload; not
|
||||||
{
|
// disposing them keeps the OFB stream open, which is the intent.
|
||||||
|
var bw = new BinaryWriter(ofb, Encoding.UTF8);
|
||||||
bw.Write(rsa.ToXmlString(false)); // public key only
|
bw.Write(rsa.ToXmlString(false)); // public key only
|
||||||
bw.Flush();
|
bw.Flush();
|
||||||
}
|
|
||||||
|
|
||||||
Log("Receiving console key.");
|
Log("Receiving console key.");
|
||||||
byte[] sessionKey;
|
var br = new BinaryReader(ofb, Encoding.UTF8);
|
||||||
using (var br = new BinaryReader(ofb, Encoding.UTF8, leaveOpen: true))
|
|
||||||
{
|
|
||||||
int encLen = br.ReadInt32();
|
int encLen = br.ReadInt32();
|
||||||
byte[] enc = br.ReadBytes(encLen);
|
byte[] enc = br.ReadBytes(encLen);
|
||||||
sessionKey = rsa.Decrypt(enc, RSAEncryptionPadding.Pkcs1);
|
byte[] sessionKey = rsa.Decrypt(enc, false); // PKCS#1 v1.5
|
||||||
}
|
|
||||||
Log($"Console key received ({sessionKey.Length} bytes).");
|
Log($"Console key received ({sessionKey.Length} bytes).");
|
||||||
|
|
||||||
// Store session key — used for OFB on the management port (53290)
|
// Store session key — used for OFB on the management port (53290)
|
||||||
@@ -945,11 +948,12 @@ namespace TeslaSecureConfig
|
|||||||
_plasma.WriteLine("Passphrase: {0}", _passphrase);
|
_plasma.WriteLine("Passphrase: {0}", _passphrase);
|
||||||
|
|
||||||
#if WINFORMS
|
#if WINFORMS
|
||||||
// WinForms dialog — only shown when running as the Agent (user session)
|
// WinForms dialog on its own STA thread. Do NOT call
|
||||||
|
// SetCompatibleTextRenderingDefault here: the single-binary launcher
|
||||||
|
// already created its tray form, and calling it after any control
|
||||||
|
// exists throws InvalidOperationException.
|
||||||
var t = new Thread(() =>
|
var t = new Thread(() =>
|
||||||
{
|
{
|
||||||
Application.EnableVisualStyles();
|
|
||||||
Application.SetCompatibleTextRenderingDefault(false);
|
|
||||||
_displayForm = new PasscodeDisplayForm(_requestId, _passphrase);
|
_displayForm = new PasscodeDisplayForm(_requestId, _passphrase);
|
||||||
_displayForm.ShowDialog();
|
_displayForm.ShowDialog();
|
||||||
})
|
})
|
||||||
@@ -1129,10 +1133,18 @@ namespace TeslaSecureConfig
|
|||||||
Log($"Warning: target IP {targetIp} not on any local subnet — keeping [{_adapterIndex}] {_adapterName}");
|
Log($"Warning: target IP {targetIp} not on any local subnet — keeping [{_adapterIndex}] {_adapterName}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns (IPv4 interface index, adapter GUID).
|
// IPv4 interface index + adapter GUID + display name (a struct instead of a
|
||||||
// Index is used with `netsh interface ipv4 ... interface=N` (avoids name-quoting issues).
|
// ValueTuple — net40 has no System.ValueTuple).
|
||||||
// GUID is used for direct registry writes to persist static IP across reboots.
|
// Index avoids netsh name-quoting issues; GUID is used for direct registry
|
||||||
private static (int index, string id, string name) FindFirstEthernetAdapter()
|
// writes to persist the static IP across reboots.
|
||||||
|
private struct AdapterInfo
|
||||||
|
{
|
||||||
|
public int Index;
|
||||||
|
public string Id;
|
||||||
|
public string Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdapterInfo FindFirstEthernetAdapter()
|
||||||
{
|
{
|
||||||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
||||||
{
|
{
|
||||||
@@ -1150,7 +1162,7 @@ namespace TeslaSecureConfig
|
|||||||
{
|
{
|
||||||
int idx = nic.GetIPProperties().GetIPv4Properties().Index;
|
int idx = nic.GetIPProperties().GetIPv4Properties().Index;
|
||||||
Log2($"Selected physical adapter: [{idx}] {nic.Name} / {nic.Description}");
|
Log2($"Selected physical adapter: [{idx}] {nic.Name} / {nic.Description}");
|
||||||
return (idx, nic.Id, nic.Name);
|
return new AdapterInfo { Index = idx, Id = nic.Id, Name = nic.Name };
|
||||||
}
|
}
|
||||||
catch (NetworkInformationException) { }
|
catch (NetworkInformationException) { }
|
||||||
}
|
}
|
||||||
@@ -1165,7 +1177,7 @@ namespace TeslaSecureConfig
|
|||||||
string.Equals(nic.Description, displayName, StringComparison.OrdinalIgnoreCase))
|
string.Equals(nic.Description, displayName, StringComparison.OrdinalIgnoreCase))
|
||||||
return nic.Id;
|
return nic.Id;
|
||||||
}
|
}
|
||||||
return FindFirstEthernetAdapter().id; // .name ignored in fallback
|
return FindFirstEthernetAdapter().Id; // Name ignored in fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find index for a named adapter (fallback when caller passes a display name)
|
// Find index for a named adapter (fallback when caller passes a display name)
|
||||||
@@ -1181,7 +1193,7 @@ namespace TeslaSecureConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fall back to auto-detect if name not matched
|
// Fall back to auto-detect if name not matched
|
||||||
return FindFirstEthernetAdapter().index; // .name ignored in fallback
|
return FindFirstEthernetAdapter().Index; // Name ignored in fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Random string generator ---
|
// --- Random string generator ---
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!-- ===========================================================================
|
||||||
|
XP11 single-binary launcher.
|
||||||
|
|
||||||
|
TargetFramework net40 is deliberate: it is the newest .NET Framework that
|
||||||
|
installs on Windows XP SP3, and net40 assemblies load in-place on the 4.8
|
||||||
|
runtime that ships in Windows 10/11 — so this ONE exe covers XP SP3
|
||||||
|
through Windows 11. Everything net45+ is off-limits here:
|
||||||
|
- System.Text.Json -> Newtonsoft.Json (Tesla.Contract)
|
||||||
|
- ZipFile/ZipArchive -> MiniZip.cs
|
||||||
|
- async/await, Task.Run -> threads
|
||||||
|
- RSA.Create(int) -> RSACryptoServiceProvider
|
||||||
|
=========================================================================== -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net40</TargetFramework>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Version>4.11.4.3</Version>
|
||||||
|
<ApplicationIcon>app.ico</ApplicationIcon>
|
||||||
|
<AssemblyName>TeslaLauncher</AssemblyName>
|
||||||
|
<RootNamespace>Tesla.Launcher</RootNamespace>
|
||||||
|
<StartupObject>Tesla.Launcher.LauncherApplication</StartupObject>
|
||||||
|
<!-- SecureConfig.cs shows the PasscodeDisplayForm in-process. -->
|
||||||
|
<DefineConstants>$(DefineConstants);WINFORMS</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||||
|
<!-- WinForms via plain framework references: UseWindowsForms is not wired up
|
||||||
|
for net40, and all UI here is code-built (no designer). -->
|
||||||
|
<Reference Include="System.Windows.Forms" />
|
||||||
|
<Reference Include="System.Drawing" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Contract\Tesla.Contract.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -2,9 +2,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
|||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.5.2.0
|
VisualStudioVersion = 17.5.2.0
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherAgent", "TeslaLauncherAgent.csproj", "{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncher", "TeslaLauncher.csproj", "{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}"
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherService", "TeslaLauncherService.csproj", "{37D7E6C3-8DFD-CCB8-AFDF-18F2C0A48862}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -16,10 +14,6 @@ Global
|
|||||||
{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}.Release|Any CPU.Build.0 = Release|Any CPU
|
{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{37D7E6C3-8DFD-CCB8-AFDF-18F2C0A48862}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{37D7E6C3-8DFD-CCB8-AFDF-18F2C0A48862}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{37D7E6C3-8DFD-CCB8-AFDF-18F2C0A48862}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{37D7E6C3-8DFD-CCB8-AFDF-18F2C0A48862}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -1,989 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// TeslaLauncher — Userspace Agent
|
|
||||||
// =============================================================================
|
|
||||||
// Runs as a WinForms tray application in the logged-in user's desktop session.
|
|
||||||
// On first boot (unconfigured machine), displays a splash with the SecureConfig
|
|
||||||
// Request ID and Passphrase so the operator can read them to the console.
|
|
||||||
// On subsequent boots, listens on a Named Pipe for commands from
|
|
||||||
// TeslaLauncherService and manages simulation applications.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Drawing;
|
|
||||||
using System.IO;
|
|
||||||
using System.IO.IsolatedStorage;
|
|
||||||
using System.IO.Pipes;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.NetworkInformation;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Windows.Forms;
|
|
||||||
using Tesla.Launcher.Shared;
|
|
||||||
|
|
||||||
namespace Tesla.Launcher.Agent
|
|
||||||
{
|
|
||||||
public class AgentApplication : Form
|
|
||||||
{
|
|
||||||
// ── Configuration ─────────────────────────────────────────────────────
|
|
||||||
private const string PIPE_NAME = "TeslaLauncherIPC";
|
|
||||||
private const string CONFIG_FILE = @"C:\ProgramData\TeslaLauncher\LaunchApps.xml";
|
|
||||||
private const string GAMES_DIR = @"C:\Games";
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private NotifyIcon _trayIcon;
|
|
||||||
private ContextMenuStrip _trayMenu;
|
|
||||||
|
|
||||||
private List<LaunchData> _launchApps = new();
|
|
||||||
private readonly Dictionary<string, List<Process>> _runningProcesses = new();
|
|
||||||
private readonly object _processLock = new();
|
|
||||||
private readonly List<Thread> _watcherThreads = new();
|
|
||||||
private bool _stopping = false;
|
|
||||||
|
|
||||||
private readonly Dictionary<string, OutOfBandProgress> _installProgress = new();
|
|
||||||
private readonly object _installLock = new();
|
|
||||||
|
|
||||||
private Thread _pipeThread;
|
|
||||||
private CancellationTokenSource _cts = new();
|
|
||||||
|
|
||||||
// ── Entry point ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[STAThread]
|
|
||||||
public static void Main()
|
|
||||||
{
|
|
||||||
Application.EnableVisualStyles();
|
|
||||||
Application.SetCompatibleTextRenderingDefault(false);
|
|
||||||
|
|
||||||
// First-boot check: show the configuring splash (with the SecureConfig
|
|
||||||
// Request ID + Passphrase) when the machine is unconfigured OR when
|
|
||||||
// SecureConfig is actively running.
|
|
||||||
//
|
|
||||||
// The second condition is essential: during SecureConfig the Service
|
|
||||||
// assigns a TEMPORARY static IP to the Ethernet adapter so it can
|
|
||||||
// broadcast. That makes IsMachineConfigured() report true, so the DHCP
|
|
||||||
// check ALONE would skip the splash and the codes would never reach the
|
|
||||||
// screen — they'd only be in the log / on the COM2 plasma display. The
|
|
||||||
// Service writes configuring.json *before* assigning that temp IP, so its
|
|
||||||
// presence is the authoritative "SecureConfig in progress" signal.
|
|
||||||
if (!IsMachineConfigured() || File.Exists(ConfiguringFilePath()))
|
|
||||||
{
|
|
||||||
ShowConfiguringWait(); // blocks until config completes or form closed
|
|
||||||
|
|
||||||
// If still not configured (config failed / user closed splash), exit.
|
|
||||||
if (!IsMachineConfigured())
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Application.Run(new AgentApplication());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Secure Configuration ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns true when the cockpit has already been configured —
|
|
||||||
/// detected by the Ethernet adapter having a static IP assignment.
|
|
||||||
/// SecureConfiguration writes the permanent address via netsh with
|
|
||||||
/// no DHCP, so a static adapter means configuration is complete.
|
|
||||||
/// A DHCP adapter (or no adapter) means we are on a fresh machine
|
|
||||||
/// and need to run the SecureConfiguration protocol.
|
|
||||||
/// </summary>
|
|
||||||
private static bool IsMachineConfigured()
|
|
||||||
{
|
|
||||||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
|
||||||
{
|
|
||||||
if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
|
|
||||||
if (nic.OperationalStatus != OperationalStatus.Up) continue;
|
|
||||||
if (IsVirtualAdapter(nic)) continue;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var ipv4 = nic.GetIPProperties().GetIPv4Properties();
|
|
||||||
// IsDhcpEnabled == false → static IP → already configured
|
|
||||||
if (!ipv4.IsDhcpEnabled)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (NetworkInformationException)
|
|
||||||
{
|
|
||||||
// GetIPv4Properties() throws if IPv4 is not configured at all;
|
|
||||||
// treat that the same as DHCP (i.e. not yet configured).
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false; // all Ethernet adapters are DHCP or absent → needs configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Shared file the Service writes (before assigning the temp IP) while
|
|
||||||
/// SecureConfig is in progress; holds the RequestId/Passphrase for the splash.</summary>
|
|
||||||
private static string ConfiguringFilePath() => Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
|
||||||
"TeslaLauncher", "configuring.json");
|
|
||||||
|
|
||||||
private static bool IsVirtualAdapter(NetworkInterface nic)
|
|
||||||
{
|
|
||||||
var desc = nic.Description ?? "";
|
|
||||||
var name = nic.Name ?? "";
|
|
||||||
string[] markers = {
|
|
||||||
"Virtual", "Hyper-V", "VMware", "VirtualBox",
|
|
||||||
"Loopback", "Tunnel", "Miniport", "Wi-Fi Direct",
|
|
||||||
"Bluetooth", "WAN Miniport", "Microsoft Kernel Debug"
|
|
||||||
};
|
|
||||||
foreach (var m in markers)
|
|
||||||
if (desc.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
||||||
name.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
||||||
return true;
|
|
||||||
var mac = nic.GetPhysicalAddress().GetAddressBytes();
|
|
||||||
if (mac.Length == 6 && (mac[0] & 0x02) != 0) return true; // locally-administered MAC
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ShowConfiguringWait()
|
|
||||||
{
|
|
||||||
// The Service handles SecureConfig in Session 0. The Agent shows
|
|
||||||
// a splash with the RequestId and Passphrase for the operator.
|
|
||||||
using var form = new Form
|
|
||||||
{
|
|
||||||
Text = "Tesla Cockpit Configuration",
|
|
||||||
FormBorderStyle = FormBorderStyle.FixedDialog,
|
|
||||||
StartPosition = FormStartPosition.CenterScreen,
|
|
||||||
ClientSize = new System.Drawing.Size(480, 260),
|
|
||||||
MaximizeBox = false,
|
|
||||||
MinimizeBox = false,
|
|
||||||
TopMost = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
var lblStatus = new Label
|
|
||||||
{
|
|
||||||
Text = "This cockpit is being configured.\r\n" +
|
|
||||||
"Configuration will complete automatically.",
|
|
||||||
AutoSize = false,
|
|
||||||
Dock = DockStyle.Top,
|
|
||||||
Height = 50,
|
|
||||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
|
||||||
Font = new System.Drawing.Font("Arial", 10f),
|
|
||||||
Padding = new Padding(12, 8, 12, 0),
|
|
||||||
};
|
|
||||||
form.Controls.Add(lblStatus);
|
|
||||||
|
|
||||||
var lblRequestIdTitle = new Label
|
|
||||||
{
|
|
||||||
Text = "Request ID:",
|
|
||||||
AutoSize = false,
|
|
||||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
|
||||||
Font = new System.Drawing.Font("Arial", 11f),
|
|
||||||
Top = 60,
|
|
||||||
Left = 0,
|
|
||||||
Width = 480,
|
|
||||||
Height = 25,
|
|
||||||
};
|
|
||||||
form.Controls.Add(lblRequestIdTitle);
|
|
||||||
|
|
||||||
var lblRequestId = new Label
|
|
||||||
{
|
|
||||||
Text = "waiting...",
|
|
||||||
AutoSize = false,
|
|
||||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
|
||||||
Font = new System.Drawing.Font("Consolas", 28f, System.Drawing.FontStyle.Bold),
|
|
||||||
ForeColor = System.Drawing.Color.DarkBlue,
|
|
||||||
Top = 85,
|
|
||||||
Left = 0,
|
|
||||||
Width = 480,
|
|
||||||
Height = 45,
|
|
||||||
};
|
|
||||||
form.Controls.Add(lblRequestId);
|
|
||||||
|
|
||||||
var lblPassphraseTitle = new Label
|
|
||||||
{
|
|
||||||
Text = "Passphrase:",
|
|
||||||
AutoSize = false,
|
|
||||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
|
||||||
Font = new System.Drawing.Font("Arial", 11f),
|
|
||||||
Top = 140,
|
|
||||||
Left = 0,
|
|
||||||
Width = 480,
|
|
||||||
Height = 25,
|
|
||||||
};
|
|
||||||
form.Controls.Add(lblPassphraseTitle);
|
|
||||||
|
|
||||||
var lblPassphrase = new Label
|
|
||||||
{
|
|
||||||
Text = "waiting...",
|
|
||||||
AutoSize = false,
|
|
||||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
|
||||||
Font = new System.Drawing.Font("Consolas", 28f, System.Drawing.FontStyle.Bold),
|
|
||||||
ForeColor = System.Drawing.Color.DarkRed,
|
|
||||||
Top = 165,
|
|
||||||
Left = 0,
|
|
||||||
Width = 480,
|
|
||||||
Height = 45,
|
|
||||||
};
|
|
||||||
form.Controls.Add(lblPassphrase);
|
|
||||||
|
|
||||||
var lblHint = new Label
|
|
||||||
{
|
|
||||||
Text = "Read the Passphrase to the console operator.",
|
|
||||||
AutoSize = false,
|
|
||||||
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
|
|
||||||
Font = new System.Drawing.Font("Arial", 9f, System.Drawing.FontStyle.Italic),
|
|
||||||
ForeColor = System.Drawing.Color.Gray,
|
|
||||||
Top = 220,
|
|
||||||
Left = 0,
|
|
||||||
Width = 480,
|
|
||||||
Height = 25,
|
|
||||||
};
|
|
||||||
form.Controls.Add(lblHint);
|
|
||||||
|
|
||||||
string lastJson = null;
|
|
||||||
bool sawFile = false;
|
|
||||||
var cfgFile = ConfiguringFilePath();
|
|
||||||
|
|
||||||
// Poll every 2 s — read codes from the Service's shared file.
|
|
||||||
// The file lifecycle:
|
|
||||||
// 1. Service deletes stale file on startup
|
|
||||||
// 2. Service writes fresh file with new codes (before the temp IP)
|
|
||||||
// 3. Agent reads and displays codes
|
|
||||||
// 4. Service deletes file when config is complete → splash closes
|
|
||||||
// If the Agent started before the file appeared, it keeps waiting; it
|
|
||||||
// closes once the file we saw is gone, or the machine becomes configured
|
|
||||||
// (covers the case where config finished before we managed to read it).
|
|
||||||
var timer = new System.Windows.Forms.Timer { Interval = 2000 };
|
|
||||||
timer.Tick += (s, e) =>
|
|
||||||
{
|
|
||||||
if (File.Exists(cfgFile))
|
|
||||||
{
|
|
||||||
sawFile = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = File.ReadAllText(cfgFile);
|
|
||||||
if (json != lastJson)
|
|
||||||
{
|
|
||||||
lastJson = json;
|
|
||||||
var doc = System.Text.Json.JsonDocument.Parse(json);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
if (root.TryGetProperty("RequestId", out var rid))
|
|
||||||
lblRequestId.Text = rid.GetString();
|
|
||||||
if (root.TryGetProperty("Passphrase", out var pp))
|
|
||||||
lblPassphrase.Text = pp.GetString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { /* file may be mid-write, retry next tick */ }
|
|
||||||
}
|
|
||||||
else if (sawFile || IsMachineConfigured())
|
|
||||||
{
|
|
||||||
// File gone after we saw it, or the machine is now configured →
|
|
||||||
// SecureConfig is complete.
|
|
||||||
timer.Stop();
|
|
||||||
form.Close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
timer.Start();
|
|
||||||
|
|
||||||
Application.Run(form);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Form / tray setup ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public AgentApplication()
|
|
||||||
{
|
|
||||||
ShowInTaskbar = false;
|
|
||||||
WindowState = FormWindowState.Minimized;
|
|
||||||
Opacity = 0;
|
|
||||||
|
|
||||||
BuildTrayIcon();
|
|
||||||
LoadLaunchApps();
|
|
||||||
StartPipeServer();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildTrayIcon()
|
|
||||||
{
|
|
||||||
_trayMenu = new ContextMenuStrip();
|
|
||||||
var version = typeof(AgentApplication).Assembly.GetName().Version;
|
|
||||||
_trayMenu.Items.Add($"Tesla Launcher Agent v{version}", null, null).Enabled = false;
|
|
||||||
_trayMenu.Items.Add(new ToolStripSeparator());
|
|
||||||
_trayMenu.Items.Add("Reload Config", null, (s, e) => LoadLaunchApps());
|
|
||||||
_trayMenu.Items.Add("Kill All Apps", null, (s, e) => CmdKillAllApps());
|
|
||||||
_trayMenu.Items.Add(new ToolStripSeparator());
|
|
||||||
_trayMenu.Items.Add("Exit", null, (s, e) => ExitAgent());
|
|
||||||
|
|
||||||
_trayIcon = new NotifyIcon
|
|
||||||
{
|
|
||||||
Text = "Tesla Launcher Agent",
|
|
||||||
Icon = SystemIcons.Application,
|
|
||||||
ContextMenuStrip = _trayMenu,
|
|
||||||
Visible = true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ExitAgent()
|
|
||||||
{
|
|
||||||
_stopping = true;
|
|
||||||
_cts.Cancel();
|
|
||||||
_trayIcon.Visible = false;
|
|
||||||
Application.Exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── LaunchApps.xml ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private void LoadLaunchApps()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!File.Exists(CONFIG_FILE))
|
|
||||||
{
|
|
||||||
_launchApps = new List<LaunchData>();
|
|
||||||
SetTrayStatus("Config not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var xml = new System.Xml.Serialization.XmlSerializer(typeof(List<LaunchData>));
|
|
||||||
using var reader = File.OpenRead(CONFIG_FILE);
|
|
||||||
_launchApps = (List<LaunchData>)xml.Deserialize(reader)
|
|
||||||
?? new List<LaunchData>();
|
|
||||||
|
|
||||||
SetTrayStatus($"{_launchApps.Count} apps configured");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
SetTrayStatus($"Config error: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SaveLaunchApps()
|
|
||||||
{
|
|
||||||
var xml = new System.Xml.Serialization.XmlSerializer(typeof(List<LaunchData>));
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(CONFIG_FILE)!);
|
|
||||||
using var writer = File.CreateText(CONFIG_FILE);
|
|
||||||
xml.Serialize(writer, _launchApps);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetTrayStatus(string status)
|
|
||||||
{
|
|
||||||
if (_trayIcon != null) _trayIcon.Text = $"Tesla Launcher: {status}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Named Pipe Server ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private void StartPipeServer()
|
|
||||||
{
|
|
||||||
_pipeThread = new Thread(PipeServerLoop)
|
|
||||||
{
|
|
||||||
IsBackground = true,
|
|
||||||
Name = "TeslaLauncherPipeServer"
|
|
||||||
};
|
|
||||||
_pipeThread.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PipeServerLoop()
|
|
||||||
{
|
|
||||||
var ct = _cts.Token;
|
|
||||||
|
|
||||||
while (!ct.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var server = new NamedPipeServerStream(
|
|
||||||
PIPE_NAME,
|
|
||||||
PipeDirection.InOut,
|
|
||||||
NamedPipeServerStream.MaxAllowedServerInstances,
|
|
||||||
PipeTransmissionMode.Byte,
|
|
||||||
PipeOptions.Asynchronous);
|
|
||||||
|
|
||||||
server.WaitForConnectionAsync(ct).GetAwaiter().GetResult();
|
|
||||||
HandlePipeRequest(server);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) { break; }
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
if (!ct.IsCancellationRequested) Thread.Sleep(500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandlePipeRequest(NamedPipeServerStream pipe)
|
|
||||||
{
|
|
||||||
IpcResponse response;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lenBuf = new byte[4];
|
|
||||||
ReadExact(pipe, lenBuf);
|
|
||||||
var reqBuf = new byte[BitConverter.ToInt32(lenBuf, 0)];
|
|
||||||
ReadExact(pipe, reqBuf);
|
|
||||||
|
|
||||||
var msg = JsonSerializer.Deserialize<IpcMessage>(
|
|
||||||
Encoding.UTF8.GetString(reqBuf));
|
|
||||||
|
|
||||||
response = ExecuteCommand(msg);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
response = new IpcResponse { Success = false, Message = ex.Message };
|
|
||||||
}
|
|
||||||
|
|
||||||
var resBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(response));
|
|
||||||
pipe.Write(BitConverter.GetBytes(resBytes.Length), 0, 4);
|
|
||||||
pipe.Write(resBytes, 0, resBytes.Length);
|
|
||||||
pipe.Flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ReadExact(Stream stream, byte[] buf)
|
|
||||||
{
|
|
||||||
var off = 0;
|
|
||||||
while (off < buf.Length)
|
|
||||||
{
|
|
||||||
var n = stream.Read(buf, off, buf.Length - off);
|
|
||||||
if (n == 0) throw new IOException("Pipe closed.");
|
|
||||||
off += n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Command Dispatcher ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private IpcResponse ExecuteCommand(IpcMessage msg)
|
|
||||||
{
|
|
||||||
if (msg == null) return Fail("Null message received.");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
object result = msg.Command switch
|
|
||||||
{
|
|
||||||
"PING" => (object)null,
|
|
||||||
"CLEARSTORE" => CmdClearStore(),
|
|
||||||
"LAUNCHAPP" => CmdLaunchApp(msg),
|
|
||||||
"KILLAPP" => CmdKillApp(msg),
|
|
||||||
"KILLALLOFTYPE" => CmdKillAllOfType(msg),
|
|
||||||
"KILLALLAPPS" => CmdKillAllApps(),
|
|
||||||
"GETLAUNCHEDAPPS" => CmdGetLaunchedApps(),
|
|
||||||
"GETLAUNCHABLEAPPS" => CmdGetLaunchableApps(),
|
|
||||||
"GETINSTALLEDAPPS" => CmdGetLaunchableApps(),
|
|
||||||
"REMOVEAPP" => CmdRemoveApp(msg),
|
|
||||||
"INSTALLAPP" => CmdInstallApp(msg),
|
|
||||||
"UNINSTALLAPP" => CmdUninstallApp(msg),
|
|
||||||
"GET_VOLUMELEVEL" => CmdGetVolumeLevel(),
|
|
||||||
"SET_VOLUMELEVEL" => CmdSetVolumeLevel(msg),
|
|
||||||
"FULLUPDATE" => CmdFullUpdate(),
|
|
||||||
"INITIATEINSTALLPRODUCT" => CmdInitiateInstallProduct(msg),
|
|
||||||
"GETOUTOFBANDPROGRESS" => CmdGetOutOfBandProgress(msg),
|
|
||||||
"SHUTDOWN" => CmdShutdown(msg),
|
|
||||||
_ => throw new Exception($"Unknown command: {msg.Command}")
|
|
||||||
};
|
|
||||||
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Fail(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Command Implementations ───────────────────────────────────────────
|
|
||||||
|
|
||||||
private object CmdClearStore()
|
|
||||||
{
|
|
||||||
var store = IsolatedStorageFile.GetMachineStoreForAssembly();
|
|
||||||
foreach (var file in store.GetFileNames("*"))
|
|
||||||
store.DeleteFile(file);
|
|
||||||
LoadLaunchApps();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdLaunchApp(IpcMessage msg)
|
|
||||||
{
|
|
||||||
var key = msg.LaunchKey
|
|
||||||
?? throw new Exception("LaunchApp requires a LaunchKey");
|
|
||||||
|
|
||||||
var app = FindApp(key)
|
|
||||||
?? throw new Exception($"No app configured for key '{key}'");
|
|
||||||
|
|
||||||
// A launch entry can be registered (pushed from the Console) before its game
|
|
||||||
// files are installed. Fail cleanly with an actionable message rather than
|
|
||||||
// surfacing a raw Win32 "file not found" from Process.Start.
|
|
||||||
if (string.IsNullOrWhiteSpace(app.ExeFile) || !File.Exists(app.ExeFile))
|
|
||||||
throw new Exception(
|
|
||||||
$"Cannot launch '{app.DisplayName ?? key}': executable not found at " +
|
|
||||||
$"'{app.ExeFile}'. The product may be registered but not yet installed on this pod.");
|
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = app.ExeFile,
|
|
||||||
Arguments = app.Arguments ?? "",
|
|
||||||
WorkingDirectory = app.WorkingDirectory ?? Path.GetDirectoryName(app.ExeFile),
|
|
||||||
UseShellExecute = false
|
|
||||||
};
|
|
||||||
|
|
||||||
var process = Process.Start(psi)
|
|
||||||
?? throw new Exception($"Process.Start returned null for '{app.ExeFile}'");
|
|
||||||
|
|
||||||
lock (_processLock)
|
|
||||||
{
|
|
||||||
if (!_runningProcesses.ContainsKey(key))
|
|
||||||
_runningProcesses[key] = new List<Process>();
|
|
||||||
_runningProcesses[key].Add(process);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (app.AutoRestart) StartWatcher(app, process);
|
|
||||||
|
|
||||||
SetTrayStatus($"Running: {app.DisplayName ?? key}");
|
|
||||||
return new LaunchedAppData { LaunchKey = key, ProcessId = process.Id };
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdKillApp(IpcMessage msg)
|
|
||||||
{
|
|
||||||
var key = msg.LaunchKey;
|
|
||||||
int? pid = null;
|
|
||||||
|
|
||||||
if (msg.PayloadJson != null)
|
|
||||||
{
|
|
||||||
var parms = JsonSerializer.Deserialize<object[]>(msg.PayloadJson);
|
|
||||||
if (parms?.Length > 1 && parms[1] is JsonElement je)
|
|
||||||
pid = je.ValueKind == JsonValueKind.Number ? je.GetInt32() : (int?)null;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_processLock)
|
|
||||||
{
|
|
||||||
if (!_runningProcesses.TryGetValue(key ?? "", out var procs)) return null;
|
|
||||||
|
|
||||||
var toKill = pid.HasValue
|
|
||||||
? procs.Where(p => p.Id == pid.Value).ToList()
|
|
||||||
: procs.ToList();
|
|
||||||
|
|
||||||
foreach (var p in toKill)
|
|
||||||
{
|
|
||||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
|
||||||
procs.Remove(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (procs.Count == 0) _runningProcesses.Remove(key ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdKillAllOfType(IpcMessage msg)
|
|
||||||
{
|
|
||||||
var key = msg.LaunchKey ?? throw new Exception("KillAllOfType requires LaunchKey");
|
|
||||||
|
|
||||||
lock (_processLock)
|
|
||||||
{
|
|
||||||
if (_runningProcesses.TryGetValue(key, out var procs))
|
|
||||||
{
|
|
||||||
foreach (var p in procs)
|
|
||||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
|
||||||
_runningProcesses.Remove(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdKillAllApps()
|
|
||||||
{
|
|
||||||
lock (_processLock)
|
|
||||||
{
|
|
||||||
foreach (var kvp in _runningProcesses)
|
|
||||||
foreach (var p in kvp.Value)
|
|
||||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
|
||||||
_runningProcesses.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
SetTrayStatus("All apps stopped");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdGetLaunchedApps()
|
|
||||||
{
|
|
||||||
var result = new List<LaunchedAppData>();
|
|
||||||
|
|
||||||
lock (_processLock)
|
|
||||||
{
|
|
||||||
foreach (var kvp in _runningProcesses)
|
|
||||||
{
|
|
||||||
kvp.Value.RemoveAll(p => p.HasExited);
|
|
||||||
|
|
||||||
foreach (var p in kvp.Value)
|
|
||||||
result.Add(new LaunchedAppData
|
|
||||||
{
|
|
||||||
LaunchKey = kvp.Key,
|
|
||||||
ProcessId = p.Id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var key in _runningProcesses.Keys
|
|
||||||
.Where(k => _runningProcesses[k].Count == 0).ToList())
|
|
||||||
_runningProcesses.Remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdGetLaunchableApps() => _launchApps.ToArray();
|
|
||||||
|
|
||||||
private object CmdRemoveApp(IpcMessage msg)
|
|
||||||
{
|
|
||||||
var key = msg.LaunchKey ?? throw new Exception("RemoveApp requires LaunchKey");
|
|
||||||
_launchApps.RemoveAll(a => a.LaunchKey == key);
|
|
||||||
SaveLaunchApps();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdInstallApp(IpcMessage msg)
|
|
||||||
{
|
|
||||||
if (msg.PayloadJson == null)
|
|
||||||
throw new Exception("InstallApp requires PayloadJson");
|
|
||||||
|
|
||||||
var parms = JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson);
|
|
||||||
if (parms == null || parms.Length == 0)
|
|
||||||
throw new Exception("InstallApp: no parameters");
|
|
||||||
|
|
||||||
var appData = JsonSerializer.Deserialize<LaunchData>(parms[0].GetRawText())
|
|
||||||
?? throw new Exception("InstallApp: could not deserialize LaunchData");
|
|
||||||
|
|
||||||
var existing = _launchApps.FindIndex(a => a.LaunchKey == appData.LaunchKey);
|
|
||||||
if (existing >= 0) _launchApps[existing] = appData;
|
|
||||||
else _launchApps.Add(appData);
|
|
||||||
|
|
||||||
SaveLaunchApps();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdUninstallApp(IpcMessage msg)
|
|
||||||
{
|
|
||||||
var key = msg.LaunchKey ?? throw new Exception("UninstallApp requires LaunchKey");
|
|
||||||
CmdKillAllOfType(msg);
|
|
||||||
|
|
||||||
var removed = _launchApps.FirstOrDefault(a => a.LaunchKey == key);
|
|
||||||
_launchApps.RemoveAll(a => a.LaunchKey == key);
|
|
||||||
SaveLaunchApps();
|
|
||||||
|
|
||||||
// If the removed app's product directory under C:\Games is no longer used by
|
|
||||||
// any remaining registered entry, tell the Service (which runs as SYSTEM) to
|
|
||||||
// run its pre-uninstall.bat and delete the directory. The orphan check keeps a
|
|
||||||
// product with several launch entries sharing one folder (e.g. Red Planet's
|
|
||||||
// GameClient/LC/MR) intact until its LAST entry is removed.
|
|
||||||
string cleanupDir = null;
|
|
||||||
if (removed != null)
|
|
||||||
{
|
|
||||||
var dir = ProductDirUnderGames(removed.WorkingDirectory)
|
|
||||||
?? ProductDirUnderGames(removed.ExeFile);
|
|
||||||
if (dir != null && !_launchApps.Any(a => string.Equals(
|
|
||||||
ProductDirUnderGames(a.WorkingDirectory) ?? ProductDirUnderGames(a.ExeFile),
|
|
||||||
dir, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
cleanupDir = dir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new { CleanupDir = cleanupDir };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The immediate child of C:\Games that contains <paramref name="path"/>
|
|
||||||
/// (e.g. C:\Games\RIOJoy\app\x.exe → C:\Games\RIOJoy), or null if not under C:\Games.</summary>
|
|
||||||
private static string ProductDirUnderGames(string path)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
|
||||||
string full;
|
|
||||||
try { full = Path.GetFullPath(path); } catch { return null; }
|
|
||||||
const string games = @"C:\Games\";
|
|
||||||
if (!full.StartsWith(games, StringComparison.OrdinalIgnoreCase)) return null;
|
|
||||||
var seg = full.Substring(games.Length).Split('\\', '/')[0];
|
|
||||||
return string.IsNullOrEmpty(seg) ? null : games + seg;
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdGetVolumeLevel()
|
|
||||||
{
|
|
||||||
return GetMasterVolume();
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdSetVolumeLevel(IpcMessage msg)
|
|
||||||
{
|
|
||||||
if (msg.PayloadJson == null)
|
|
||||||
throw new Exception("set_VolumeLevel requires parameters");
|
|
||||||
|
|
||||||
var parms = JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson);
|
|
||||||
if (parms == null || parms.Length == 0)
|
|
||||||
throw new Exception("Invalid volume value");
|
|
||||||
var scalar = parms[0].ValueKind == JsonValueKind.Number
|
|
||||||
? (float)parms[0].GetDouble()
|
|
||||||
: throw new Exception("Invalid volume value");
|
|
||||||
SetMasterVolume(Math.Max(0f, Math.Min(1f, scalar)));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdFullUpdate() =>
|
|
||||||
new FullUpdateData
|
|
||||||
{
|
|
||||||
InstalledApps = _launchApps.ToArray(),
|
|
||||||
LaunchedApps = (LaunchedAppData[])CmdGetLaunchedApps(),
|
|
||||||
VolumeLevel = GetMasterVolume()
|
|
||||||
};
|
|
||||||
|
|
||||||
private object CmdInitiateInstallProduct(IpcMessage msg)
|
|
||||||
{
|
|
||||||
// ILauncherService.InitiateInstallProduct() takes no parameters.
|
|
||||||
// It just returns a tracking Guid. The actual game registration
|
|
||||||
// happens via InstallApp(LaunchData) in a separate RPC call.
|
|
||||||
var callId = Guid.NewGuid().ToString("N");
|
|
||||||
var progress = new OutOfBandProgress
|
|
||||||
{
|
|
||||||
PercentComplete = 100,
|
|
||||||
Status = "Complete",
|
|
||||||
IsCompleted = true
|
|
||||||
};
|
|
||||||
|
|
||||||
lock (_installLock) _installProgress[callId] = progress;
|
|
||||||
|
|
||||||
return callId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdGetOutOfBandProgress(IpcMessage msg)
|
|
||||||
{
|
|
||||||
var callId = msg.PayloadJson != null
|
|
||||||
? JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson)?[0].GetString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (callId == null)
|
|
||||||
throw new Exception("GetOutOfBandProgress requires a callId");
|
|
||||||
|
|
||||||
lock (_installLock)
|
|
||||||
{
|
|
||||||
if (_installProgress.TryGetValue(callId, out var prog)) return prog;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new OutOfBandProgress
|
|
||||||
{
|
|
||||||
PercentComplete = 0,
|
|
||||||
Status = "Unknown call ID",
|
|
||||||
IsCompleted = true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private object CmdShutdown(IpcMessage msg)
|
|
||||||
{
|
|
||||||
bool doRestart = false;
|
|
||||||
if (msg.PayloadJson != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var parms = JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson);
|
|
||||||
if (parms?.Length > 0 && parms[0].ValueKind == JsonValueKind.True)
|
|
||||||
doRestart = true;
|
|
||||||
}
|
|
||||||
catch { /* default to shutdown */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
CmdKillAllApps();
|
|
||||||
var flag = doRestart ? "/r" : "/s";
|
|
||||||
new Thread(() =>
|
|
||||||
{
|
|
||||||
Thread.Sleep(2000);
|
|
||||||
Process.Start("shutdown", $"{flag} /t 0");
|
|
||||||
}) { IsBackground = true }.Start();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private LaunchData FindApp(string launchKey) =>
|
|
||||||
_launchApps.FirstOrDefault(a =>
|
|
||||||
string.Equals(a.LaunchKey, launchKey, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
private void StartWatcher(LaunchData app, Process process)
|
|
||||||
{
|
|
||||||
new Thread(() =>
|
|
||||||
{
|
|
||||||
process.WaitForExit();
|
|
||||||
if (_stopping) return;
|
|
||||||
|
|
||||||
bool stillTracked;
|
|
||||||
lock (_processLock)
|
|
||||||
{
|
|
||||||
stillTracked = _runningProcesses.TryGetValue(
|
|
||||||
app.LaunchKey, out var procs) && procs.Contains(process);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stillTracked) return;
|
|
||||||
|
|
||||||
Thread.Sleep(2000);
|
|
||||||
try { CmdLaunchApp(new IpcMessage { LaunchKey = app.LaunchKey }); }
|
|
||||||
catch { }
|
|
||||||
})
|
|
||||||
{ IsBackground = true, Name = $"Watcher-{app.LaunchKey}" }.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Volume Control ────────────────────────────────────────────────────
|
|
||||||
// The Console stores/retrieves volume as a float (0.0–1.0 scalar).
|
|
||||||
// We cache the exact value the Console sent so get returns the same
|
|
||||||
// value without CoreAudio roundtrip quantization error.
|
|
||||||
|
|
||||||
private float _cachedVolumeScalar = 1.0f;
|
|
||||||
|
|
||||||
private float GetMasterVolume()
|
|
||||||
{
|
|
||||||
return _cachedVolumeScalar;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetMasterVolume(float scalar)
|
|
||||||
{
|
|
||||||
_cachedVolumeScalar = scalar;
|
|
||||||
|
|
||||||
// Try nircmd.exe first (legacy compatibility)
|
|
||||||
var nircmd = Path.Combine(GAMES_DIR, "nircmd.exe");
|
|
||||||
if (File.Exists(nircmd))
|
|
||||||
{
|
|
||||||
Process.Start(nircmd, $"setsysvolume {(int)(scalar * 65535)}")?.Dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows Core Audio API fallback (Vista+, no external dependencies)
|
|
||||||
CoreAudio.SetMasterScalar(scalar);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Response helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static IpcResponse Ok(object data = null) => new() { Success = true, Data = data };
|
|
||||||
private static IpcResponse Fail(string msg) => new() { Success = false, Message = msg };
|
|
||||||
|
|
||||||
protected override void OnLoad(EventArgs e)
|
|
||||||
{
|
|
||||||
base.OnLoad(e);
|
|
||||||
Visible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
if (disposing) { _trayIcon?.Dispose(); _trayMenu?.Dispose(); }
|
|
||||||
base.Dispose(disposing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Agent-local data types ────────────────────────────────────────────────
|
|
||||||
// These are used internally by the Agent (XML config, process management).
|
|
||||||
// They are NOT the BinaryFormatter wire types (Tesla.Net namespace).
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Agent-internal representation of a launchable app.
|
|
||||||
/// Loaded from LaunchApps.xml via XmlSerializer.
|
|
||||||
/// </summary>
|
|
||||||
[Serializable]
|
|
||||||
public class LaunchData
|
|
||||||
{
|
|
||||||
public string LaunchKey { get; set; }
|
|
||||||
public string DisplayName { get; set; }
|
|
||||||
public string WorkingDirectory { get; set; }
|
|
||||||
public string ExeFile { get; set; }
|
|
||||||
public string Arguments { get; set; }
|
|
||||||
public bool AutoRestart { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Agent-internal tracking of a running process.
|
|
||||||
/// Returned via JSON IPC (string LaunchKey, not Guid).
|
|
||||||
/// </summary>
|
|
||||||
[Serializable]
|
|
||||||
public class LaunchedAppData
|
|
||||||
{
|
|
||||||
public string LaunchKey { get; set; }
|
|
||||||
public int ProcessId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Agent-internal full state snapshot.
|
|
||||||
/// Returned via JSON IPC for the FullUpdate command.
|
|
||||||
/// </summary>
|
|
||||||
[Serializable]
|
|
||||||
public class FullUpdateData
|
|
||||||
{
|
|
||||||
public LaunchData[] InstalledApps { get; set; }
|
|
||||||
public LaunchedAppData[] LaunchedApps { get; set; }
|
|
||||||
public float VolumeLevel { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Serializable]
|
|
||||||
public class OutOfBandProgress
|
|
||||||
{
|
|
||||||
public int PercentComplete { get; set; }
|
|
||||||
public string Status { get; set; }
|
|
||||||
public bool IsCompleted { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Windows Core Audio API — minimal COM interop (Vista+) ─────────────────
|
|
||||||
// No external dependencies. Vtable slot order matches the SDK headers exactly.
|
|
||||||
// Methods we don't call are declared as void stubs to preserve vtable offsets.
|
|
||||||
|
|
||||||
internal static class CoreAudio
|
|
||||||
{
|
|
||||||
internal static float GetMasterScalar()
|
|
||||||
{
|
|
||||||
var ep = GetEndpointVolume();
|
|
||||||
try { ep.GetMasterVolumeLevelScalar(out float v); return v; }
|
|
||||||
finally { ReleaseAll(ep); }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static void SetMasterScalar(float scalar)
|
|
||||||
{
|
|
||||||
var ep = GetEndpointVolume();
|
|
||||||
try { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); }
|
|
||||||
finally { ReleaseAll(ep); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IAudioEndpointVolume GetEndpointVolume()
|
|
||||||
{
|
|
||||||
var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out var device);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var iid = typeof(IAudioEndpointVolume).GUID;
|
|
||||||
device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out var obj);
|
|
||||||
return (IAudioEndpointVolume)obj;
|
|
||||||
}
|
|
||||||
finally { Marshal.ReleaseComObject(device); }
|
|
||||||
}
|
|
||||||
finally { Marshal.ReleaseComObject(enumerator); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ReleaseAll(object obj)
|
|
||||||
{
|
|
||||||
if (obj != null) Marshal.ReleaseComObject(obj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
|
||||||
internal class MMAudioEnumeratorComClass { }
|
|
||||||
|
|
||||||
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
|
|
||||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
||||||
internal interface IMMDeviceEnumerator
|
|
||||||
{
|
|
||||||
void _unused_EnumAudioEndpoints(); // slot 0 — not used
|
|
||||||
[PreserveSig]
|
|
||||||
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Guid("D666063F-1587-4E43-81F1-B948E807363F"),
|
|
||||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
||||||
internal interface IMMDevice
|
|
||||||
{
|
|
||||||
[PreserveSig]
|
|
||||||
int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams,
|
|
||||||
[MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vtable order (after IUnknown): RegisterControlChangeNotify(0),
|
|
||||||
// UnregisterControlChangeNotify(1), GetChannelCount(2),
|
|
||||||
// SetMasterVolumeLevel(3), SetMasterVolumeLevelScalar(4),
|
|
||||||
// GetMasterVolumeLevel(5), GetMasterVolumeLevelScalar(6)
|
|
||||||
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"),
|
|
||||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
||||||
internal interface IAudioEndpointVolume
|
|
||||||
{
|
|
||||||
void _unused_RegisterControlChangeNotify(); // slot 0
|
|
||||||
void _unused_UnregisterControlChangeNotify(); // slot 1
|
|
||||||
void _unused_GetChannelCount(); // slot 2
|
|
||||||
[PreserveSig] int SetMasterVolumeLevel(float levelDB, ref Guid ctx);
|
|
||||||
[PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx);
|
|
||||||
[PreserveSig] int GetMasterVolumeLevel(out float levelDB);
|
|
||||||
[PreserveSig] int GetMasterVolumeLevelScalar(out float level);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net48</TargetFramework>
|
|
||||||
<OutputType>WinExe</OutputType>
|
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
|
||||||
<Nullable>disable</Nullable>
|
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
|
||||||
<LangVersion>latest</LangVersion>
|
|
||||||
<WarningsAsErrors></WarningsAsErrors>
|
|
||||||
<Version>4.11.4.1</Version>
|
|
||||||
<ApplicationIcon>app.ico</ApplicationIcon>
|
|
||||||
<AssemblyName>TeslaLauncherAgent</AssemblyName>
|
|
||||||
<RootNamespace>Tesla.Launcher.Agent</RootNamespace>
|
|
||||||
<StartupObject>Tesla.Launcher.Agent.AgentApplication</StartupObject>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Exclude service-only source files — they belong to TeslaLauncherService.csproj -->
|
|
||||||
<Compile Remove="TeslaLauncherService.cs" />
|
|
||||||
<Compile Remove="SecureConfig.cs" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
|
||||||
<!-- Agent parses configuring.json + the IPC JSON; built-in on net6, a package on net48. -->
|
|
||||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net48</TargetFramework>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<Nullable>disable</Nullable>
|
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
|
||||||
<LangVersion>latest</LangVersion>
|
|
||||||
<WarningsAsErrors></WarningsAsErrors>
|
|
||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
|
||||||
<Version>4.11.4.1</Version>
|
|
||||||
<ApplicationIcon>app.ico</ApplicationIcon>
|
|
||||||
<AssemblyName>TeslaLauncherService</AssemblyName>
|
|
||||||
<RootNamespace>Tesla.Launcher.Service</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Exclude the Agent source — it belongs to TeslaLauncherAgent.csproj only -->
|
|
||||||
<Compile Remove="TeslaLauncherAgent.cs" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- net48 reference assemblies + GAC refs the framework-dependent build needs. -->
|
|
||||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
|
||||||
<Reference Include="System.IO.Compression" />
|
|
||||||
<Reference Include="System.IO.Compression.FileSystem" />
|
|
||||||
<Reference Include="System.ServiceProcess" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Generic host on .NET Framework: Microsoft.Extensions.Hosting(.WindowsServices)
|
|
||||||
8.x ship a net462 target, so they run on net48 unchanged. -->
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.*" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.*" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.EventLog" Version="8.*" />
|
|
||||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
|
||||||
<!-- COM2 / PlasmaIO serial output in SecureConfig.cs -->
|
|
||||||
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Contract\Tesla.Contract.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// TeslaLauncher — system master volume (shared with vPOD)
|
||||||
|
// =============================================================================
|
||||||
|
// The Console's set_VolumeLevel ends here. Setter chain:
|
||||||
|
// nircmd.exe (legacy, works everywhere incl. XP, if present in the games dir)
|
||||||
|
// → Core Audio (Vista+) → winmm waveOutSetVolume (XP fallback).
|
||||||
|
//
|
||||||
|
// Like MiniZip.cs, this file is compiled into BOTH the launcher and vPOD
|
||||||
|
// (linked source): vPOD's "Actually set system volume" mode applies the
|
||||||
|
// console's volume commands through the exact code the real pod runs.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Tesla.Launcher
|
||||||
|
{
|
||||||
|
internal static class VolumeControl
|
||||||
|
{
|
||||||
|
/// <summary>Sets the system master volume to <paramref name="scalar"/>
|
||||||
|
/// (clamped to 0.0–1.0). <paramref name="nircmdDir"/> is probed for
|
||||||
|
/// nircmd.exe first (the legacy path used on the original pods); the
|
||||||
|
/// Windows APIs are the fallback. Never throws.</summary>
|
||||||
|
public static void SetMasterScalar(float scalar, string nircmdDir)
|
||||||
|
{
|
||||||
|
scalar = Math.Max(0f, Math.Min(1f, scalar));
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(nircmdDir))
|
||||||
|
{
|
||||||
|
var nircmd = Path.Combine(nircmdDir, "nircmd.exe");
|
||||||
|
if (File.Exists(nircmd))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var p = Process.Start(nircmd, "setsysvolume " + (int)(scalar * 65535));
|
||||||
|
if (p != null) p.Dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch { /* fall through to API */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Environment.OSVersion.Version.Major >= 6)
|
||||||
|
{
|
||||||
|
try { CoreAudio.SetMasterScalar(scalar); return; }
|
||||||
|
catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
WinMmVolume.SetMasterScalar(scalar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Windows Core Audio API — minimal COM interop (Vista+) ─────────────────
|
||||||
|
// No external dependencies. Vtable slot order matches the SDK headers exactly.
|
||||||
|
// Methods we don't call are declared as void stubs to preserve vtable offsets.
|
||||||
|
// NOT available on XP — callers must gate on OS version.
|
||||||
|
|
||||||
|
internal static class CoreAudio
|
||||||
|
{
|
||||||
|
internal static void SetMasterScalar(float scalar)
|
||||||
|
{
|
||||||
|
var ep = GetEndpointVolume();
|
||||||
|
try { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); }
|
||||||
|
finally { Marshal.ReleaseComObject(ep); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IAudioEndpointVolume GetEndpointVolume()
|
||||||
|
{
|
||||||
|
var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IMMDevice device;
|
||||||
|
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out device);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var iid = typeof(IAudioEndpointVolume).GUID;
|
||||||
|
object obj;
|
||||||
|
device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out obj);
|
||||||
|
return (IAudioEndpointVolume)obj;
|
||||||
|
}
|
||||||
|
finally { Marshal.ReleaseComObject(device); }
|
||||||
|
}
|
||||||
|
finally { Marshal.ReleaseComObject(enumerator); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
||||||
|
internal class MMAudioEnumeratorComClass { }
|
||||||
|
|
||||||
|
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
|
||||||
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
internal interface IMMDeviceEnumerator
|
||||||
|
{
|
||||||
|
void _unused_EnumAudioEndpoints(); // slot 0 — not used
|
||||||
|
[PreserveSig]
|
||||||
|
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Guid("D666063F-1587-4E43-81F1-B948E807363F"),
|
||||||
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
internal interface IMMDevice
|
||||||
|
{
|
||||||
|
[PreserveSig]
|
||||||
|
int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams,
|
||||||
|
[MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vtable order (after IUnknown): RegisterControlChangeNotify(0),
|
||||||
|
// UnregisterControlChangeNotify(1), GetChannelCount(2),
|
||||||
|
// SetMasterVolumeLevel(3), SetMasterVolumeLevelScalar(4),
|
||||||
|
// GetMasterVolumeLevel(5), GetMasterVolumeLevelScalar(6)
|
||||||
|
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"),
|
||||||
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
internal interface IAudioEndpointVolume
|
||||||
|
{
|
||||||
|
void _unused_RegisterControlChangeNotify(); // slot 0
|
||||||
|
void _unused_UnregisterControlChangeNotify(); // slot 1
|
||||||
|
void _unused_GetChannelCount(); // slot 2
|
||||||
|
[PreserveSig] int SetMasterVolumeLevel(float levelDB, ref Guid ctx);
|
||||||
|
[PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx);
|
||||||
|
[PreserveSig] int GetMasterVolumeLevel(out float levelDB);
|
||||||
|
[PreserveSig] int GetMasterVolumeLevelScalar(out float level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── winmm wave-out volume (XP fallback) ───────────────────────────────────
|
||||||
|
// waveOutSetVolume with device -1 sets the wave mixer level of the default
|
||||||
|
// device: low 16 bits = left channel, high 16 bits = right channel.
|
||||||
|
|
||||||
|
internal static class WinMmVolume
|
||||||
|
{
|
||||||
|
[DllImport("winmm.dll")]
|
||||||
|
private static extern int waveOutSetVolume(IntPtr hwo, uint dwVolume);
|
||||||
|
|
||||||
|
internal static void SetMasterScalar(float scalar)
|
||||||
|
{
|
||||||
|
uint level = (uint)(Math.Max(0f, Math.Min(1f, scalar)) * 0xFFFF);
|
||||||
|
try { waveOutSetVolume(IntPtr.Zero, (level << 16) | level); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,55 +1,52 @@
|
|||||||
@echo off
|
@echo off
|
||||||
:: =============================================================================
|
:: =============================================================================
|
||||||
:: TeslaLauncher — Unified Build / Package Script
|
:: TeslaLauncher - Unified Build / Package Script (XP11 single binary)
|
||||||
:: =============================================================================
|
:: =============================================================================
|
||||||
:: Publishes TeslaLauncherService.exe and TeslaLauncherAgent.exe as
|
:: Publishes TeslaLauncher.exe as a framework-dependent net40 build and
|
||||||
:: framework-dependent net48 builds and assembles the installable TeslaLauncher\
|
:: assembles the installable TeslaLauncher\ package next to install.bat.
|
||||||
:: package next to install.bat. The target pod needs .NET Framework 4.8 (built
|
::
|
||||||
:: into Windows 10/11) — there is no bundled runtime, so the package is small.
|
:: net40 is deliberate: it is the newest .NET Framework that installs on
|
||||||
|
:: Windows XP SP3, and net40 assemblies run in-place on the 4.8 runtime built
|
||||||
|
:: into Windows 10/11 - so this ONE exe covers XP SP3 through Windows 11.
|
||||||
|
:: - XP SP3 pods additionally need the .NET Framework 4.0 redistributable
|
||||||
|
:: (dotNetFx40_Full_x86_x64.exe); drop it into assets\dotnet40\ to have
|
||||||
|
:: install.bat run it automatically when missing.
|
||||||
|
:: - Windows 10/11 pods need nothing extra.
|
||||||
::
|
::
|
||||||
:: Requirements:
|
:: Requirements:
|
||||||
:: .NET SDK (6.0+) to drive the build https://dotnet.microsoft.com/download
|
:: .NET SDK (6.0+) to drive the build https://dotnet.microsoft.com/download
|
||||||
:: Internet access for NuGet restore (first build only)
|
:: Internet access for NuGet restore (first build only)
|
||||||
::
|
::
|
||||||
:: Usage:
|
:: Usage:
|
||||||
:: build.bat - build both components + package
|
:: build.bat - build + package
|
||||||
:: build.bat /service - build Service only
|
:: build.bat /q - no pause on exit
|
||||||
:: build.bat /agent - build Agent only
|
|
||||||
::
|
::
|
||||||
:: Output (under dist\, parity with Console\dist\):
|
:: Output (under dist\, parity with Console\dist\):
|
||||||
:: dist\TeslaLauncher\
|
:: dist\TeslaLauncher\
|
||||||
:: install.bat
|
:: install.bat
|
||||||
:: Service\TeslaLauncherService.exe
|
:: App\TeslaLauncher.exe (+ Newtonsoft.Json.dll, TeslaConsoleLaunchLib.dll)
|
||||||
:: Agent\TeslaLauncherAgent.exe
|
:: dx9201006\ openal\ UltraVNC\ dotnet40\ (redist, mirrored from assets\)
|
||||||
:: dx9201006\ openal\ UltraVNC\ (redist, mirrored from assets\)
|
|
||||||
:: dist\TeslaLauncher-podpkg.zip (the deployable package)
|
:: dist\TeslaLauncher-podpkg.zip (the deployable package)
|
||||||
::
|
::
|
||||||
:: NOTE: the projects reference ..\Contract\Tesla.Contract.csproj, so they are
|
:: NOTE: the project references ..\Contract\Tesla.Contract.csproj, so it is
|
||||||
:: published IN PLACE (not staged into a temp folder) — the project reference
|
:: published IN PLACE (not staged into a temp folder) - the project reference
|
||||||
:: must be able to resolve relative to this directory.
|
:: must be able to resolve relative to this directory.
|
||||||
:: =============================================================================
|
:: =============================================================================
|
||||||
|
|
||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
set ROOT=%~dp0
|
set ROOT=%~dp0
|
||||||
:: Package under dist\ (parity with Console\dist\).
|
|
||||||
set BUILD_DIR=%ROOT%dist\TeslaLauncher
|
set BUILD_DIR=%ROOT%dist\TeslaLauncher
|
||||||
set ZIP=%ROOT%dist\TeslaLauncher-podpkg.zip
|
set ZIP=%ROOT%dist\TeslaLauncher-podpkg.zip
|
||||||
|
|
||||||
:: -- Parse arguments ----------------------------------------------------------
|
|
||||||
set BUILD_SERVICE=1
|
|
||||||
set BUILD_AGENT=1
|
|
||||||
set QUIET=0
|
set QUIET=0
|
||||||
|
|
||||||
for %%a in (%*) do (
|
for %%a in (%*) do (
|
||||||
if /i "%%~a"=="/service" set BUILD_AGENT=0
|
|
||||||
if /i "%%~a"=="/agent" set BUILD_SERVICE=0
|
|
||||||
if /i "%%~a"=="/q" set QUIET=1
|
if /i "%%~a"=="/q" set QUIET=1
|
||||||
)
|
)
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ============================================================
|
echo ============================================================
|
||||||
echo Tesla Launcher v4.11.4.1 - Build ^& Package (net48, framework-dependent)
|
echo Tesla Launcher v4.11.4.3 - Build ^& Package (net40 single binary)
|
||||||
echo Output : %BUILD_DIR%
|
echo Output : %BUILD_DIR%
|
||||||
echo ============================================================
|
echo ============================================================
|
||||||
echo.
|
echo.
|
||||||
@@ -68,60 +65,38 @@ echo SDK : %SDK_VER%
|
|||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: -- Clean prior package output ----------------------------------------------
|
:: -- Clean prior package output ----------------------------------------------
|
||||||
|
if exist "%BUILD_DIR%\App" rmdir /s /q "%BUILD_DIR%\App"
|
||||||
if exist "%BUILD_DIR%\Service" rmdir /s /q "%BUILD_DIR%\Service"
|
if exist "%BUILD_DIR%\Service" rmdir /s /q "%BUILD_DIR%\Service"
|
||||||
if exist "%BUILD_DIR%\Agent" rmdir /s /q "%BUILD_DIR%\Agent"
|
if exist "%BUILD_DIR%\Agent" rmdir /s /q "%BUILD_DIR%\Agent"
|
||||||
if exist "%BUILD_DIR%\dx9201006" rmdir /s /q "%BUILD_DIR%\dx9201006"
|
if exist "%BUILD_DIR%\dx9201006" rmdir /s /q "%BUILD_DIR%\dx9201006"
|
||||||
if exist "%BUILD_DIR%\openal" rmdir /s /q "%BUILD_DIR%\openal"
|
if exist "%BUILD_DIR%\openal" rmdir /s /q "%BUILD_DIR%\openal"
|
||||||
if exist "%BUILD_DIR%\UltraVNC" rmdir /s /q "%BUILD_DIR%\UltraVNC"
|
if exist "%BUILD_DIR%\UltraVNC" rmdir /s /q "%BUILD_DIR%\UltraVNC"
|
||||||
|
if exist "%BUILD_DIR%\dotnet40" rmdir /s /q "%BUILD_DIR%\dotnet40"
|
||||||
|
|
||||||
:: -- Build Service ------------------------------------------------------------
|
:: -- Build --------------------------------------------------------------------
|
||||||
if %BUILD_SERVICE%==0 goto :skip_service
|
echo [1/1] Publishing TeslaLauncher (net40 single binary)...
|
||||||
|
|
||||||
echo [1/2] Publishing TeslaLauncherService (Session 0 Windows Service)...
|
|
||||||
echo.
|
echo.
|
||||||
dotnet publish "%ROOT%TeslaLauncherService.csproj" ^
|
dotnet publish "%ROOT%TeslaLauncher.csproj" ^
|
||||||
-c Release ^
|
-c Release ^
|
||||||
-o "%BUILD_DIR%\Service"
|
-o "%BUILD_DIR%\App"
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo.
|
echo.
|
||||||
echo ERROR: Service build failed.
|
echo ERROR: build failed.
|
||||||
if "%QUIET%"=="0" pause
|
if "%QUIET%"=="0" pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
echo.
|
echo.
|
||||||
echo Service : %BUILD_DIR%\Service\TeslaLauncherService.exe
|
echo Launcher : %BUILD_DIR%\App\TeslaLauncher.exe
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:skip_service
|
|
||||||
|
|
||||||
:: -- Build Agent --------------------------------------------------------------
|
|
||||||
if %BUILD_AGENT%==0 goto :skip_agent
|
|
||||||
|
|
||||||
if %BUILD_SERVICE%==1 (echo [2/2] Publishing TeslaLauncherAgent...) else (echo [1/1] Publishing TeslaLauncherAgent...)
|
|
||||||
echo.
|
|
||||||
dotnet publish "%ROOT%TeslaLauncherAgent.csproj" ^
|
|
||||||
-c Release ^
|
|
||||||
"-p:DefineConstants=WINFORMS" ^
|
|
||||||
-o "%BUILD_DIR%\Agent"
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo.
|
|
||||||
echo ERROR: Agent build failed.
|
|
||||||
if "%QUIET%"=="0" pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
echo.
|
|
||||||
echo Agent : %BUILD_DIR%\Agent\TeslaLauncherAgent.exe
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:skip_agent
|
|
||||||
|
|
||||||
:: -- Copy shared assets into the package --------------------------------------
|
:: -- Copy shared assets into the package --------------------------------------
|
||||||
:: Mirror each redist folder under assets\ into the package root so install.bat's
|
:: Mirror each redist folder under assets\ into the package root so install.bat's
|
||||||
:: paths (%ROOT%\dx9201006\, %ROOT%\openal\, %ROOT%\UltraVNC\) resolve.
|
:: paths (%ROOT%\dx9201006\ etc.) resolve.
|
||||||
if exist "%ROOT%install.bat" copy /y "%ROOT%install.bat" "%BUILD_DIR%\" >nul
|
if exist "%ROOT%install.bat" copy /y "%ROOT%install.bat" "%BUILD_DIR%\" >nul
|
||||||
if exist "%ROOT%assets\dx9201006" xcopy /y /s /i /q "%ROOT%assets\dx9201006" "%BUILD_DIR%\dx9201006" >nul
|
if exist "%ROOT%assets\dx9201006" xcopy /y /s /i /q "%ROOT%assets\dx9201006" "%BUILD_DIR%\dx9201006" >nul
|
||||||
if exist "%ROOT%assets\openal" xcopy /y /s /i /q "%ROOT%assets\openal" "%BUILD_DIR%\openal" >nul
|
if exist "%ROOT%assets\openal" xcopy /y /s /i /q "%ROOT%assets\openal" "%BUILD_DIR%\openal" >nul
|
||||||
if exist "%ROOT%assets\UltraVNC" xcopy /y /s /i /q "%ROOT%assets\UltraVNC" "%BUILD_DIR%\UltraVNC" >nul
|
if exist "%ROOT%assets\UltraVNC" xcopy /y /s /i /q "%ROOT%assets\UltraVNC" "%BUILD_DIR%\UltraVNC" >nul
|
||||||
|
if exist "%ROOT%assets\dotnet40" xcopy /y /s /i /q "%ROOT%assets\dotnet40" "%BUILD_DIR%\dotnet40" >nul
|
||||||
|
|
||||||
:: -- Zip the package ----------------------------------------------------------
|
:: -- Zip the package ----------------------------------------------------------
|
||||||
echo Zipping package...
|
echo Zipping package...
|
||||||
@@ -133,14 +108,13 @@ echo ============================================================
|
|||||||
echo Build complete
|
echo Build complete
|
||||||
echo ============================================================
|
echo ============================================================
|
||||||
echo.
|
echo.
|
||||||
if %BUILD_SERVICE%==1 echo Service : %BUILD_DIR%\Service\TeslaLauncherService.exe
|
echo Launcher : %BUILD_DIR%\App\TeslaLauncher.exe
|
||||||
if %BUILD_AGENT%==1 echo Agent : %BUILD_DIR%\Agent\TeslaLauncherAgent.exe
|
|
||||||
echo.
|
echo.
|
||||||
echo Package : %BUILD_DIR%
|
echo Package : %BUILD_DIR%
|
||||||
echo Zip : %ZIP%
|
echo Zip : %ZIP%
|
||||||
echo.
|
echo.
|
||||||
echo Next steps:
|
echo Next steps:
|
||||||
echo 1. Copy TeslaLauncher-podpkg.zip to the pod (or stand-in) PC and extract it
|
echo 1. Copy TeslaLauncher-podpkg.zip to the pod (XP SP3 or Win10/11) and extract
|
||||||
echo 2. Run TeslaLauncher\install.bat as Administrator
|
echo 2. Run TeslaLauncher\install.bat as Administrator
|
||||||
echo.
|
echo.
|
||||||
if "%QUIET%"=="0" pause
|
if "%QUIET%"=="0" pause
|
||||||
|
|||||||
@@ -1,40 +1,53 @@
|
|||||||
@echo off
|
@echo off
|
||||||
:: =============================================================================
|
:: =============================================================================
|
||||||
:: TeslaLauncher Modern — Installation Script
|
:: TeslaLauncher Modern - Installation Script (XP11 single binary, dual-OS)
|
||||||
:: =============================================================================
|
:: =============================================================================
|
||||||
:: Run as Administrator on each cockpit PC.
|
:: Run as Administrator on each cockpit PC. Works on Windows XP SP3 and on
|
||||||
|
:: Windows 10/11 - one binary, one installer, two OS code paths where the
|
||||||
|
:: tooling differs (cacls/icacls, netsh firewall/advfirewall, no dism on XP...).
|
||||||
:: Must be run from the TeslaLauncher\ folder produced by build.bat:
|
:: Must be run from the TeslaLauncher\ folder produced by build.bat:
|
||||||
::
|
::
|
||||||
:: TeslaLauncher\
|
:: TeslaLauncher\
|
||||||
:: install.bat <- this file
|
:: install.bat <- this file
|
||||||
:: Service\TeslaLauncherService.exe
|
:: App\TeslaLauncher.exe (net40 single binary)
|
||||||
:: Agent\TeslaLauncherAgent.exe
|
|
||||||
:: dx9201006\DXSETUP.exe (DirectX June 2010 redist)
|
:: dx9201006\DXSETUP.exe (DirectX June 2010 redist)
|
||||||
:: openal\oalinst.exe (OpenAL redist)
|
:: openal\oalinst.exe (OpenAL redist)
|
||||||
:: UltraVNC\UltraVNC_x64_Setup.exe (UltraVNC server + UltraVNC.inf)
|
:: UltraVNC\UltraVNC_x64_Setup.exe (UltraVNC server, Win10/11)
|
||||||
|
:: UltraVNC\UltraVNC_x86_Setup.exe (optional: UltraVNC for XP)
|
||||||
|
:: dotnet40\dotNetFx40_Full_x86_x64.exe (optional: .NET 4.0 for XP)
|
||||||
:: =============================================================================
|
:: =============================================================================
|
||||||
|
|
||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
echo ============================================================
|
echo ============================================================
|
||||||
echo Tesla Launcher v4.11.4.1 - Installation
|
echo Tesla Launcher v4.11.4.3 - Installation (single binary)
|
||||||
echo ============================================================
|
echo ============================================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: ── Paths ─────────────────────────────────────────────────────────────────────
|
:: -- OS detection --------------------------------------------------------------
|
||||||
|
:: NT 5.x = XP / Server 2003 era. Everything else is treated as modern Windows.
|
||||||
|
set ISXP=0
|
||||||
|
ver | findstr /r /c:"Version 5\." >nul && set ISXP=1
|
||||||
|
if "%ISXP%"=="1" (echo OS : Windows XP era ^(NT 5.x^)) else (echo OS : Windows Vista or later)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: -- Paths ----------------------------------------------------------------------
|
||||||
set ROOT=%~dp0
|
set ROOT=%~dp0
|
||||||
set SERVICE_SRC=%ROOT%Service
|
set APP_SRC=%ROOT%App
|
||||||
set AGENT_SRC=%ROOT%Agent
|
set INSTALL_DIR=%ProgramFiles%\TeslaLauncher
|
||||||
set INSTALL_DIR=C:\Program Files\TeslaLauncher
|
set LAUNCHER_EXE=%INSTALL_DIR%\TeslaLauncher.exe
|
||||||
set DATA_DIR=C:\ProgramData\TeslaLauncher
|
|
||||||
set SERVICE_EXE=%INSTALL_DIR%\TeslaLauncherService.exe
|
|
||||||
set AGENT_EXE=%INSTALL_DIR%\TeslaLauncherAgent.exe
|
|
||||||
set SERVICE_NAME=Tesla Application Launcher
|
|
||||||
set SERVICE_DISPLAY=Tesla Application Launcher
|
|
||||||
set CONSOLE_PORT=53290
|
set CONSOLE_PORT=53290
|
||||||
|
|
||||||
|
:: CommonApplicationData differs by OS. The launcher resolves it via
|
||||||
|
:: Environment.GetFolderPath; these must match what it computes.
|
||||||
|
if "%ISXP%"=="1" (
|
||||||
|
set DATA_DIR=%ALLUSERSPROFILE%\Application Data\TeslaLauncher
|
||||||
|
) else (
|
||||||
|
set DATA_DIR=%ALLUSERSPROFILE%\TeslaLauncher
|
||||||
|
)
|
||||||
|
|
||||||
:: Auto-login account for the cockpit (kiosk). Plain-text password in the
|
:: Auto-login account for the cockpit (kiosk). Plain-text password in the
|
||||||
:: registry is by design here — closed network, single-purpose PC.
|
:: registry is by design here - closed network, single-purpose PC.
|
||||||
set AUTOLOGIN_USER=Firestorm
|
set AUTOLOGIN_USER=Firestorm
|
||||||
set AUTOLOGIN_PASS=thor6
|
set AUTOLOGIN_PASS=thor6
|
||||||
|
|
||||||
@@ -42,139 +55,161 @@ set AUTOLOGIN_PASS=thor6
|
|||||||
:: the mw4files / c shares browse cleanly. Change once per site if needed.
|
:: the mw4files / c shares browse cleanly. Change once per site if needed.
|
||||||
set WORKGROUP=Tesla
|
set WORKGROUP=Tesla
|
||||||
|
|
||||||
:: ── Admin check ───────────────────────────────────────────────────────────────
|
:: -- Admin check -----------------------------------------------------------------
|
||||||
net session >nul 2>&1
|
net session >nul 2>&1
|
||||||
if %errorlevel% neq 0 (
|
if %errorlevel% neq 0 (
|
||||||
echo ERROR: This script must be run as Administrator.
|
echo ERROR: This script must be run as Administrator.
|
||||||
pause & exit /b 1
|
pause & exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
:: ── Verify source files ───────────────────────────────────────────────────────
|
:: -- Verify source files ---------------------------------------------------------
|
||||||
if not exist "%SERVICE_SRC%\TeslaLauncherService.exe" (
|
if not exist "%APP_SRC%\TeslaLauncher.exe" (
|
||||||
echo ERROR: TeslaLauncherService.exe not found in %SERVICE_SRC%
|
echo ERROR: TeslaLauncher.exe not found in %APP_SRC%
|
||||||
echo Run build.bat first, then run install.bat from the TeslaLauncher\ folder.
|
|
||||||
pause & exit /b 1
|
|
||||||
)
|
|
||||||
if not exist "%AGENT_SRC%\TeslaLauncherAgent.exe" (
|
|
||||||
echo ERROR: TeslaLauncherAgent.exe not found in %AGENT_SRC%
|
|
||||||
echo Run build.bat first, then run install.bat from the TeslaLauncher\ folder.
|
echo Run build.bat first, then run install.bat from the TeslaLauncher\ folder.
|
||||||
pause & exit /b 1
|
pause & exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
:: ── Detect and clean up existing installation ─────────────────────────────────
|
:: -- .NET Framework 4.0 check (XP ships no .NET; Win10/11 has 4.8 built in) ------
|
||||||
set EXISTING=0
|
reg query "HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" /v Install >nul 2>&1
|
||||||
if exist "%SERVICE_EXE%" set EXISTING=1
|
if not errorlevel 1 goto :dotnet_ok
|
||||||
if exist "%AGENT_EXE%" set EXISTING=1
|
reg query "HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Client" /v Install >nul 2>&1
|
||||||
|
if not errorlevel 1 goto :dotnet_ok
|
||||||
|
if exist "%ROOT%dotnet40\dotNetFx40_Full_x86_x64.exe" (
|
||||||
|
echo .NET Framework 4.0 not found - installing it now ^(takes a few minutes^)...
|
||||||
|
"%ROOT%dotnet40\dotNetFx40_Full_x86_x64.exe" /q /norestart
|
||||||
|
echo .NET Framework 4.0 installed.
|
||||||
|
) else (
|
||||||
|
echo ERROR: .NET Framework 4.0 is not installed and dotnet40\ redist is not
|
||||||
|
echo in this package. Install dotNetFx40_Full_x86_x64.exe first.
|
||||||
|
pause & exit /b 1
|
||||||
|
)
|
||||||
|
:dotnet_ok
|
||||||
|
|
||||||
if "%EXISTING%"=="1" (
|
:: -- Detect and clean up existing installation -----------------------------------
|
||||||
echo Existing installation detected. Cleaning up...
|
echo Cleaning up any existing installation...
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Stop and kill the Agent process
|
:: Stop launcher / legacy agent processes
|
||||||
|
taskkill /F /IM TeslaLauncher.exe >nul 2>&1
|
||||||
taskkill /F /IM TeslaLauncherAgent.exe >nul 2>&1
|
taskkill /F /IM TeslaLauncherAgent.exe >nul 2>&1
|
||||||
echo Agent process stopped.
|
|
||||||
|
|
||||||
:: Stop and remove the service, wait for it to fully stop
|
:: Remove the legacy two-process service registration (pre-XP11 installs)
|
||||||
sc stop "%SERVICE_NAME%" >nul 2>&1
|
|
||||||
sc stop "Tesla Application Launcher" >nul 2>&1
|
sc stop "Tesla Application Launcher" >nul 2>&1
|
||||||
timeout /t 3 /nobreak >nul
|
ping -n 4 127.0.0.1 >nul
|
||||||
sc delete "%SERVICE_NAME%" >nul 2>&1
|
|
||||||
sc delete "Tesla Application Launcher" >nul 2>&1
|
sc delete "Tesla Application Launcher" >nul 2>&1
|
||||||
timeout /t 2 /nobreak >nul
|
reg delete "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /v TeslaLauncherAgent /f >nul 2>&1
|
||||||
echo Service stopped and removed.
|
|
||||||
|
|
||||||
:: Delete old executables, logs, and session key so SecureConfig re-runs
|
:: Delete old executables, logs, and session key so SecureConfig re-runs
|
||||||
del /F /Q "%SERVICE_EXE%" >nul 2>&1
|
if exist "%LAUNCHER_EXE%" (
|
||||||
del /F /Q "%AGENT_EXE%" >nul 2>&1
|
del /F /Q "%INSTALL_DIR%\*.exe" >nul 2>&1
|
||||||
|
del /F /Q "%INSTALL_DIR%\*.dll" >nul 2>&1
|
||||||
del /F /Q "%INSTALL_DIR%\podconf.log" >nul 2>&1
|
del /F /Q "%INSTALL_DIR%\podconf.log" >nul 2>&1
|
||||||
|
)
|
||||||
|
del /F /Q "%INSTALL_DIR%\TeslaLauncherService.exe" >nul 2>&1
|
||||||
|
del /F /Q "%INSTALL_DIR%\TeslaLauncherAgent.exe" >nul 2>&1
|
||||||
del /F /Q "%DATA_DIR%\TeslaKeyStore.key" >nul 2>&1
|
del /F /Q "%DATA_DIR%\TeslaKeyStore.key" >nul 2>&1
|
||||||
del /F /Q "%DATA_DIR%\LaunchApps.xml" >nul 2>&1
|
del /F /Q "%DATA_DIR%\LaunchApps.xml" >nul 2>&1
|
||||||
del /F /Q "%DATA_DIR%\configuring.json" >nul 2>&1
|
del /F /Q "%DATA_DIR%\configuring.json" >nul 2>&1
|
||||||
|
del /F /Q "%DATA_DIR%\podconf.log" >nul 2>&1
|
||||||
echo Old executables, logs, session key, and app config deleted.
|
echo Old executables, logs, session key, and app config deleted.
|
||||||
|
|
||||||
:: Reset all physical Ethernet adapters to DHCP so the SecureConfig
|
:: Reset all Ethernet adapters to DHCP so the SecureConfig protocol triggers
|
||||||
:: protocol triggers correctly on the next boot.
|
:: on the next boot. The static address entry must be REMOVED, not just the
|
||||||
:: We must REMOVE the static IP address entry first, then enable DHCP.
|
:: DHCP flag flipped, or IsMachineConfigured() still sees a static adapter.
|
||||||
:: Set-NetIPInterface -Dhcp Enabled alone only flips the DHCP flag but
|
|
||||||
:: leaves the static address in the registry, so IsMachineConfigured()
|
|
||||||
:: still sees a non-DHCP adapter on the next boot and skips SecureConfig.
|
|
||||||
echo Resetting network adapters to DHCP...
|
echo Resetting network adapters to DHCP...
|
||||||
|
if "%ISXP%"=="1" (
|
||||||
|
rem XP: netsh "interface ip set address <name> dhcp" clears the static entry.
|
||||||
|
rem Enumerate interface names from "netsh interface show interface" (col 4+).
|
||||||
|
for /f "skip=3 tokens=4*" %%i in ('netsh interface show interface') do (
|
||||||
|
if "%%j"=="" (
|
||||||
|
netsh interface ip set address "%%i" dhcp >nul 2>&1
|
||||||
|
netsh interface ip set dns "%%i" dhcp >nul 2>&1
|
||||||
|
) else (
|
||||||
|
netsh interface ip set address "%%i %%j" dhcp >nul 2>&1
|
||||||
|
netsh interface ip set dns "%%i %%j" dhcp >nul 2>&1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) else (
|
||||||
powershell -NoProfile -Command "Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | ForEach-Object { $n=$_.Name; Get-NetIPAddress -InterfaceAlias $n -AddressFamily IPv4 -EA 0 | Where-Object {$_.PrefixOrigin -ne 'WellKnown' -and $_.PrefixOrigin -ne 'Dhcp'} | Remove-NetIPAddress -Confirm:$false -EA 0; Set-NetIPInterface -InterfaceAlias $n -Dhcp Enabled -EA 0 }; Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | Set-DnsClientServerAddress -ResetServerAddresses" >nul 2>&1
|
powershell -NoProfile -Command "Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | ForEach-Object { $n=$_.Name; Get-NetIPAddress -InterfaceAlias $n -AddressFamily IPv4 -EA 0 | Where-Object {$_.PrefixOrigin -ne 'WellKnown' -and $_.PrefixOrigin -ne 'Dhcp'} | Remove-NetIPAddress -Confirm:$false -EA 0; Set-NetIPInterface -InterfaceAlias $n -Dhcp Enabled -EA 0 }; Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | Set-DnsClientServerAddress -ResetServerAddresses" >nul 2>&1
|
||||||
|
)
|
||||||
echo Network adapters reset to DHCP.
|
echo Network adapters reset to DHCP.
|
||||||
echo.
|
echo.
|
||||||
) else (
|
|
||||||
:: No existing install — still remove any stale service registration
|
|
||||||
sc stop "%SERVICE_NAME%" >nul 2>&1
|
|
||||||
sc stop "Tesla Application Launcher" >nul 2>&1
|
|
||||||
sc delete "%SERVICE_NAME%" >nul 2>&1
|
|
||||||
sc delete "Tesla Application Launcher" >nul 2>&1
|
|
||||||
timeout /t 2 /nobreak >nul
|
|
||||||
)
|
|
||||||
|
|
||||||
:: ── STEP 1: Create directories ────────────────────────────────────────────────
|
:: -- STEP 1: Create directories ---------------------------------------------------
|
||||||
echo [1/8] Creating directories...
|
echo [1/7] Creating directories...
|
||||||
if not exist "%INSTALL_DIR%" mkdir "%INSTALL_DIR%"
|
if not exist "%INSTALL_DIR%" mkdir "%INSTALL_DIR%"
|
||||||
if not exist "%DATA_DIR%" mkdir "%DATA_DIR%"
|
if not exist "%DATA_DIR%" mkdir "%DATA_DIR%"
|
||||||
if not exist "C:\Games" mkdir "C:\Games"
|
if not exist "C:\Games" mkdir "C:\Games"
|
||||||
|
|
||||||
:: Grant all users modify access to the data and games directories so
|
:: Grant Users modify access to the data and games directories so the launcher
|
||||||
:: the Service and Agent can write files (LaunchApps.xml, game installs).
|
:: can write files (LaunchApps.xml, session key, game installs) from any account.
|
||||||
|
if "%ISXP%"=="1" (
|
||||||
|
cacls "%DATA_DIR%" /T /E /G Users:C >nul 2>&1
|
||||||
|
cacls "C:\Games" /T /E /G Users:C >nul 2>&1
|
||||||
|
) else (
|
||||||
icacls "%DATA_DIR%" /grant *S-1-5-32-545:(OI)(CI)M /T >nul 2>&1
|
icacls "%DATA_DIR%" /grant *S-1-5-32-545:(OI)(CI)M /T >nul 2>&1
|
||||||
icacls "C:\Games" /grant *S-1-5-32-545:(OI)(CI)M /T >nul 2>&1
|
icacls "C:\Games" /grant *S-1-5-32-545:(OI)(CI)M /T >nul 2>&1
|
||||||
|
)
|
||||||
echo %INSTALL_DIR%
|
echo %INSTALL_DIR%
|
||||||
echo %DATA_DIR% (Users: modify access)
|
echo %DATA_DIR% (Users: modify access)
|
||||||
echo C:\Games (Users: modify access)
|
echo C:\Games (Users: modify access)
|
||||||
|
|
||||||
:: ── STEP 2: Copy files ────────────────────────────────────────────────────────
|
:: -- STEP 2: Copy files ------------------------------------------------------------
|
||||||
echo [2/8] Copying files...
|
echo [2/7] Copying files...
|
||||||
:: net48 framework-dependent build: each folder holds the exe + its dependency
|
:: net40 framework-dependent build: the folder holds the exe + its dependency
|
||||||
:: DLLs + .config. Copy both folders into the install dir (shared DLLs overwrite
|
:: DLLs (Newtonsoft.Json, TeslaConsoleLaunchLib) + .config.
|
||||||
:: identically; the two .exe.config files coexist).
|
copy /Y "%APP_SRC%\*.*" "%INSTALL_DIR%\" >nul
|
||||||
copy /Y "%SERVICE_SRC%\*.*" "%INSTALL_DIR%\" >nul
|
|
||||||
copy /Y "%AGENT_SRC%\*.*" "%INSTALL_DIR%\" >nul
|
|
||||||
|
|
||||||
:: Install DirectX June 2010 runtime (required by games)
|
:: Install DirectX June 2010 runtime (required by games; supports XP and Win10/11)
|
||||||
if exist "%ROOT%\dx9201006\DXSETUP.exe" (
|
if exist "%ROOT%dx9201006\DXSETUP.exe" (
|
||||||
echo Installing DirectX runtime...
|
echo Installing DirectX runtime...
|
||||||
"%ROOT%\dx9201006\DXSETUP.exe" /silent
|
"%ROOT%dx9201006\DXSETUP.exe" /silent
|
||||||
echo DirectX installed.
|
echo DirectX installed.
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Install OpenAL runtime (required by game audio)
|
:: Install OpenAL runtime (required by game audio)
|
||||||
if exist "%ROOT%\openal\oalinst.exe" (
|
if exist "%ROOT%openal\oalinst.exe" (
|
||||||
echo Installing OpenAL runtime...
|
echo Installing OpenAL runtime...
|
||||||
"%ROOT%\openal\oalinst.exe" /s
|
"%ROOT%openal\oalinst.exe" /s
|
||||||
echo OpenAL installed.
|
echo OpenAL installed.
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Install UltraVNC server (remote diagnostics). The Inno installer lays down
|
:: Install UltraVNC server (remote diagnostics). x64 build for modern Windows;
|
||||||
:: the files and (via /loadinf) sets the install dir + VNC password; we then
|
:: XP uses the x86 build when the package carries one.
|
||||||
:: register and start the service EXPLICITLY with winvnc.exe. Doing the service
|
set UVNC_SETUP=
|
||||||
:: step ourselves means a missing/incomplete "install service" task in the
|
if "%ISXP%"=="1" (
|
||||||
:: answer file can't leave the pod with the files present but nothing listening.
|
if exist "%ROOT%UltraVNC\UltraVNC_x86_Setup.exe" set UVNC_SETUP=%ROOT%UltraVNC\UltraVNC_x86_Setup.exe
|
||||||
set UVNC_SETUP=%ROOT%UltraVNC\UltraVNC_x64_Setup.exe
|
) else (
|
||||||
set UVNC_DIR=C:\Program Files\uvnc bvba\UltraVNC
|
if exist "%ROOT%UltraVNC\UltraVNC_x64_Setup.exe" set UVNC_SETUP=%ROOT%UltraVNC\UltraVNC_x64_Setup.exe
|
||||||
if exist "%UVNC_SETUP%" (
|
)
|
||||||
|
if defined UVNC_SETUP (
|
||||||
echo Installing UltraVNC server...
|
echo Installing UltraVNC server...
|
||||||
start "" /wait "%UVNC_SETUP%" /verysilent /norestart /loadinf="%ROOT%UltraVNC\UltraVNC.inf"
|
start "" /wait "%UVNC_SETUP%" /verysilent /norestart /loadinf="%ROOT%UltraVNC\UltraVNC.inf"
|
||||||
|
) else (
|
||||||
|
if "%ISXP%"=="1" echo UltraVNC x86 setup not in package - skipped on XP.
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Enable SMB1 client AND server (required by game networking). The pod both
|
:: SMB1 + DirectPlay: native on XP; must be re-enabled on Win10/11.
|
||||||
:: reaches SMB1 shares (client) and hosts the mw4files / c shares below (server),
|
if "%ISXP%"=="0" (
|
||||||
:: and Win10/11 ship both SMB1 sub-features off by default — so enable all three
|
echo Enabling SMB1 file sharing ^(client + server^)...
|
||||||
:: (umbrella + client + server); each child pulls in the umbrella via /All.
|
|
||||||
echo Enabling SMB1 file sharing (client + server)...
|
|
||||||
dism /Online /Enable-Feature /FeatureName:SMB1Protocol /All /NoRestart >nul 2>&1
|
dism /Online /Enable-Feature /FeatureName:SMB1Protocol /All /NoRestart >nul 2>&1
|
||||||
dism /Online /Enable-Feature /FeatureName:SMB1Protocol-Client /All /NoRestart >nul 2>&1
|
dism /Online /Enable-Feature /FeatureName:SMB1Protocol-Client /All /NoRestart >nul 2>&1
|
||||||
dism /Online /Enable-Feature /FeatureName:SMB1Protocol-Server /All /NoRestart >nul 2>&1
|
dism /Online /Enable-Feature /FeatureName:SMB1Protocol-Server /All /NoRestart >nul 2>&1
|
||||||
echo SMB1 client + server enabled (reboot required to take effect).
|
echo SMB1 client + server enabled ^(reboot required to take effect^).
|
||||||
|
echo Enabling DirectPlay...
|
||||||
|
dism /Online /Enable-Feature /FeatureName:DirectPlay /All /NoRestart >nul 2>&1
|
||||||
|
echo DirectPlay enabled.
|
||||||
|
) else (
|
||||||
|
echo SMB1 and DirectPlay are native on XP - nothing to enable.
|
||||||
|
)
|
||||||
|
|
||||||
:: Create and share game-data folders (closed network — open to Everyone).
|
:: Create and share game-data folders (closed network - open to Everyone).
|
||||||
echo Creating network shares...
|
echo Creating network shares...
|
||||||
if not exist "C:\mw4files" mkdir "C:\mw4files"
|
if not exist "C:\mw4files" mkdir "C:\mw4files"
|
||||||
:: Grant Everyone modify on the NTFS side so the share grant is effective.
|
if "%ISXP%"=="1" (
|
||||||
|
cacls "C:\mw4files" /T /E /G Everyone:C >nul 2>&1
|
||||||
|
) else (
|
||||||
icacls "C:\mw4files" /grant *S-1-1-0:(OI)(CI)M /T >nul 2>&1
|
icacls "C:\mw4files" /grant *S-1-1-0:(OI)(CI)M /T >nul 2>&1
|
||||||
:: Recreate shares idempotently (net share fails if the name already exists).
|
)
|
||||||
net share mw4files /delete >nul 2>&1
|
net share mw4files /delete >nul 2>&1
|
||||||
net share mw4files=C:\mw4files /grant:Everyone,FULL >nul 2>&1
|
net share mw4files=C:\mw4files /grant:Everyone,FULL >nul 2>&1
|
||||||
echo "Share \\%COMPUTERNAME%\mw4files -> C:\mw4files (Everyone: Full)"
|
echo "Share \\%COMPUTERNAME%\mw4files -> C:\mw4files (Everyone: Full)"
|
||||||
@@ -182,130 +217,96 @@ net share c /delete >nul 2>&1
|
|||||||
net share c=C:\ /grant:Everyone,FULL >nul 2>&1
|
net share c=C:\ /grant:Everyone,FULL >nul 2>&1
|
||||||
echo "Share \\%COMPUTERNAME%\c -> C:\ (Everyone: Full)"
|
echo "Share \\%COMPUTERNAME%\c -> C:\ (Everyone: Full)"
|
||||||
|
|
||||||
:: Join the site workgroup so every pod shares one browse list. Add-Computer
|
:: Join the site workgroup so every pod shares one browse list.
|
||||||
:: throws if the machine is already in that workgroup, so the error is ignored.
|
|
||||||
:: Takes effect on the reboot at the end of this script (same as the hostname
|
|
||||||
:: SecureConfig applies on first boot).
|
|
||||||
echo Joining workgroup "%WORKGROUP%"...
|
echo Joining workgroup "%WORKGROUP%"...
|
||||||
|
if "%ISXP%"=="1" (
|
||||||
|
wmic computersystem where "name='%COMPUTERNAME%'" call joindomainorworkgroup name="%WORKGROUP%" >nul 2>&1
|
||||||
|
) else (
|
||||||
powershell -NoProfile -Command "try { Add-Computer -WorkgroupName '%WORKGROUP%' -Force -ErrorAction Stop } catch { }" >nul 2>&1
|
powershell -NoProfile -Command "try { Add-Computer -WorkgroupName '%WORKGROUP%' -Force -ErrorAction Stop } catch { }" >nul 2>&1
|
||||||
|
)
|
||||||
echo Workgroup set to "%WORKGROUP%" (effective after reboot).
|
echo Workgroup set to "%WORKGROUP%" (effective after reboot).
|
||||||
|
|
||||||
:: Enable DirectPlay (required by older games)
|
|
||||||
echo Enabling DirectPlay...
|
|
||||||
dism /Online /Enable-Feature /FeatureName:DirectPlay /All /NoRestart >nul 2>&1
|
|
||||||
echo DirectPlay enabled.
|
|
||||||
echo Done.
|
echo Done.
|
||||||
|
|
||||||
:: ── STEP 3: Install Windows Service ──────────────────────────────────────────
|
:: -- STEP 3: Launcher auto-start (no Windows Service on XP11) ----------------------
|
||||||
echo [3/8] Installing Windows Service...
|
echo [3/7] Configuring launcher auto-start...
|
||||||
sc create "%SERVICE_NAME%" ^
|
:: The single binary runs in the auto-logged-in user session. Restart-after-crash
|
||||||
binPath= "\"%SERVICE_EXE%\"" ^
|
:: comes from RegisterApplicationRestart (Vista+) inside the launcher itself.
|
||||||
DisplayName= "%SERVICE_DISPLAY%" ^
|
set AUTORUN_KEY=HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
|
||||||
start= delayed-auto ^
|
reg add "%AUTORUN_KEY%" /v "TeslaLauncher" /t REG_SZ ^
|
||||||
obj= LocalSystem ^
|
/d "\"%LAUNCHER_EXE%\"" /f >nul
|
||||||
type= own
|
echo Added TeslaLauncher to HKLM\...\Run (no service - single userland binary).
|
||||||
|
|
||||||
if %errorlevel% neq 0 (
|
:: -- STEP 4: Disable Windows Firewall (closed network) -----------------------------
|
||||||
echo ERROR: Failed to register Windows Service.
|
echo.
|
||||||
pause & exit /b 1
|
echo [4/7] Disabling Windows Firewall (closed network)...
|
||||||
|
if "%ISXP%"=="1" (
|
||||||
|
netsh firewall set opmode mode=disable >nul 2>&1
|
||||||
|
) else (
|
||||||
|
netsh advfirewall set allprofiles state off >nul
|
||||||
)
|
)
|
||||||
|
echo Windows Firewall disabled.
|
||||||
|
|
||||||
sc description "%SERVICE_NAME%" ^
|
:: -- STEP 5: Power settings (max performance, no sleep) ----------------------------
|
||||||
"Tesla Application Launcher - accepts TeslaConsole connections and controls simulation software"
|
|
||||||
sc failure "%SERVICE_NAME%" ^
|
|
||||||
reset= 86400 actions= restart/5000/restart/10000/restart/30000
|
|
||||||
|
|
||||||
:: Delay auto-start by 20 seconds to let the network stack settle
|
|
||||||
reg add "HKLM\SYSTEM\CurrentControlSet\Services\Tesla Application Launcher" ^
|
|
||||||
/v AutoStartDelay /t REG_DWORD /d 20000 /f >nul
|
|
||||||
|
|
||||||
:: Set the global delayed-auto-start delay to 20 seconds (default is 120s)
|
|
||||||
reg add "HKLM\SYSTEM\CurrentControlSet\Control" ^
|
|
||||||
/v AutoStartDelay /t REG_DWORD /d 20000 /f >nul
|
|
||||||
|
|
||||||
echo Service registered: "%SERVICE_NAME%" (20s per-service delay, 20s global delay)
|
|
||||||
|
|
||||||
:: ── STEP 4: Disable Windows Firewall (closed network) ────────────────────────
|
|
||||||
echo.
|
echo.
|
||||||
echo [4/8] Disabling Windows Firewall (closed network)...
|
echo [5/7] Configuring power settings...
|
||||||
:: Disable firewall on all profiles — pods run on a closed network
|
if "%ISXP%"=="1" (
|
||||||
netsh advfirewall set allprofiles state off
|
powercfg /setactive "Always On" >nul 2>&1
|
||||||
echo Windows Firewall disabled on all profiles.
|
powercfg /change "Always On" /monitor-timeout-ac 0 >nul 2>&1
|
||||||
|
powercfg /change "Always On" /disk-timeout-ac 0 >nul 2>&1
|
||||||
:: ── STEP 5: Power settings (max performance, no sleep) ──────────────────────
|
powercfg /change "Always On" /standby-timeout-ac 0 >nul 2>&1
|
||||||
echo.
|
powercfg /hibernate off >nul 2>&1
|
||||||
echo [5/8] Configuring power settings...
|
echo "Always On" power scheme activated; sleep/hibernate disabled.
|
||||||
|
) else (
|
||||||
:: Activate Ultimate Performance plan (hidden by default, GUID is well-known)
|
|
||||||
powercfg -duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 >nul 2>&1
|
powercfg -duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 >nul 2>&1
|
||||||
for /f "tokens=4" %%g in ('powercfg -list ^| findstr /i "Ultimate"') do (
|
for /f "tokens=4" %%g in ('powercfg -list ^| findstr /i "Ultimate"') do (
|
||||||
powercfg -setactive %%g
|
powercfg -setactive %%g
|
||||||
)
|
)
|
||||||
echo Ultimate Performance power plan activated.
|
|
||||||
|
|
||||||
:: Disable all sleep/hibernate/standby timeouts (AC power)
|
|
||||||
powercfg -change -standby-timeout-ac 0
|
powercfg -change -standby-timeout-ac 0
|
||||||
powercfg -change -hibernate-timeout-ac 0
|
powercfg -change -hibernate-timeout-ac 0
|
||||||
powercfg -change -monitor-timeout-ac 0
|
powercfg -change -monitor-timeout-ac 0
|
||||||
powercfg -change -disk-timeout-ac 0
|
powercfg -change -disk-timeout-ac 0
|
||||||
powercfg -h off >nul 2>&1
|
powercfg -h off >nul 2>&1
|
||||||
echo Sleep, hibernate, monitor timeout, and disk timeout disabled.
|
echo Ultimate Performance plan activated; sleep/hibernate disabled.
|
||||||
|
)
|
||||||
|
|
||||||
:: ── STEP 6: Disable notifications ───────────────────────────────────────────
|
:: -- STEP 6: Quiet the OS (notifications / UAC - modern Windows only) --------------
|
||||||
echo.
|
echo.
|
||||||
echo [6/8] Disabling notifications...
|
echo [6/7] Disabling notifications and UAC...
|
||||||
:: These are machine-wide Group Policy keys (HKLM) so they apply to EVERY user,
|
if "%ISXP%"=="1" (
|
||||||
:: including the auto-login kiosk account. Per-user HKCU tweaks would only touch
|
echo XP has no Action Center or UAC - nothing to disable.
|
||||||
:: the admin running this installer, not the account the pod actually runs as.
|
) else (
|
||||||
|
rem Machine-wide Group Policy keys (HKLM) so they apply to EVERY user,
|
||||||
:: Remove Action Center / Notification Center entirely
|
rem including the auto-login kiosk account.
|
||||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Explorer" ^
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Explorer" ^
|
||||||
/v DisableNotificationCenter /t REG_DWORD /d 1 /f >nul
|
/v DisableNotificationCenter /t REG_DWORD /d 1 /f >nul
|
||||||
|
|
||||||
:: Kill all toast/app notifications (desktop and lock screen) and cloud toasts
|
|
||||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^
|
||||||
/v NoToastApplicationNotification /t REG_DWORD /d 1 /f >nul
|
/v NoToastApplicationNotification /t REG_DWORD /d 1 /f >nul
|
||||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^
|
||||||
/v NoToastApplicationNotificationOnLockScreen /t REG_DWORD /d 1 /f >nul
|
/v NoToastApplicationNotificationOnLockScreen /t REG_DWORD /d 1 /f >nul
|
||||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^
|
||||||
/v NoCloudApplicationNotification /t REG_DWORD /d 1 /f >nul
|
/v NoCloudApplicationNotification /t REG_DWORD /d 1 /f >nul
|
||||||
|
|
||||||
:: Suppress tips, suggestions, Spotlight, and other consumer content pop-ups
|
|
||||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^
|
||||||
/v DisableSoftLanding /t REG_DWORD /d 1 /f >nul
|
/v DisableSoftLanding /t REG_DWORD /d 1 /f >nul
|
||||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^
|
||||||
/v DisableWindowsSpotlightFeatures /t REG_DWORD /d 1 /f >nul
|
/v DisableWindowsSpotlightFeatures /t REG_DWORD /d 1 /f >nul
|
||||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^
|
||||||
/v DisableWindowsConsumerFeatures /t REG_DWORD /d 1 /f >nul
|
/v DisableWindowsConsumerFeatures /t REG_DWORD /d 1 /f >nul
|
||||||
|
|
||||||
:: Silence Security & Maintenance (formerly Action Center) health notifications
|
|
||||||
reg add "HKLM\SOFTWARE\Microsoft\Windows Defender Security Center\Notifications" ^
|
reg add "HKLM\SOFTWARE\Microsoft\Windows Defender Security Center\Notifications" ^
|
||||||
/v DisableNotifications /t REG_DWORD /d 1 /f >nul
|
/v DisableNotifications /t REG_DWORD /d 1 /f >nul
|
||||||
echo All Windows notifications disabled (machine-wide).
|
|
||||||
|
|
||||||
:: ── STEP 7: Disable UAC ─────────────────────────────────────────────────────
|
|
||||||
echo.
|
|
||||||
echo [7/8] Disabling UAC...
|
|
||||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^
|
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^
|
||||||
/v EnableLUA /t REG_DWORD /d 0 /f >nul
|
/v EnableLUA /t REG_DWORD /d 0 /f >nul
|
||||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^
|
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^
|
||||||
/v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f >nul
|
/v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f >nul
|
||||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^
|
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^
|
||||||
/v PromptOnSecureDesktop /t REG_DWORD /d 0 /f >nul
|
/v PromptOnSecureDesktop /t REG_DWORD /d 0 /f >nul
|
||||||
echo UAC disabled (takes effect after reboot).
|
echo Notifications disabled; UAC disabled ^(takes effect after reboot^).
|
||||||
|
)
|
||||||
|
|
||||||
:: ── STEP 8: Configure Agent auto-start and auto-login ────────────────────────
|
:: -- STEP 7: Auto-login --------------------------------------------------------------
|
||||||
echo.
|
echo.
|
||||||
echo [8/8] Configuring Agent auto-start and auto-login...
|
echo [7/7] Configuring auto-login...
|
||||||
|
:: Winlogon reads these on every boot (same keys since NT). AutoAdminLogon=1
|
||||||
set AUTORUN_KEY=HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
|
:: signs DefaultUserName in with DefaultPassword without a prompt.
|
||||||
reg add "%AUTORUN_KEY%" /v "TeslaLauncherAgent" /t REG_SZ ^
|
|
||||||
/d "\"%AGENT_EXE%\"" /f >nul
|
|
||||||
echo Added TeslaLauncherAgent to HKLM\...\Run.
|
|
||||||
|
|
||||||
:: Auto-login: Winlogon reads these on every boot. AutoAdminLogon=1 tells
|
|
||||||
:: Winlogon to sign the DefaultUserName in with DefaultPassword without a
|
|
||||||
:: prompt. DefaultDomainName="." means the local machine account.
|
|
||||||
set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon
|
set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon
|
||||||
reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d "1" /f >nul
|
reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d "1" /f >nul
|
||||||
reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d "%AUTOLOGIN_USER%" /f >nul
|
reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d "%AUTOLOGIN_USER%" /f >nul
|
||||||
@@ -315,13 +316,15 @@ reg add "%WINLOGON%" /v DefaultDomainName /t REG_SZ /d "." /f >nu
|
|||||||
reg add "%WINLOGON%" /v ForceAutoLogon /t REG_SZ /d "1" /f >nul
|
reg add "%WINLOGON%" /v ForceAutoLogon /t REG_SZ /d "1" /f >nul
|
||||||
:: Clear any logon-count cap that would disable auto-login after N boots.
|
:: Clear any logon-count cap that would disable auto-login after N boots.
|
||||||
reg delete "%WINLOGON%" /v AutoLogonCount /f >nul 2>&1
|
reg delete "%WINLOGON%" /v AutoLogonCount /f >nul 2>&1
|
||||||
:: Some builds require this flag off for a plain-text auto-login password to
|
if "%ISXP%"=="0" (
|
||||||
:: be honored (device-passwordless / Windows Hello preference).
|
rem Some Win10/11 builds require this flag off for a plain-text auto-login
|
||||||
|
rem password to be honored (device-passwordless preference).
|
||||||
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\PasswordLess\Device" ^
|
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\PasswordLess\Device" ^
|
||||||
/v DevicePasswordLessBuildVersion /t REG_DWORD /d 0 /f >nul 2>&1
|
/v DevicePasswordLessBuildVersion /t REG_DWORD /d 0 /f >nul 2>&1
|
||||||
|
)
|
||||||
echo Auto-login configured for user "%AUTOLOGIN_USER%".
|
echo Auto-login configured for user "%AUTOLOGIN_USER%".
|
||||||
|
|
||||||
:: ── Done ──────────────────────────────────────────────────────────────────────
|
:: -- Done ----------------------------------------------------------------------------
|
||||||
echo.
|
echo.
|
||||||
echo ============================================================
|
echo ============================================================
|
||||||
echo Installation Complete!
|
echo Installation Complete!
|
||||||
@@ -332,9 +335,9 @@ echo Config dir : %DATA_DIR%
|
|||||||
echo Firewall : disabled (closed network)
|
echo Firewall : disabled (closed network)
|
||||||
echo.
|
echo.
|
||||||
echo Rebooting in 10 seconds...
|
echo Rebooting in 10 seconds...
|
||||||
echo The Service and Agent will start automatically in the correct order.
|
echo The launcher starts automatically at login (single binary, no service).
|
||||||
echo On first boot with DHCP, SecureConfig runs automatically —
|
echo On first boot with DHCP, SecureConfig runs automatically -
|
||||||
echo watch plasma or on main screen for the Request ID and Passphrase.
|
echo watch plasma or the main screen for the Request ID and Passphrase.
|
||||||
echo.
|
echo.
|
||||||
echo After reboot: use Console to install apps.
|
echo After reboot: use Console to install apps.
|
||||||
echo.
|
echo.
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ The Tesla cockpit-pod software, in one repository:
|
|||||||
|
|
||||||
| Folder | What it is | Target |
|
| Folder | What it is | Target |
|
||||||
|--------|------------|--------|
|
|--------|------------|--------|
|
||||||
| [`Console/`](Console/) | **TeslaConsole** — the operator console (WinForms) that configures and drives the pods. A decompiled reconstruction of the original `TeslaConsole.exe` (now the modernized 4.11.4.x line), with a differential test suite pinning it to the original 4.11.3.37076 baseline. | .NET Framework 4.8 |
|
| [`Console/`](Console/) | **TeslaConsole** — the operator console (WinForms) that configures and drives the pods. A decompiled reconstruction of the original `TeslaConsole.exe` (now the modernized 4.11.4.x line), with a differential test suite pinning it to the original 4.11.3.37076 baseline. | .NET Framework 4.0 |
|
||||||
| [`Launcher/`](Launcher/) | **TeslaLauncher** — the pod-side Service (Session 0 RPC listener) + Agent (user-session app launcher). A clean rewrite of the original launcher. | .NET Framework 4.8 |
|
| [`Launcher/`](Launcher/) | **TeslaLauncher** — the pod-side launcher: ONE userland tray app (RPC listener + app launcher). A clean rewrite of the original; the old Service+Agent split (a Session 0 workaround) is gone. | .NET Framework 4.0 |
|
||||||
| [`Contract/`](Contract/) | **Tesla.Contract** — the shared Console↔Launcher RPC contract: wire types, the client, and the framed-JSON protocol. Emits assembly `TeslaConsoleLaunchLib`. | .NET Framework 4.8 |
|
| [`Contract/`](Contract/) | **Tesla.Contract** — the shared Console↔Launcher RPC contract: wire types, the client, and the framed-JSON protocol. Emits assembly `TeslaConsoleLaunchLib`. | .NET Framework 4.0 |
|
||||||
| [`SecureConfig/`](SecureConfig/) | **Tesla.SecureConfig** — the first-boot pod provisioning protocol (UDP beacons, OFB crypto, RSA key exchange). Emits assembly `TeslaSecureConfiguration`. | .NET Framework 4.8 |
|
| [`SecureConfig/`](SecureConfig/) | **Tesla.SecureConfig** — the first-boot pod provisioning protocol (UDP beacons, OFB crypto, RSA key exchange). Emits assembly `TeslaSecureConfiguration`. | .NET Framework 4.0 |
|
||||||
|
| [`vPOD/`](vPOD/) | **vPOD** — a virtual pod for testing the consoles without cockpit hardware: impersonates both the game client (Munga, TCP 1501) and the pod's TeslaLauncher (provisioning + Site Management / Install Product on TCP 53290). | .NET Framework 4.0 |
|
||||||
|
|
||||||
The console and launcher talk over **TCP 53290** using **length-prefixed
|
Everything targets **net40** on purpose (the XP11 port, v4.11.4.3): it is the newest
|
||||||
`System.Text.Json` frames over an OFB-encrypted stream** ([`Contract/PodRpcProtocol.cs`](Contract/PodRpcProtocol.cs)),
|
.NET Framework that installs on Windows XP SP3, and net40 assemblies run in-place on
|
||||||
|
the 4.8 runtime that ships in Windows 10/11 — so the same binaries cover the original
|
||||||
|
XP-era cockpit PCs and modern hardware. That rules out net45+ APIs
|
||||||
|
(`System.Text.Json`, `ZipFile`, async/await, ...); JSON is Newtonsoft, zip extraction
|
||||||
|
is the launcher's own [`MiniZip.cs`](Launcher/MiniZip.cs).
|
||||||
|
|
||||||
|
The console and launcher talk over **TCP 53290** using **length-prefixed JSON
|
||||||
|
frames over an OFB-encrypted stream** ([`Contract/PodRpcProtocol.cs`](Contract/PodRpcProtocol.cs)),
|
||||||
dispatched by method name. The wire contract lives in one source project
|
dispatched by method name. The wire contract lives in one source project
|
||||||
([`Contract/`](Contract/)) referenced by both sides — a single source of truth, no
|
([`Contract/`](Contract/)) referenced by both sides — a single source of truth, no
|
||||||
duplication or hand-syncing.
|
duplication or hand-syncing.
|
||||||
@@ -27,12 +35,20 @@ dotnet test Console/tests/TeslaConsole.DiffTests # differential + protocol
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Pod deployment:** [`Launcher/build.bat`](Launcher/build.bat) publishes the
|
**Pod deployment:** [`Launcher/build.bat`](Launcher/build.bat) publishes the
|
||||||
framework-dependent net48 package into `Launcher/dist/` (~1.6 MB zipped — no runtime to
|
framework-dependent net40 package into `Launcher/dist/` — the launcher itself is tiny,
|
||||||
install, since .NET Framework 4.8 ships in Windows 10/11), and
|
but the package bundles the pod redists (DirectX June 2010, OpenAL, UltraVNC, and
|
||||||
[`Launcher/install.bat`](Launcher/install.bat) deploys it on a cockpit PC (registers the
|
`dotNetFx40_Full_x86_x64.exe` for XP-era pods that don't have .NET 4.0 yet).
|
||||||
Service, sets up the Agent for auto-login, hardens the box). The operator console packages
|
[`Launcher/install.bat`](Launcher/install.bat) deploys it on a cockpit PC — dual-OS
|
||||||
|
(XP SP3 and Win10/11 code paths): auto-login, Run-key registration for the single
|
||||||
|
launcher binary, firewall + box hardening. The operator console packages
|
||||||
the same way: [`Console/build-package.bat`](Console/build-package.bat) → `Console/dist/`,
|
the same way: [`Console/build-package.bat`](Console/build-package.bat) → `Console/dist/`,
|
||||||
installed with [`Console/install.bat`](Console/install.bat).
|
installed with [`Console/install.bat`](Console/install.bat). vPOD packages with
|
||||||
|
[`vPOD/pack.ps1`](vPOD/pack.ps1) → `vPOD/dist/vPOD.zip`, deployable to a pod via the
|
||||||
|
console's Install Product (or run directly on any machine).
|
||||||
|
|
||||||
|
Release packages for all three are attached to the
|
||||||
|
[Gitea releases](https://gitea.mysticmachines.com/VWE/TeslaSuite/releases)
|
||||||
|
(latest: **v4.11.4.3**).
|
||||||
|
|
||||||
## Layout notes
|
## Layout notes
|
||||||
|
|
||||||
@@ -50,6 +66,11 @@ installed with [`Console/install.bat`](Console/install.bat).
|
|||||||
The system was modernized in 2026: the duplicated wire contract was extracted to a single
|
The system was modernized in 2026: the duplicated wire contract was extracted to a single
|
||||||
source project, `BinaryFormatter` (an RCE sink, and what pinned the launcher to an old
|
source project, `BinaryFormatter` (an RCE sink, and what pinned the launcher to an old
|
||||||
runtime) was replaced with the framed-JSON protocol, and the launcher was rebuilt — briefly
|
runtime) was replaced with the framed-JSON protocol, and the launcher was rebuilt — briefly
|
||||||
on net8/x64, then settled on net48 to match the console and ship a tiny, runtime-free
|
on net8/x64, then on net48, then (the **XP11** port, v4.11.4.3) the whole suite settled on
|
||||||
package. The whole console↔pod path (provisioning, install, launch) is validated on real
|
**net40** so one set of binaries runs on the original Windows XP SP3 cockpit hardware and
|
||||||
pods.
|
on Windows 10/11 alike. XP11 also merged the launcher's Service+Agent pair — a workaround
|
||||||
|
for Vista+ Session 0 isolation that XP never needed — back into a single userland app, and
|
||||||
|
moved the console's `.resx` BinaryFormatter bitmaps to raw embedded images (their runtime
|
||||||
|
reader doesn't exist on net40). The whole console↔pod path (provisioning, install, launch)
|
||||||
|
is validated on real pods; the net40 build is bench-validated on Win11's 4.8 runtime (real
|
||||||
|
XP SP3 hardware still pending).
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- net48 only: consumed by the net48 Console and the net48 client half of
|
<!-- net40 only (XP11), like everything that consumes it: the client half of
|
||||||
Tesla.Contract. The pod side is the Launcher's own SecureConfig.cs. -->
|
Tesla.Contract, the Console, and vPOD. The protocol code is net20-era
|
||||||
<TargetFramework>net48</TargetFramework>
|
(RSACryptoServiceProvider / Rijndael / CryptoStream), so the old net48
|
||||||
|
leg compiled from the same source; it was dropped 2026-07-11 with the
|
||||||
|
Contract's. The pod side is still the Launcher's own SecureConfig.cs. -->
|
||||||
|
<TargetFramework>net40</TargetFramework>
|
||||||
|
|
||||||
<!-- Emit an assembly named TeslaSecureConfiguration (v1.0.0.0) so it is a
|
<!-- Emit an assembly named TeslaSecureConfiguration (v1.0.0.0) so it is a
|
||||||
drop-in replacement for the original vendored binary. Unlike the wire
|
drop-in replacement for the original vendored binary. Unlike the wire
|
||||||
|
|||||||
@@ -11,24 +11,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{27C769F3
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaConsole.DiffTests", "Console\tests\TeslaConsole.DiffTests\TeslaConsole.DiffTests.csproj", "{467AA87A-FBD4-45D7-B8F8-9336C95884D2}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaConsole.DiffTests", "Console\tests\TeslaConsole.DiffTests\TeslaConsole.DiffTests.csproj", "{467AA87A-FBD4-45D7-B8F8-9336C95884D2}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherService", "Launcher\TeslaLauncherService.csproj", "{910D4404-B3A2-4217-B61C-D43E7F22CDAA}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncher", "Launcher\TeslaLauncher.csproj", "{E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}"
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherAgent", "Launcher\TeslaLauncherAgent.csproj", "{916DCDA5-3379-4383-96F0-AC7B26FAF64E}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tesla.Contract", "Contract\Tesla.Contract.csproj", "{0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tesla.Contract", "Contract\Tesla.Contract.csproj", "{0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tesla.SecureConfig", "SecureConfig\Tesla.SecureConfig.csproj", "{070A6093-6C46-4A5B-A119-47ED195530E1}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tesla.SecureConfig", "SecureConfig\Tesla.SecureConfig.csproj", "{070A6093-6C46-4A5B-A119-47ED195530E1}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "vPOD", "Console\vPOD\vPOD.csproj", "{9EAC97A1-D71A-4AAB-9957-A79A1587D406}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "vPOD", "vPOD\vPOD.csproj", "{9EAC97A1-D71A-4AAB-9957-A79A1587D406}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{27285664-95C3-49FB-95BA-A34721060BF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{27285664-95C3-49FB-95BA-A34721060BF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{27285664-95C3-49FB-95BA-A34721060BF1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{27285664-95C3-49FB-95BA-A34721060BF1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
@@ -38,14 +33,10 @@ Global
|
|||||||
{467AA87A-FBD4-45D7-B8F8-9336C95884D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{467AA87A-FBD4-45D7-B8F8-9336C95884D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{467AA87A-FBD4-45D7-B8F8-9336C95884D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{467AA87A-FBD4-45D7-B8F8-9336C95884D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{467AA87A-FBD4-45D7-B8F8-9336C95884D2}.Release|Any CPU.Build.0 = Release|Any CPU
|
{467AA87A-FBD4-45D7-B8F8-9336C95884D2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{910D4404-B3A2-4217-B61C-D43E7F22CDAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{910D4404-B3A2-4217-B61C-D43E7F22CDAA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{910D4404-B3A2-4217-B61C-D43E7F22CDAA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{910D4404-B3A2-4217-B61C-D43E7F22CDAA}.Release|Any CPU.Build.0 = Release|Any CPU
|
{E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{916DCDA5-3379-4383-96F0-AC7B26FAF64E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{916DCDA5-3379-4383-96F0-AC7B26FAF64E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{916DCDA5-3379-4383-96F0-AC7B26FAF64E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{916DCDA5-3379-4383-96F0-AC7B26FAF64E}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
@@ -59,9 +50,11 @@ Global
|
|||||||
{9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Release|Any CPU.Build.0 = Release|Any CPU
|
{9EAC97A1-D71A-4AAB-9957-A79A1587D406}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{27C769F3-F07F-456E-ACB7-F4A4A21AB6D1} = {91E92ADD-9F67-41E4-B43C-CCB7B2D95F15}
|
{27C769F3-F07F-456E-ACB7-F4A4A21AB6D1} = {91E92ADD-9F67-41E4-B43C-CCB7B2D95F15}
|
||||||
{467AA87A-FBD4-45D7-B8F8-9336C95884D2} = {27C769F3-F07F-456E-ACB7-F4A4A21AB6D1}
|
{467AA87A-FBD4-45D7-B8F8-9336C95884D2} = {27C769F3-F07F-456E-ACB7-F4A4A21AB6D1}
|
||||||
{9EAC97A1-D71A-4AAB-9957-A79A1587D406} = {91E92ADD-9F67-41E4-B43C-CCB7B2D95F15}
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Threading;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Tesla;
|
||||||
|
using Tesla.Launcher;
|
||||||
|
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" />; packaged product scripts
|
||||||
|
/// (postinstall.bat here, pre-uninstall.bat in UninstallApp) are logged and
|
||||||
|
/// removed unrun unless <see cref="VirtualLauncher.RunPackageScripts" /> is set
|
||||||
|
/// from the vPOD window, in which case they run like on the real pod.
|
||||||
|
/// </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 ?? "???";
|
||||||
|
List<JToken> args = request.Args ?? new List<JToken>();
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPC args surface as Newtonsoft JTokens (the Contract is net40;
|
||||||
|
// System.Text.Json has no net40 target).
|
||||||
|
private static bool IsNull(JToken arg) => arg == null || arg.Type == JTokenType.Null;
|
||||||
|
|
||||||
|
private static T Arg<T>(JToken arg) => arg.ToObject<T>(PodRpc.JsonOptions);
|
||||||
|
|
||||||
|
/// <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, List<JToken> args)
|
||||||
|
{
|
||||||
|
switch (method)
|
||||||
|
{
|
||||||
|
case "Ping":
|
||||||
|
return args.Count > 0 && !IsNull(args[0])
|
||||||
|
? mLauncher.Ping(Arg<DateTime>(args[0]))
|
||||||
|
: 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(Arg<Guid>(args[0]));
|
||||||
|
|
||||||
|
case "InitiateInstallProduct":
|
||||||
|
return mLauncher.InitiateInstallProduct();
|
||||||
|
|
||||||
|
case "InstallApp":
|
||||||
|
mLauncher.InstallApp(Arg<LaunchData>(args[0]));
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "UninstallApp":
|
||||||
|
mLauncher.UninstallApp(Arg<Guid>(args[0]));
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "RemoveApp":
|
||||||
|
mLauncher.RemoveApp(Arg<Guid>(args[0]));
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "LaunchApp":
|
||||||
|
return mLauncher.LaunchApp(Arg<Guid>(args[0]));
|
||||||
|
|
||||||
|
case "KillApp":
|
||||||
|
mLauncher.KillApp(Arg<Guid>(args[0]), Arg<int>(args[1]));
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "KillAllOfType":
|
||||||
|
mLauncher.KillAllOfType(Arg<Guid>(args[0]));
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "KillAllApps":
|
||||||
|
mLauncher.KillAllApps();
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "Shutdown":
|
||||||
|
mLauncher.Shutdown(Arg<bool>(args[0]));
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "ClearStore":
|
||||||
|
mLauncher.ClearStore();
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "get_VolumeLevel":
|
||||||
|
return mLauncher.VolumeLevel;
|
||||||
|
|
||||||
|
case "set_VolumeLevel":
|
||||||
|
mLauncher.VolumeLevel = Arg<float>(args[0]);
|
||||||
|
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
|
||||||
|
/// games root (the real C:\Games, like the launcher; tests override it),
|
||||||
|
/// 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);
|
||||||
|
// The Launcher's own extractor (zip-slip protection included): net40
|
||||||
|
// has no ZipFile/ZipArchive, and sharing it keeps vPOD's extraction
|
||||||
|
// byte-identical to the real pod service.
|
||||||
|
MiniZip.ExtractToDirectory(tempZip, gamesRoot, (done, total) =>
|
||||||
|
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 only does so when the operator opts in via RunPackageScripts;
|
||||||
|
// otherwise the script is logged and removed unrun (default), since it
|
||||||
|
// runs package code on the host machine.
|
||||||
|
string postInstall = Path.Combine(gamesRoot, "postinstall.bat");
|
||||||
|
if (File.Exists(postInstall))
|
||||||
|
{
|
||||||
|
if (mLauncher.RunPackageScripts)
|
||||||
|
{
|
||||||
|
mLauncher.UpdateProgress(callId, 96, "Running postinstall...");
|
||||||
|
Log?.Invoke($"Install {callId:N}: running postinstall.bat...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ProcessStartInfo psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "cmd.exe",
|
||||||
|
Arguments = "/c \"" + postInstall + "\"",
|
||||||
|
WorkingDirectory = gamesRoot,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
using (Process proc = Process.Start(psi))
|
||||||
|
{
|
||||||
|
if (proc.WaitForExit(300000))
|
||||||
|
{
|
||||||
|
Log?.Invoke($"Install {callId:N}: postinstall.bat exited with code {proc.ExitCode}.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log?.Invoke($"Install {callId:N}: postinstall.bat still running after 5 min — leaving it, continuing.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log?.Invoke($"Install {callId:N}: postinstall.bat failed to run: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ namespace VPod;
|
|||||||
/// <c>-app rp|bt</c> which ApplicationID to report (RP by default; also
|
/// <c>-app rp|bt</c> which ApplicationID to report (RP by default; also
|
||||||
/// switchable live in the UI)
|
/// switchable live in the UI)
|
||||||
/// <c>-host <id></c> the responding host id reported in state responses
|
/// <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>
|
/// </summary>
|
||||||
internal sealed class PodArguments
|
internal sealed class PodArguments
|
||||||
{
|
{
|
||||||
@@ -25,6 +29,8 @@ internal sealed class PodArguments
|
|||||||
|
|
||||||
public int HostId { get; private set; } = 1;
|
public int HostId { get; private set; } = 1;
|
||||||
|
|
||||||
|
public bool NoManage { get; private set; }
|
||||||
|
|
||||||
public static PodArguments Parse(string[] args)
|
public static PodArguments Parse(string[] args)
|
||||||
{
|
{
|
||||||
PodArguments result = new PodArguments();
|
PodArguments result = new PodArguments();
|
||||||
@@ -63,6 +69,9 @@ internal sealed class PodArguments
|
|||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "-nomanage":
|
||||||
|
result.NoManage = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# 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 `C:\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 the real
|
||||||
|
`C:\Games` — the launcher's games root, so products land where their catalog
|
||||||
|
launch entries point — 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 into `C:\Games` (created on first install if missing; if an
|
||||||
|
admin-owned `C:\Games` isn't writable by your account, the install reports
|
||||||
|
Failed — fix the folder's ACL or run vPOD elevated). Uninstalling a product
|
||||||
|
removes its `C:\Games\<product>` folder, like the real launcher. A packaged
|
||||||
|
`postinstall.bat` is logged and removed **unrun by default**; the **"Run
|
||||||
|
postinstall.bat after install"** checkbox makes the install execute it (via
|
||||||
|
`cmd /c`, waited on up to 5 min) before deleting it, like the real Agent —
|
||||||
|
off by default because it runs package script code on the host machine.
|
||||||
|
- **Launch/Kill** from the squad panel simulate PIDs by default. The
|
||||||
|
**"Actually launch apps (real processes)"** checkbox switches to the real
|
||||||
|
Agent's behavior: LaunchApp starts the entry's exe from `C:\Games` (missing
|
||||||
|
exe → the same "registered but not yet installed" error a real pod gives),
|
||||||
|
Kill\* terminate the processes, and apps that exit or crash on their own
|
||||||
|
disappear from the console's running list. The indented **"Auto-restart
|
||||||
|
after the app exits (watchdog)"** checkbox (on by default) adds the Agent's
|
||||||
|
watchdog: an `autoRestart` entry that exits on its own relaunches after 2 s,
|
||||||
|
while console-ordered kills stay down — exactly the real pod's behavior.
|
||||||
|
Real processes also die when the pod is powered off / rebooted / the vPOD
|
||||||
|
window closes (which also cancels pending watchdog restarts). Caveat:
|
||||||
|
launching a *deployed vPOD* or a real game client this way will fight the
|
||||||
|
running vPOD for ports 1501/53290. 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`).
|
||||||
|
|
||||||
|
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 **not** in the shipped catalog (`Console\RedPlanet\Apps.xml`) — it was
|
||||||
|
removed 2026-07-11 since it is a dev tool, never a console-deployed product. To
|
||||||
|
push it to a pod for testing, add it locally via Site Management → **Add New
|
||||||
|
Product...** (or hand-edit Apps.xml) and 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 a launch entry pointing at `C:\Games\vPOD\vPOD.exe` works
|
||||||
|
as-is.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
// No System.Text.Json on net40 — persistence goes through Newtonsoft, which
|
||||||
|
// writes the same JSON shape (public fields, PascalCase; see PodRpcProtocol).
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Tesla.Launcher;
|
||||||
|
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. Launch/Kill simulate PIDs by default; with
|
||||||
|
/// <see cref="RealLaunch" /> set they start and terminate real processes,
|
||||||
|
/// mirroring the Agent.
|
||||||
|
///
|
||||||
|
/// Persisted state:
|
||||||
|
/// %LocalAppData%\vPOD\TeslaKeyStore.key session key, launcher format: [1-byte len][key]
|
||||||
|
/// %LocalAppData%\vPOD\LaunchApps.json installed apps (the wire LaunchData list, PodRpc JSON)
|
||||||
|
/// C:\Games\ where InstallProduct zips are extracted — the
|
||||||
|
/// REAL games root, same as the launcher's GAMES_DIR,
|
||||||
|
/// so deployed products land where their catalog
|
||||||
|
/// launch entries point (C:\Games\...\*.exe)
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class VirtualLauncher
|
||||||
|
{
|
||||||
|
/// <summary>The launcher's GAMES_DIR. Tests override via the ctor for isolation.</summary>
|
||||||
|
public const string DefaultGamesRoot = @"C:\Games";
|
||||||
|
|
||||||
|
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<int, Process> mRealProcesses = new Dictionary<int, Process>(); // pid -> live process, RealLaunch mode
|
||||||
|
private readonly Dictionary<Guid, OutOfBandProgress> mInstallProgress = new Dictionary<Guid, OutOfBandProgress>();
|
||||||
|
private float mVolumeLevel = 1.0f;
|
||||||
|
private int mNextPid = 4000;
|
||||||
|
|
||||||
|
/// <summary>When set, LaunchApp actually starts the entry's ExeFile (like the
|
||||||
|
/// real Agent) instead of recording a simulated PID, and the Kill* commands
|
||||||
|
/// terminate those processes. Toggled from the vPOD window; off by default.
|
||||||
|
/// Entries launched in either mode coexist — kills handle both.</summary>
|
||||||
|
public bool RealLaunch { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The Agent's autoRestart watchdog for real-launched apps: when a
|
||||||
|
/// tracked process exits on its own (not via a Kill* command), relaunch it
|
||||||
|
/// after 2 s — but only for entries whose LaunchData.AutoRestart is set,
|
||||||
|
/// exactly like the real pod. Toggled from the vPOD window.</summary>
|
||||||
|
public bool RealAutoRestart { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>When set, packaged product scripts are executed like on the real
|
||||||
|
/// pod: postinstall.bat at the end of a product install (before being
|
||||||
|
/// deleted), and pre-uninstall.bat before the product directory is removed
|
||||||
|
/// on uninstall. Off by default — both run package script code on the host
|
||||||
|
/// machine — in which case they are logged and removed unrun. Toggled from
|
||||||
|
/// the vPOD window.</summary>
|
||||||
|
public bool RunPackageScripts { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When set, the console's set_VolumeLevel changes this machine's
|
||||||
|
/// REAL master volume through the launcher's own chain (nircmd.exe in the
|
||||||
|
/// games root → Core Audio → winmm), exactly like the real pod. Off by
|
||||||
|
/// default: the value is only stored and displayed. Toggled from the vPOD
|
||||||
|
/// window.</summary>
|
||||||
|
public bool RealVolume { get; set; }
|
||||||
|
|
||||||
|
// Bumped to cancel watchdog restarts pending in their 2 s delay — the pod
|
||||||
|
// "machine" went dark (power off / reboot / reprovision), so nothing may
|
||||||
|
// relaunch after it.
|
||||||
|
private int mWatchdogGeneration;
|
||||||
|
|
||||||
|
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 { get; }
|
||||||
|
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, string gamesRoot = null)
|
||||||
|
{
|
||||||
|
DataDirectory = dataDirectory;
|
||||||
|
GamesRoot = gamesRoot ?? DefaultGamesRoot;
|
||||||
|
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; }
|
||||||
|
if (RealVolume)
|
||||||
|
{
|
||||||
|
// The launcher's own device chain; probes GamesRoot for
|
||||||
|
// nircmd.exe first, like the real pod's GAMES_DIR.
|
||||||
|
VolumeControl.SetMasterScalar(value, GamesRoot);
|
||||||
|
Log?.Invoke($"Volume set to {value:P0} (applied to system).");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log?.Invoke($"Volume set to {value:P0} (simulated).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
SaveApps();
|
||||||
|
// Like the real Agent's CleanupDir: remove the product folder, but only
|
||||||
|
// when it's inside our 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Kill (real or simulated) BEFORE deleting the product directory, like the
|
||||||
|
// real Agent — a still-running process would hold its files locked.
|
||||||
|
KillRealProcesses(RemoveLaunched(l => l.LaunchKey == launchKey));
|
||||||
|
if (cleanupDir != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(cleanupDir))
|
||||||
|
{
|
||||||
|
RunPreUninstallScript(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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The real Agent's pre-uninstall hook: a product may ship a
|
||||||
|
/// pre-uninstall.bat (e.g. RIOJoy removes its driver) that runs before its
|
||||||
|
/// directory is deleted. Executed only when <see cref="RunPackageScripts" />
|
||||||
|
/// is opted in — otherwise logged and removed unrun (it dies with the
|
||||||
|
/// directory). Mirrors the launcher's CleanupProductDirectory.</summary>
|
||||||
|
private void RunPreUninstallScript(string productDir)
|
||||||
|
{
|
||||||
|
string preUninstall = Path.Combine(productDir, "pre-uninstall.bat");
|
||||||
|
if (!File.Exists(preUninstall))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!RunPackageScripts)
|
||||||
|
{
|
||||||
|
Log?.Invoke("UninstallApp: pre-uninstall.bat present — NOT executed (vPOD), removed with the directory.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log?.Invoke("UninstallApp: running pre-uninstall.bat...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ProcessStartInfo psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = preUninstall,
|
||||||
|
WorkingDirectory = productDir,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
using (Process proc = Process.Start(psi))
|
||||||
|
{
|
||||||
|
if (proc != null && proc.WaitForExit(120000))
|
||||||
|
{
|
||||||
|
Log?.Invoke($"UninstallApp: pre-uninstall.bat exited with code {proc.ExitCode}.");
|
||||||
|
}
|
||||||
|
else if (proc != null)
|
||||||
|
{
|
||||||
|
Log?.Invoke("UninstallApp: pre-uninstall.bat still running after 2 min — continuing.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log?.Invoke($"UninstallApp: pre-uninstall.bat failed to run: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveApp(Guid launchKey)
|
||||||
|
{
|
||||||
|
lock (mLock)
|
||||||
|
{
|
||||||
|
int removed = mInstalledApps.RemoveAll(a => a.LaunchPair.LaunchKey == launchKey);
|
||||||
|
if (removed == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SaveApps();
|
||||||
|
}
|
||||||
|
KillRealProcesses(RemoveLaunched(l => l.LaunchKey == launchKey));
|
||||||
|
Log?.Invoke($"RemoveApp: {launchKey} unregistered.");
|
||||||
|
AppsChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int LaunchApp(Guid launchKey)
|
||||||
|
{
|
||||||
|
LaunchData app;
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
if (RealLaunch)
|
||||||
|
{
|
||||||
|
return LaunchRealProcess(app);
|
||||||
|
}
|
||||||
|
int pid;
|
||||||
|
lock (mLock)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Actually starts the launch entry, mirroring the real Agent's
|
||||||
|
/// CmdLaunchApp: same ProcessStartInfo shape, and the same clean error when
|
||||||
|
/// the entry is registered but its files aren't installed (the console shows
|
||||||
|
/// the thrown message, exactly as against a real pod).</summary>
|
||||||
|
private int LaunchRealProcess(LaunchData app)
|
||||||
|
{
|
||||||
|
string name = app.LaunchPair.DisplayName ?? app.LaunchPair.LaunchKey.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(app.ExeFile) || !File.Exists(app.ExeFile))
|
||||||
|
{
|
||||||
|
Log?.Invoke($"LaunchApp: \"{name}\": executable not found at '{app.ExeFile}'.");
|
||||||
|
throw new Exception(
|
||||||
|
$"Cannot launch '{name}': executable not found at '{app.ExeFile}'. " +
|
||||||
|
"The product may be registered but not yet installed on this pod.");
|
||||||
|
}
|
||||||
|
ProcessStartInfo psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = app.ExeFile,
|
||||||
|
Arguments = app.Arguments ?? "",
|
||||||
|
WorkingDirectory = string.IsNullOrEmpty(app.WorkingDirectory)
|
||||||
|
? Path.GetDirectoryName(app.ExeFile)
|
||||||
|
: app.WorkingDirectory,
|
||||||
|
UseShellExecute = false
|
||||||
|
};
|
||||||
|
Process process = Process.Start(psi)
|
||||||
|
?? throw new Exception($"Process.Start returned null for '{app.ExeFile}'");
|
||||||
|
lock (mLock)
|
||||||
|
{
|
||||||
|
mLaunchedApps.Add(new LaunchedAppData { ProcessId = process.Id, LaunchKey = app.LaunchPair.LaunchKey });
|
||||||
|
mRealProcesses[process.Id] = process;
|
||||||
|
}
|
||||||
|
StartWatcher(app, process, process.Id);
|
||||||
|
Log?.Invoke($"LaunchApp: \"{name}\" -> started {app.ExeFile} (real PID {process.Id}).");
|
||||||
|
AppsChanged?.Invoke();
|
||||||
|
return process.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Agent's per-process watcher: waits for exit, and if the process
|
||||||
|
/// is STILL TRACKED (Kill*/Uninstall remove it from the table first, so a
|
||||||
|
/// console-ordered kill never restarts), drops it from the running list and —
|
||||||
|
/// when the watchdog toggle and the entry's AutoRestart both apply — relaunches
|
||||||
|
/// it after the Agent's 2 s delay.</summary>
|
||||||
|
private void StartWatcher(LaunchData app, Process process, int pid)
|
||||||
|
{
|
||||||
|
string name = app.LaunchPair.DisplayName ?? app.LaunchPair.LaunchKey.ToString();
|
||||||
|
new Thread(() =>
|
||||||
|
{
|
||||||
|
try { process.WaitForExit(); }
|
||||||
|
catch { /* handle disposed by a Kill* — it is no longer tracked */ }
|
||||||
|
|
||||||
|
bool stillTracked;
|
||||||
|
lock (mLock)
|
||||||
|
{
|
||||||
|
stillTracked = mRealProcesses.TryGetValue(pid, out Process tracked) && ReferenceEquals(tracked, process);
|
||||||
|
if (stillTracked)
|
||||||
|
{
|
||||||
|
mRealProcesses.Remove(pid);
|
||||||
|
mLaunchedApps.RemoveAll(l => l.ProcessId == pid);
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stillTracked)
|
||||||
|
{
|
||||||
|
return; // killed via the console / power-off; whoever killed it cleaned up
|
||||||
|
}
|
||||||
|
AppsChanged?.Invoke();
|
||||||
|
|
||||||
|
// Thread.VolatileRead, not Volatile.Read: the latter is net45+.
|
||||||
|
int generation = Thread.VolatileRead(ref mWatchdogGeneration);
|
||||||
|
if (!RealAutoRestart || !app.AutoRestart)
|
||||||
|
{
|
||||||
|
Log?.Invoke($"\"{name}\" (PID {pid}) exited on its own (no watchdog restart).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log?.Invoke($"\"{name}\" (PID {pid}) exited on its own — watchdog restarting in 2 s...");
|
||||||
|
Thread.Sleep(2000);
|
||||||
|
|
||||||
|
// Still wanted? The pod may have powered off/reprovisioned (generation),
|
||||||
|
// the mode or toggle flipped, or the app been uninstalled meanwhile.
|
||||||
|
if (generation != Thread.VolatileRead(ref mWatchdogGeneration) || !RealLaunch || !RealAutoRestart)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LaunchData current;
|
||||||
|
lock (mLock)
|
||||||
|
{
|
||||||
|
int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == app.LaunchPair.LaunchKey);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return; // uninstalled during the delay
|
||||||
|
}
|
||||||
|
current = mInstalledApps[index];
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LaunchRealProcess(current);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log?.Invoke($"Watchdog restart of \"{name}\" failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{ IsBackground = true, Name = $"vPOD-watchdog-{pid}" }.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void KillApp(Guid launchKey, int processId)
|
||||||
|
{
|
||||||
|
List<LaunchedAppData> victims = RemoveLaunched(l => l.LaunchKey == launchKey && l.ProcessId == processId);
|
||||||
|
if (victims.Count > 0)
|
||||||
|
{
|
||||||
|
int real = KillRealProcesses(victims);
|
||||||
|
Log?.Invoke($"KillApp: PID {processId} killed{(real > 0 ? "" : " (simulated)")}.");
|
||||||
|
AppsChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void KillAllOfType(Guid launchKey)
|
||||||
|
{
|
||||||
|
List<LaunchedAppData> victims = RemoveLaunched(l => l.LaunchKey == launchKey);
|
||||||
|
if (victims.Count > 0)
|
||||||
|
{
|
||||||
|
int real = KillRealProcesses(victims);
|
||||||
|
Log?.Invoke($"KillAllOfType: {victims.Count} process(es) killed ({real} real).");
|
||||||
|
AppsChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Kills everything launched. <paramref name="cancelPendingRestarts" />
|
||||||
|
/// is the pod-power path (off/reboot/exit): it also aborts watchdog restarts
|
||||||
|
/// waiting out their 2 s delay. The console's KillAllApps RPC leaves them
|
||||||
|
/// pending, matching the real Agent's race behavior.</summary>
|
||||||
|
public void KillAllApps(bool cancelPendingRestarts = false)
|
||||||
|
{
|
||||||
|
if (cancelPendingRestarts)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref mWatchdogGeneration);
|
||||||
|
}
|
||||||
|
List<LaunchedAppData> victims = RemoveLaunched(l => true);
|
||||||
|
if (victims.Count > 0)
|
||||||
|
{
|
||||||
|
int real = KillRealProcesses(victims);
|
||||||
|
Log?.Invoke($"KillAllApps: {victims.Count} process(es) killed ({real} real).");
|
||||||
|
AppsChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes matching entries from the launched table and returns them,
|
||||||
|
/// so real processes can be killed outside the lock.</summary>
|
||||||
|
private List<LaunchedAppData> RemoveLaunched(Predicate<LaunchedAppData> match)
|
||||||
|
{
|
||||||
|
List<LaunchedAppData> removed = new List<LaunchedAppData>();
|
||||||
|
lock (mLock)
|
||||||
|
{
|
||||||
|
for (int i = mLaunchedApps.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (match(mLaunchedApps[i]))
|
||||||
|
{
|
||||||
|
removed.Add(mLaunchedApps[i]);
|
||||||
|
mLaunchedApps.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Terminates any real processes behind the given (already removed)
|
||||||
|
/// entries; simulated PIDs are ignored. Waits briefly for each exit so a
|
||||||
|
/// following product-directory delete doesn't race the dying process.</summary>
|
||||||
|
private int KillRealProcesses(List<LaunchedAppData> victims)
|
||||||
|
{
|
||||||
|
int killed = 0;
|
||||||
|
foreach (LaunchedAppData victim in victims)
|
||||||
|
{
|
||||||
|
Process process;
|
||||||
|
lock (mLock)
|
||||||
|
{
|
||||||
|
if (!mRealProcesses.TryGetValue(victim.ProcessId, out process))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mRealProcesses.Remove(victim.ProcessId);
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!process.HasExited)
|
||||||
|
{
|
||||||
|
process.Kill();
|
||||||
|
process.WaitForExit(3000);
|
||||||
|
killed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log?.Invoke($"Kill PID {victim.ProcessId}: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return killed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
/// Any real launched processes are terminated first, and pending watchdog
|
||||||
|
/// restarts cancelled.</summary>
|
||||||
|
public void WipeApps()
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref mWatchdogGeneration);
|
||||||
|
KillRealProcesses(RemoveLaunched(l => true));
|
||||||
|
lock (mLock)
|
||||||
|
{
|
||||||
|
mInstalledApps.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 = JsonConvert.DeserializeObject<LaunchData[]>(File.ReadAllText(LaunchAppsPath));
|
||||||
|
if (apps != null)
|
||||||
|
{
|
||||||
|
mInstalledApps.AddRange(apps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// unreadable store: start empty, like a fresh pod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveApps()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(LaunchAppsPath, JsonConvert.SerializeObject(mInstalledApps.ToArray()));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log?.Invoke("Could not persist LaunchApps.json: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ param([string]$Config = 'Release')
|
|||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
$bin = Join-Path $root "bin\$Config\net48"
|
# net40 leg: the XP11 build that runs on XP SP3 pods through Windows 11.
|
||||||
|
$bin = Join-Path $root "bin\$Config\net40"
|
||||||
$distDir = Join-Path $root 'dist'
|
$distDir = Join-Path $root 'dist'
|
||||||
$stage = Join-Path $distDir 'vPOD' # -> C:\Games\vPOD on the pod
|
$stage = Join-Path $distDir 'vPOD' # -> C:\Games\vPOD on the pod
|
||||||
$zipPath = Join-Path $distDir 'vPOD.zip'
|
$zipPath = Join-Path $distDir 'vPOD.zip'
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<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\ + Console\RedPlanet\Apps.xml).
|
||||||
|
|
||||||
|
net40 (XP11): runs on XP SP3 through Windows 11, like the Launcher and the
|
||||||
|
Console — one flavor everywhere (Contract and SecureConfig included). The
|
||||||
|
differential tests' net48 host loads all of it fine (net40 and net48 are both
|
||||||
|
CLR4). The vendored Munga Net.dll is CLR2 pure-IL, so it loads anywhere.
|
||||||
|
WinForms comes via plain framework references: UseWindowsForms is not wired
|
||||||
|
up for net40, and all UI here is code-built (no designer).
|
||||||
|
-->
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net40</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<AssemblyName>vPOD</AssemblyName>
|
||||||
|
<RootNamespace>VPod</RootNamespace>
|
||||||
|
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||||
|
<!-- Versioned with the suite since v4.11.4.3 (was its own 1.0.0 line). -->
|
||||||
|
<AssemblyVersion>4.11.4.3</AssemblyVersion>
|
||||||
|
<Version>4.11.4.3</Version>
|
||||||
|
<Product>vPOD</Product>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- .NET Framework reference assemblies so this builds without a full
|
||||||
|
targeting pack installed (resolves per-TFM, covers net40 and net48) -->
|
||||||
|
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||||
|
<Reference Include="System.Windows.Forms" />
|
||||||
|
<Reference Include="System.Drawing" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- JSON for LaunchApps.json persistence + RPC arg materialization: Newtonsoft,
|
||||||
|
matching the Contract (System.Text.Json has no net40 target). -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- The differential suite drives the real PodManagerConnection client against
|
||||||
|
vPOD's LauncherRpcServer in-process (VPodLauncherServerTests). -->
|
||||||
|
<InternalsVisibleTo Include="TeslaConsole.DiffTests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- InstallProduct zips are extracted with the Launcher's own MiniZip on BOTH
|
||||||
|
legs (ZipFile/ZipArchive are net45+, absent on net40): identical extraction
|
||||||
|
behavior to the real pod service, and the differential suite's install
|
||||||
|
round-trip exercises MiniZip against real ZipArchive-built archives. -->
|
||||||
|
<Compile Include="..\Launcher\MiniZip.cs" Link="MiniZip.cs" />
|
||||||
|
<!-- The real pod's master-volume chain (nircmd -> CoreAudio -> winmm), for
|
||||||
|
the "Actually set system volume" mode. Same linked-source sharing. -->
|
||||||
|
<Compile Include="..\Launcher\VolumeControl.cs" Link="VolumeControl.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- The Munga wire types (messages, header, enums), vendored under the
|
||||||
|
console's lib\. Copied next to vPOD.exe so the deployable package is
|
||||||
|
self-contained. -->
|
||||||
|
<Reference Include="Munga Net">
|
||||||
|
<HintPath>..\Console\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>
|
||||||