18 Commits
Author SHA1 Message Date
CydandClaude Fable 5 85595b8c52 Console: fix volume menu check-mark off-by-one (original 4.11.3 bug)
mnuVolume_DropDownOpening checked the item where i+1 == num/10, but the
items are index=level (rVolumeItems[0]="mute", [8]="80"), so the mark
sat one step below the reported volume and mute (0) never got a mark.
Set path was always correct — display only. Now checks i == num/10.

Faithful reproduction of a bug in the original decompiled console;
fixed here. Verified live: console reports 80 -> "80" checked, mute ->
"mute" checked, against vPOD. 106/106 diff tests unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:06:37 -05:00
CydandClaude Fable 5 106fa610c0 vPOD: real system volume + pre-uninstall.bat, behind the existing opt-ins
Two launcher behaviors vPOD only simulated are now available for real,
matching the pod exactly:

- "Actually set system volume": set_VolumeLevel drives this machine's
  master volume through the launcher's own chain — nircmd.exe in the
  games root, else Core Audio (Vista+), else winmm. The chain moved out
  of TeslaLauncher.cs into Launcher/VolumeControl.cs and is compiled
  into both apps as linked source (the MiniZip pattern); launcher
  behavior is unchanged. Off by default: the value is stored/echoed
  only, as before.

- pre-uninstall.bat now runs before the product directory is deleted on
  UninstallApp (working dir, hidden window, 120 s wait, exit code
  logged — mirrors CleanupProductDirectory). Gated behind the renamed
  "Run package install/uninstall scripts" checkbox (was "Run
  postinstall.bat after install"; RunPostInstall -> RunPackageScripts),
  closing the asymmetry where install scripts had an opt-in but
  uninstall scripts silently never ran.

Verified: 106/106 diff tests; live master-volume set/restore through
vPOD's build of VolumeControl; functional probe of UninstallApp against
an isolated games root with the flag off (script skipped, dir removed)
and on (script ran, then dir removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:26:38 -05:00
CydandClaude Fable 5 8ba428b6a4 README: catch up with the XP11 net40 suite
All targets are net40 now (one binary set for XP SP3 through Win11), the
launcher is a single userland app rather than Service+Agent, the wire is
Newtonsoft JSON, the launcher package bundles the pod redists (incl. the
.NET 4.0 installer), and releases live on Gitea (latest v4.11.4.3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:07:56 -05:00
CydandClaude Fable 5 91640dcbf2 XP11: whole suite on net40 — Console + vPOD run on XP SP3 through Win11
The Launcher's XP11 port (8730b9b) now extends to everything: one net40
flavor across Console, vPOD, Contract, and SecureConfig (Newtonsoft.Json
everywhere; the net48/System.Text.Json legs and their #if splits are gone
since nothing consumed them).

Console (net40, single TFM like the Launcher):
- The ~31 BinaryFormatter bitmap blobs in the .resx files became raw
  embedded files under assets/icons/ (extracted byte-faithfully via a
  serialization surrogate — the animated square_throbber.gif survives),
  loaded by Properties.Resources.EmbeddedBitmap/EmbeddedIcon. Reason:
  System.Resources.Extensions' DeserializingResourceReader is net461+
  and cannot load on net40. Strings stay in the .resx.
- IReadOnlyList -> IList in AppRegistry (net45+ interface).

vPOD (net40, single TFM):
- Zip extraction now shares the Launcher's MiniZip.cs (linked source), so
  the diff-test install round-trip exercises it against ZipArchive zips.
- RPC args as JTokens; LaunchApps.json persistence via Newtonsoft;
  Thread.VolatileRead instead of Volatile.Read.

Contract/SecureConfig: net40-only; Client/** (PodManagerConnection) now
ships in the one build. The Launcher package gains
TeslaSecureConfiguration.dll as a dependency of the client half.

Tests: the net48 xunit host loads the net40 assemblies (both CLR4), so
the suite exercises exactly what ships — 106/106 green. Also verified
live: net40 console provisioned, managed, and ran a full RP mission
against net40 vPOD (beacon/passphrase/RSA, 53290 RPC, egg load,
Run/Stop Mission).

Version: 4.11.4.3 across Launcher, Console, and vPOD (vPOD joins the
suite version line; was 1.0.0). Ship the dotNetFx40 redistributable in
Launcher/assets for XP-era pods.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:01:34 -05:00
CydandClaude Fable 5 eefb8054e0 Catalog: launch-key convention, TeslaRel410 product, drop vPOD entry
- Adopt the launch-key convention (documented in the Apps.xml header):
  fresh Guid = product id, first Launch key reuses it, each extra entry
  increments the last hex digit (wrapping F->0). Never a -1 suffix:
  keys parse as System.Guid and silently collapse to Guid.Empty.
- Rewrite BT411 LC/MR + RIOJoy keys to the convention. RP4.11 LC/MR stay
  pinned to the original console's hardcoded Guids (SiteManagement
  constants + diff tests).
- Remove vPOD from the shipped catalog: dev tool, never a console-deployed
  product (README documents the ad-hoc Add Product path instead).
- Add TeslaRel410 (DOSBox-X preservation pods): six entries, BT/RP 4.10 x
  GameClient/LC/MR, all C:\Games\TeslaPod410\pod-launch.exe with mode
  bt/rp. LC/MR boot identically (role assigned via egg hostType) and no
  {res} token (output size fixed per rig at postinstall).
- CatalogTests: now 5 products / 14 entries; full diff suite 106/106.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 17:21:00 -05:00
CydandClaude Fable 5 8730b9b966 XP11: single net40 binary runs on XP SP3 through Windows 11
Merge TeslaLauncherService (Session 0) + TeslaLauncherAgent (tray) into one
userland TeslaLauncher.exe. The split existed only to work around Vista+
Session 0 isolation; running everything in the auto-logged-in admin session
needs no service, no named pipe, and no flat<->wire conversion layer. The
original Elsewhen software was likewise a single binary.

net40 is the newest framework XP SP3 can install, and net40 assemblies load
in-place on the 4.8 runtime in Win10/11 -- one exe covers both.

- Contract: multi-target net48;net40. The net40 leg of PodRpcProtocol uses
  Newtonsoft.Json (STJ has no net40 target); JSON is shape-identical on the
  wire, and the request reader keeps date strings raw so Ping echoes
  byte-identically. Console keeps the untouched net48/STJ leg.
- MiniZip.cs: central-directory ZIP extractor (stored/deflate/ZIP64) since
  net40 has no ZipFile; zip-slip guarded.
- SecureConfig: RSACryptoServiceProvider instead of RSA.Create (net46+),
  no leaveOpen BinaryWriter/Reader, struct instead of ValueTuple, and no
  SetCompatibleTextRenderingDefault on the passcode thread (the merged app
  already has a form). netsh "interface ip" syntax was already XP-correct.
- Volume: nircmd -> CoreAudio (Vista+) -> winmm waveOutSetVolume (XP).
- Paths: CommonApplicationData resolved per-OS (XP has no C:\ProgramData);
  launcher log moved next to the key/config in the data dir.
- install.bat: dual-OS (cacls/icacls, netsh firewall/advfirewall, dism and
  UAC/notification steps skipped on XP, .NET 4.0 redist check, XP DHCP reset
  via netsh); no service registration -- HKLM Run key + auto-login on both;
  RegisterApplicationRestart supplies crash-restart on Vista+.
- Bench switches: /skipconfig, /port:NNNN.

Verified: Console + diff suite (103 tests) still green on the net48 leg;
E2E smoke test drove the net40 launcher over real TCP with the Console's own
PodManagerConnection (STJ client vs Newtonsoft server) -- Ping echo, volume,
app registry, launch/kill/watch, InstallProduct zip transfer + extract, and
uninstall orphan cleanup: 17/17 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:59:06 -05:00
CydandClaude Fable 5 1d95a800fd Add verify skill: drive the console against vPOD
A project verify skill (.claude/skills/verify) capturing the recipe used
this session: build the console + vPOD, launch both, drive the console's
WinForms UI via UI Automation, and observe real behavior -- pod connection
endpoint via netstat, egg contents in vPOD, the full Load/Run/Stop mission
lifecycle -- rather than relying on tests alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:30:28 -05:00
CydandClaude Fable 5 f07df88acb 410console: BattleTech egress-hold addendum -- issue closed (~3s hold)
Decoded/disassembled finding from the TeslaRel410 side, verified against
a live DOSBox BTL4OPT pod driven by this console: the post-mission egress
window is a compiled ~3 s (stage-1 timer), not ~30 s, and byte-identical
across all four BTL4OPT builds. Marked won't-fix -- the floor egress lamps
no longer exist on any surviving cockpit. Console takeaway unchanged: send
StopMission promptly at timer end; the pod handles its own (short) close,
no egress delay to implement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:30:28 -05:00
CydandClaude Fable 5 13f8e0456b vPOD: real-launch auto-restart watchdog + optional postinstall.bat
Two additions to the virtual launcher's real-process mode:

- Auto-restart watchdog. Replaces the poll-on-query PruneExitedProcesses
  with a per-process watcher thread (StartWatcher): when a real-launched
  app exits on its own -- not via a Kill*/Uninstall, which untrack it
  first -- it is dropped from the running list and, if its LaunchData has
  AutoRestart and the "Auto-restart after the app exits (watchdog)"
  toggle is on, relaunched after the Agent's 2 s delay. A watchdog
  generation counter cancels pending restarts when the pod goes dark
  (power off / reboot / reprovision / WipeApps); the console's KillAllApps
  leaves them pending, matching the real Agent's race.

- postinstall.bat toggle. A "Run postinstall.bat after install" checkbox
  (above "Actually launch apps", off by default) makes an install execute
  a packaged postinstall.bat via cmd /c (waited up to 5 min) before
  deleting it, like the real service. Off, it is logged and removed unrun
  as before -- it runs package script code on the host.

Both are opt-in from the vPOD window. Verified against the real
LauncherRpcServer over a loopback socket: the watchdog test relaunches an
exited ping.exe with a new PID and stops once toggled off; a crafted
package's postinstall.bat runs (and is removed) only when enabled. Full
differential suite 103/103.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:30:28 -05:00
CydandClaude Fable 5 80ee1d26ea Console: move DOSBox IP shift from game pages into game defaults
The per-page "DOSBox IPs (+100)" checkbox becomes a single machine-level
setting per game, edited in Settings > Change <game> Defaults (a "DOSBox
Build" row below Autotranslocate Delay) and persisted in the game's
defaults file (RPDefaults.rpd / BTDefaults.btd) as a top-level
DosBoxAddressShift element.

The game pages no longer carry the checkbox; each RPGame/BTGame reads the
stored default once at construction into a readonly field and applies it
when enabling a pod (MungaGame.DosBoxAddressShift) and when building the
mission egg (MissionAddress). Since it is read at construction, an
already-open game page keeps its shift until reopened -- consistent with
how every other game default behaves.

Verified against vPOD: enabling the DOSBox default drives a freshly
opened game page's pod connection to 127.0.0.101 and the egg to
pilot=127.0.0.101 with no per-page control; unchecking round-trips to
False in the defaults file. All 103 diff tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:33:44 -05:00
CydandClaude Fable 5 a5f25c8bb8 Console: move the DOSBox IPs checkbox beside Print Last Mission
Same row instead of a fourth row below it: the mission-properties table
gains a ninth column holding the checkbox at (7,2) and the issues label
moves to column 8, so the group box returns to its original height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:14:39 -05:00
CydandClaude Fable 5 6a77fd0991 Console: DOSBox IPs (+100) checkbox on all four game pages
The DOSBox-X preservation pods run the original DOS builds of BattleTech
4.10 / Red Planet 4.10 inside an emulator whose bridged NIC is enumerated
100 above the host pod's last octet. A new checkbox on each game page
(RP Death Race / Martian Football, BT Free For All / No Return) shifts
every siteconfig address the page uses by +100 so the same pages control
either version of the games:

- Munga game connections (port 1501) target host+100; toggling the box
  drops and reconnects any requested pods at the new address.
- Mission egg player and camera entries carry the shifted address.
- The checkbox locks while a mission is loaded/running, like the other
  mission properties.

Launcher / site-management traffic is untouched -- those services still
run on the pod host itself. Pages share a pod's MungaGame, so the most
recent page to toggle or enable wins if two pages fight over one pod.

Verified end-to-end against vPOD: netstat shows the game connection move
127.0.0.1 -> 127.0.0.101 -> 127.0.0.1 as the box is toggled, and the egg
received by vPOD carries pilot=127.0.0.101. All 103 diff tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:59:58 -05:00
CydandClaude Fable 5 d30ce8bdbe vPOD: real C:\Games installs, optional real app launching, power-group swap
- InstallProduct now extracts into the real C:\Games (the launcher's
  GAMES_DIR) so deployed products land where their catalog launch entries
  point; uninstall removes the real product folder. Tests pass an isolated
  games root through the VirtualLauncher ctor.
- New "Actually launch apps (real processes)" toggle (off by default =
  simulated PIDs): LaunchApp starts the entry's exe exactly like the Agent
  (same start info, same registered-but-not-installed error), Kill*/
  Uninstall/Wipe terminate the real processes (kill before folder delete),
  and self-exited apps are pruned from GetLaunchedApps/FullUpdate. Real
  processes die with the machine: power off, reboot, or closing vPOD. The
  Agent's autoRestart watchdog is deliberately not emulated.
- Pod Power and Mimicking Game groups swapped (game left, power right).
- Provisioning round-trip test now skips as inconclusive when a running
  TeslaConsole holds UDP 53291 instead of failing the suite; two new tests
  cover real launch/kill (ping.exe) and the missing-exe error path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:46:55 -05:00
CydandClaude Fable 5 cb7c655530 Promote vPOD to a top-level project (Console/vPOD -> vPOD/)
vPOD has outgrown its home inside the console's folder: it now emulates both
halves of a pod (Munga game client + TeslaLauncher service / provisioning),
so it lives at the repo root beside Console/, Launcher/, Contract/ and
SecureConfig/, like the peer it has become.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:52:59 -05:00
CydandClaude Fable 5 60893172c3 vPOD: Reprovision also wipes the installed-apps store
The UI's Reprovision button only dropped the session key; the console-driven
ClearStore was the only path that cleared LaunchApps.json. Both now share
VirtualLauncher.WipeApps, so Reprovision = fresh pod (key + app registry
gone, beacon mode; extracted Games\ files stay, as on a real pod).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:47:48 -05:00
CydandClaude Fable 5 e87a8a22f1 vPOD: pod power vs game split; install bar finishes; command column
- Pod Power (new group) is the whole machine: Power On boots the launcher /
  site-management side and auto-starts the game (a real pod's boot-time
  autoRestart launch); Power Off darkens everything. Start Game / Stop Game
  control just the emulated game exe, so "machine up, game not running" is
  now representable — Manage Site keeps seeing a healthy pod while the
  console's game connection is down. Watchdog stays game-level and no-ops if
  the pod is powered off during its window; console Shutdown/Restart stays a
  pod-level power cycle.
- The install progress bar snaps to 100% when the transfer completes (the
  wire still reports 99 — the console retries on anything else).
- Installed-apps list shows the full command line (exe + arguments).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:40:27 -05:00
CydandClaude Fable 5 e0a3c72370 Console: survive a launcher-dropped connection during product installs
The launcher drops RPC sessions idle >30s (easily hit while the operator sits
in the install dialogs), but a dropped socket still reports Connected until an
I/O fails. InstallProductWorker then skipped its reopen, streamed the archive
on the fresh out-of-band connection, and died on the first progress poll —
and the install-completed handler registered launch entries without checking
e.Error, so AddApp's disconnected-guard exception crashed the whole console.

- PodInfo.EnsureConnectionAlive: probe an "open" connection with a Ping and
  reconnect when it is dead; used by the install and uninstall workers.
- SiteManagement.PodInfo_InstallProductCompleted: register launch entries only
  on success, and surface registration failures as the row's Install Failed
  state instead of an unhandled exception.
- Regression test pins the premise + recovery against vPOD's launcher server
  (stale socket reports open, Ping exposes it, reconnect restores service).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:40:14 -05:00
CydandClaude Fable 5 1daaf4af78 vPOD: virtual launcher — test Site Management / product deploys without a cockpit
vPOD now also impersonates the pod's TeslaLauncher service, so the console's
Manage Site works against it unmodified:

- LauncherRpcServer: ILauncherService over OFB + framed JSON on TCP 53290,
  mirroring TeslaLauncherService (concurrent sessions, out-of-band install
  zip on a second connection, the 99%-not-100 completion convention).
  Packages extract to %LocalAppData%\vPOD\Games; postinstall.bat is logged
  but never executed.
- PodProvisioning: pod side of SecureConfig (RQST beacon, RPLY decrypt, RSA
  session-key exchange), display-only — never touches the NIC/registry. The
  console's Configure flow mints the key exactly as for a real pod; console
  Reconfigure (ClearStore) drops the key and re-enters beacon mode.
- VirtualLauncher: installed-app registry (persisted), simulated launch PIDs,
  volume, install progress; console Shutdown/Restart power-cycles the pod.
- Form gets a Launcher/Site Management column (passcode display, RPC status,
  install progress, app list, Reprovision); Power Off darkens the launcher
  side too; new -nomanage flag disables it.

vPOD references the shared Tesla.Contract/Tesla.SecureConfig projects (server
side of the existing contract only, no new RPCs). Loopback tests drive the
real PodManagerConnection and PodConfigurationServer against the new code
(VPodLauncherServerTests, VPodProvisioningTests) — suite now 99 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:45:33 -05:00
99 changed files with 6055 additions and 3564 deletions
+58
View File
@@ -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.
+2 -2
View File
@@ -8,9 +8,9 @@ using System.Runtime.InteropServices;
[assembly: AssemblyCopyright("Copyright © 2009")]
[assembly: AssemblyConfiguration("")]
[assembly: Guid("581ca4b6-a91c-4d24-b9b5-207f3b5da379")]
[assembly: AssemblyFileVersion("4.11.4.1")]
[assembly: AssemblyFileVersion("4.11.4.3")]
[assembly: AssemblyTrademark("")]
[assembly: ComVisible(false)]
[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: AssemblyVersion("4.11.4.1")]
[assembly: AssemblyVersion("4.11.4.3")]
+19 -14
View File
@@ -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
shared across the suite:
- `TeslaConsoleLaunchLib``../Contract/Tesla.Contract.csproj`, a net48 project: the
single source of truth for the Console↔Launcher RPC contract (wire types, the
- `TeslaConsoleLaunchLib``../Contract/Tesla.Contract.csproj` (net40, like 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
Launcher Service. The assembly keeps the
Launcher. The assembly keeps 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).
- `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 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)
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`,
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
Launcher to an old runtime); dispatch is now by method-name string. The Launcher
Service/Agent target **net48**, same as the Console. Note the Console still uses
`BinaryFormatter` for *local* disk persistence (`Site` config, mission results) — that
is local file I/O on net48, not the network surface, and is intentionally left alone.
Launcher to an old runtime); dispatch is now by method-name string. The Launcher and
the Console both target **net40** (XP11: XP SP3 through Windows 11). Note the Console
still uses `BinaryFormatter` for *local* disk persistence (`Site` config, mission
results) — that is local file I/O, not the network surface, and is intentionally
left alone.
## Layout
@@ -72,8 +75,10 @@ and still build and run.
```
TeslaConsole/
*.cs, TeslaConsole.*/ decompiled source (by namespace)
*.resx, app.ico embedded resources + icon
TeslaConsole.csproj net48 project
*.resx, app.ico string resources + icon
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
images/ source art (pod art / maps / vehicles) — reference only
installer_banner.bmp installer artwork — reference only
@@ -84,14 +89,14 @@ TeslaConsole/
## Building
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.
```
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).
## Runtime content
+53 -23
View File
@@ -27,6 +27,15 @@
To add a product, append a <Product> with one <Launch>. exe must live under
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>
<Product id="7D241B1F-AB6D-4e08-9C20-12294E743D94"
@@ -66,13 +75,13 @@
args="-net 1501{res}"
autoRestart="true"
hostType="GameClient" />
<Launch key="D393711A-EDA0-48B2-82A0-89DF12B768AF"
<Launch key="F4C957FD-72F7-4C5F-8971-28095007E8D0"
displayName="BattleTech 4.11 LC"
exe="C:\Games\BT411\btl4.exe"
args="-net 1501{res} -lc"
autoRestart="true"
hostType="LiveCamera" />
<Launch key="2E9B8628-9C20-42FB-B070-E9C38D521082"
<Launch key="F4C957FD-72F7-4C5F-8971-28095007E8D1"
displayName="BattleTech 4.11 MR"
exe="C:\Games\BT411\btl4.exe"
args="-net 1501{res} -mr"
@@ -104,7 +113,7 @@
name="RIOJoy"
menuText="RIOJoy..."
hostTypeDialog="false">
<Launch key="FE83E212-45DF-48F9-848E-0B3CEE0692A3"
<Launch key="87FBC2E6-6359-4EF4-96A5-DF157823CFF6"
displayName="RIOJoy"
exe="C:\Games\RIOJoy\app\RioJoy.Tray.exe"
args=""
@@ -112,31 +121,52 @@
hostType="None" />
</Product>
<!-- vPOD - virtual pod / game-client stand-in for testing the game consoles
(Console\vPOD). Speaks Munga on 1501 like a real rpl4opt.exe/btl4.exe and
reports either ApplicationID (toggled live in its window). Deploy it to a
pod exactly like a real client; the package (Console\vPOD\dist\vPOD.zip,
built by pack.ps1) extracts to C:\Games\vPOD. -->
<Product id="0041C870-6E5E-4F3B-9782-F94F2F76F21D"
name="vPOD (Virtual Pod)"
menuText="vPOD (Virtual Pod)..."
<!-- TeslaRel410 - the DOSBox-X preservation pods (C:\VWE\TeslaRel410 repo).
The package (emulator\dist\TeslaPod410.zip, built by deploy\package.ps1)
extracts to C:\Games (postinstall.bat + TeslaPod410\); pod-launch.exe is
the single supervising entry point and the mode arg ("bt"/"rp") selects
the game. Camera ship and live mission review are NOT separate modes in
4.10 (the console assigns the role per IP via the egg hostType), so the
LC/MR entries boot identically and differ only by catalog role. No {res}
token: DOSBox output size is fixed per rig at install time. -->
<Product id="135019C7-2C2F-4C38-96BE-C7DB39994AB0"
name="TeslaRel410"
menuText="TeslaRel410..."
hostTypeDialog="true">
<Launch key="0041C870-6E5E-4F3B-9782-F94F2F76F21D"
displayName="vPOD"
exe="C:\Games\vPOD\vPOD.exe"
args="-net 1501{res}"
<Launch key="135019C7-2C2F-4C38-96BE-C7DB39994AB0"
displayName="BT4.10"
exe="C:\Games\TeslaPod410\pod-launch.exe"
args="bt"
autoRestart="true"
hostType="GameClient" />
<Launch key="EA0D4129-8950-428D-8399-E6A77D2D566A"
displayName="vPOD LC"
exe="C:\Games\vPOD\vPOD.exe"
args="-net 1501{res} -lc"
<Launch key="135019C7-2C2F-4C38-96BE-C7DB39994AB1"
displayName="BT4.10 LC"
exe="C:\Games\TeslaPod410\pod-launch.exe"
args="bt"
autoRestart="true"
hostType="LiveCamera" />
<Launch key="FC7CE34E-F4FE-4218-84CD-B13A6FA58E57"
displayName="vPOD MR"
exe="C:\Games\vPOD\vPOD.exe"
args="-net 1501{res} -mr"
<Launch key="135019C7-2C2F-4C38-96BE-C7DB39994AB2"
displayName="BT4.10 MR"
exe="C:\Games\TeslaPod410\pod-launch.exe"
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"
hostType="MissionReview" />
</Product>
@@ -283,6 +283,14 @@ internal static class BTDefaults
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()
{
sDefaultsFilePath = Path.Combine(Program.GetCommonAppDataDirectory(), "BTDefaults.btd");
@@ -315,6 +323,14 @@ internal static class BTDefaults
case "NoReturnDefaults":
sNoReturn.Parse(childNode);
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"));
sFreeForAll.Save(xmlDocument.DocumentElement);
sNoReturn.Save(xmlDocument.DocumentElement);
xmlDocument.DocumentElement.AppendChild(xmlDocument.CreateElement("DosBoxAddressShift")).InnerText = DosBoxAddressShift.ToString();
string directoryName = Path.GetDirectoryName(filePath);
if (!Directory.Exists(directoryName))
{
@@ -89,6 +89,10 @@ public class BTDefaultsDialog : Form
private NumericUpDown mNoReturnLaunchDelay;
private Label mDosBoxLabel;
private CheckBox mDosBoxShift;
private TableLayoutPanel mButtonsTable;
private Button mCancel;
@@ -124,6 +128,7 @@ public class BTDefaultsDialog : Form
BTScenario scenario = BTConfig.FreeForAll;
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);
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)
@@ -208,6 +213,7 @@ public class BTDefaultsDialog : Form
{
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);
BTDefaults.DosBoxAddressShift = mDosBoxShift.Checked;
BTDefaults.Save();
Hide();
}
@@ -274,6 +280,8 @@ public class BTDefaultsDialog : Form
this.mNoReturnExperience = new System.Windows.Forms.ComboBox();
this.mFfaLaunchDelay = 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.mCancel = 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.mFfaLaunchDelay, 1, 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.Location = new System.Drawing.Point(3, 3);
this.mMainTable.Name = "mMainTable";
this.mMainTable.RowCount = 13;
for (int i = 0; i < 12; i++)
this.mMainTable.RowCount = 14;
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(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.mFfaHeaderLabel.Anchor = System.Windows.Forms.AnchorStyles.None;
this.mFfaHeaderLabel.AutoSize = true;
@@ -352,6 +362,7 @@ public class BTDefaultsDialog : Form
ConfigureRowLabel(this.mBadgeLabel, "mBadgeLabel", "Badge:");
ConfigureRowLabel(this.mExperienceLabel, "mExperienceLabel", "Experience:");
ConfigureRowLabel(this.mLaunchDelayLabel, "mLaunchDelayLabel", "Autotranslocate Delay:");
ConfigureRowLabel(this.mDosBoxLabel, "mDosBoxLabel", "DOSBox Build:");
ConfigureOptionCombo(this.mFfaMap, "mFfaMap");
ConfigureOptionCombo(this.mNoReturnMap, "mNoReturnMap");
ConfigureOptionCombo(this.mFfaWeather, "mFfaWeather");
@@ -378,6 +389,12 @@ public class BTDefaultsDialog : Form
this.mFfaLaunchDelay.Name = "mFfaLaunchDelay";
this.mNoReturnLaunchDelay.Dock = System.Windows.Forms.DockStyle.Fill;
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.mMainTable.SetColumnSpan(this.mButtonsTable, 3);
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.AutoScaleDimensions = new System.Drawing.SizeF(6f, 13f);
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.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
base.MaximizeBox = false;
+10 -2
View File
@@ -182,6 +182,8 @@ internal class BTGame : DockContent
private Timer tmrAutoTranslocate;
private readonly bool mDosBoxAddressShift = BTDefaults.DosBoxAddressShift;
private string GameStatusText
{
set
@@ -394,6 +396,11 @@ internal class BTGame : DockContent
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)
{
if (mCurrentState == BTGameState.Run && mRequestedState == BTGameState.Run && !mProcessingMissionEndStateChangeReq)
@@ -747,14 +754,14 @@ internal class BTGame : DockContent
if (pod.HostType == HostType.GameMachineHostType)
{
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);
mMissionPlayers.Add(num++, bTPlayer);
PilotNameCache.PilotNames.Add(value);
}
else
{
bTMission.Cameras.Add(new BTCamera(pod.IPAddress.ToString(), pod.HostType));
bTMission.Cameras.Add(new BTCamera(MissionAddress(pod), pod.HostType));
}
}
mEggFileMessages = bTMission.ToEggFileMessages();
@@ -1077,6 +1084,7 @@ internal class BTGame : DockContent
{
if (flag.Value)
{
((Pod)dataGridViewRow.Tag).MungaGame.DosBoxAddressShift = mDosBoxAddressShift;
((Pod)dataGridViewRow.Tag).MungaGame.MakeRequested(this);
}
else
File diff suppressed because one or more lines are too long
+65 -208
View File
@@ -1,8 +1,10 @@
using System.CodeDom.Compiler;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Resources;
using System.Runtime.CompilerServices;
@@ -17,6 +19,15 @@ internal class Resources
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)]
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);
return (Bitmap)@object;
object image;
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);
return (Bitmap)@object;
object icon;
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
{
get
{
object @object = ResourceManager.GetObject("DeleteHS", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap Add => EmbeddedBitmap("Add.png");
internal static Bitmap Error
{
get
{
object @object = ResourceManager.GetObject("Error", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap Blank => EmbeddedBitmap("Blank.png");
internal static Bitmap DeleteHS => EmbeddedBitmap("DeleteHS.png");
internal static Bitmap Error => EmbeddedBitmap("Error.bmp");
internal static string ErrorReportsDir => ResourceManager.GetString("ErrorReportsDir", resourceCulture);
internal static Bitmap install
{
get
{
object @object = ResourceManager.GetObject("install", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap install => EmbeddedBitmap("install.png");
internal static Bitmap openHS
{
get
{
object @object = ResourceManager.GetObject("openHS", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap openHS => EmbeddedBitmap("openHS.png");
internal static Bitmap Play
{
get
{
object @object = ResourceManager.GetObject("Play", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap Play => EmbeddedBitmap("Play.png");
internal static Bitmap PodBad16
{
get
{
object @object = ResourceManager.GetObject("PodBad16", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodBad16 => EmbeddedBitmap("PodBad16.png");
internal static Bitmap PodBad32
{
get
{
object @object = ResourceManager.GetObject("PodBad32", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodBad32 => EmbeddedBitmap("PodBad32.png");
internal static string PodBadImageKey => ResourceManager.GetString("PodBadImageKey", resourceCulture);
internal static Bitmap PodGo16
{
get
{
object @object = ResourceManager.GetObject("PodGo16", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodGo16 => EmbeddedBitmap("PodGo16.png");
internal static Bitmap PodGo32
{
get
{
object @object = ResourceManager.GetObject("PodGo32", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodGo32 => EmbeddedBitmap("PodGo32.png");
internal static string PodGoImageKey => ResourceManager.GetString("PodGoImageKey", resourceCulture);
internal static Bitmap PodOffline16
{
get
{
object @object = ResourceManager.GetObject("PodOffline16", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodOffline16 => EmbeddedBitmap("PodOffline16.png");
internal static Bitmap PodOffline32
{
get
{
object @object = ResourceManager.GetObject("PodOffline32", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodOffline32 => EmbeddedBitmap("PodOffline32.png");
internal static string PodOfflineImageKey => ResourceManager.GetString("PodOfflineImageKey", resourceCulture);
internal static Bitmap PodOfflineQuestion32
{
get
{
object @object = ResourceManager.GetObject("PodOfflineQuestion32", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodOfflineQuestion32 => EmbeddedBitmap("PodOfflineQuestion32.png");
internal static Bitmap PodOnline16
{
get
{
object @object = ResourceManager.GetObject("PodOnline16", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodOnline16 => EmbeddedBitmap("PodOnline16.png");
internal static Bitmap PodOnline32
{
get
{
object @object = ResourceManager.GetObject("PodOnline32", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodOnline32 => EmbeddedBitmap("PodOnline32.png");
internal static string PodOnlineImageKey => ResourceManager.GetString("PodOnlineImageKey", resourceCulture);
internal static Bitmap PodQuestion16
{
get
{
object @object = ResourceManager.GetObject("PodQuestion16", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodQuestion16 => EmbeddedBitmap("PodQuestion16.png");
internal static Bitmap PodQuestion32
{
get
{
object @object = ResourceManager.GetObject("PodQuestion32", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodQuestion32 => EmbeddedBitmap("PodQuestion32.png");
internal static string PodQuestionImageKey => ResourceManager.GetString("PodQuestionImageKey", resourceCulture);
internal static Bitmap PodRun16
{
get
{
object @object = ResourceManager.GetObject("PodRun16", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodRun16 => EmbeddedBitmap("PodRun16.png");
internal static Bitmap PodRun32
{
get
{
object @object = ResourceManager.GetObject("PodRun32", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap PodRun32 => EmbeddedBitmap("PodRun32.png");
internal static string PodRunImageKey => ResourceManager.GetString("PodRunImageKey", resourceCulture);
internal static Bitmap Power
{
get
{
object @object = ResourceManager.GetObject("Power", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap Power => EmbeddedBitmap("Power.png");
internal static Bitmap RefreshDoc
{
get
{
object @object = ResourceManager.GetObject("RefreshDoc", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap RefreshDoc => EmbeddedBitmap("RefreshDoc.png");
internal static Bitmap saveHS
{
get
{
object @object = ResourceManager.GetObject("saveHS", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap saveHS => EmbeddedBitmap("saveHS.png");
internal static Bitmap square_throbber
{
get
{
object @object = ResourceManager.GetObject("square_throbber", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap square_throbber => EmbeddedBitmap("square_throbber.gif");
internal static Bitmap Stop
{
get
{
object @object = ResourceManager.GetObject("Stop", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap Stop => EmbeddedBitmap("Stop.png");
internal static Icon swirl
{
get
{
object @object = ResourceManager.GetObject("swirl", resourceCulture);
return (Icon)@object;
}
}
internal static Icon swirl => EmbeddedIcon("swirl.ico");
internal static Bitmap WebRefreshHH
{
get
{
object @object = ResourceManager.GetObject("WebRefreshHH", resourceCulture);
return (Bitmap)@object;
}
}
internal static Bitmap WebRefreshHH => EmbeddedBitmap("WebRefreshHH.png");
internal Resources()
{
@@ -352,6 +352,14 @@ internal static class RPDefaults
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()
{
sDefaultsFilePath = Path.Combine(Program.GetCommonAppDataDirectory(), "RPDefaults.rpd");
@@ -384,6 +392,14 @@ internal static class RPDefaults
case "FootballDefaults":
sFootball.Parse(childNode);
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"));
sDeathRace.Save(xmlDocument.DocumentElement);
sFootball.Save(xmlDocument.DocumentElement);
xmlDocument.DocumentElement.AppendChild(xmlDocument.CreateElement("DosBoxAddressShift")).InnerText = DosBoxAddressShift.ToString();
string directoryName = Path.GetDirectoryName(filePath);
if (!Directory.Exists(directoryName))
{
@@ -77,6 +77,10 @@ public class RPDefaultsDialog : Form
private NumericUpDown mFootballLaunchDelay;
private Label mDosBoxLabel;
private CheckBox mDosBoxShift;
private static RPDefaultsDialog mSingletonDialog;
protected override void Dispose(bool disposing)
@@ -123,6 +127,8 @@ public class RPDefaultsDialog : Form
this.label11 = new System.Windows.Forms.Label();
this.mDeathraceLaunchDelay = 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.mButtonsTable.SuspendLayout();
((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.label1, 1, 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.mDeathRaceVehicle, 1, 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.label11, 0, 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.Location = new System.Drawing.Point(3, 3);
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());
@@ -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(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.mFootballPosition.Anchor = System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
this.mFootballPosition.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
@@ -406,10 +415,26 @@ public class RPDefaultsDialog : Form
this.mFootballLaunchDelay.Name = "mFootballLaunchDelay";
this.mFootballLaunchDelay.Size = new System.Drawing.Size(173, 20);
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.AutoScaleDimensions = new System.Drawing.SizeF(6f, 13f);
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.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
base.MaximizeBox = false;
@@ -457,6 +482,7 @@ public class RPDefaultsDialog : Form
BuildGenericOption(mFootballPosition, RPConfig.Football.Positions, RPDefaults.Football.PositionKey);
BuildDelayControl(mDeathraceLaunchDelay, RPDefaults.DeathRace.LaunchDelay);
BuildDelayControl(mFootballLaunchDelay, RPDefaults.Football.LaunchDelay);
mDosBoxShift.Checked = RPDefaults.DosBoxAddressShift;
}
private static void BuildScoreCompressionOptions(ComboBox comboBox, bool defaultValue)
@@ -577,6 +603,7 @@ public class RPDefaultsDialog : Form
RPDefaults.Football.PositionKey = ExtractSelectedKey<string>(mFootballPosition);
RPDefaults.DeathRace.LaunchDelay = (int)mDeathraceLaunchDelay.Value;
RPDefaults.Football.LaunchDelay = (int)mFootballLaunchDelay.Value;
RPDefaults.DosBoxAddressShift = mDosBoxShift.Checked;
RPDefaults.Save();
Hide();
}
+11 -3
View File
@@ -176,6 +176,8 @@ public class RPGame : DockContent
private Timer tmrAutoTranslocate;
private readonly bool mDosBoxAddressShift = RPDefaults.DosBoxAddressShift;
private string GameStatusText
{
set
@@ -421,6 +423,11 @@ public class RPGame : DockContent
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)
{
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]);
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))
{
dictionary[rPFootballPlayer.TeamKey] = new List<RPFootballPlayer>();
@@ -800,7 +807,7 @@ public class RPGame : DockContent
}
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);
mMissionPlayers.Add(num++, rPRacePlayer);
}
@@ -808,7 +815,7 @@ public class RPGame : DockContent
}
else
{
rPMission.Cameras.Add(new RPCamera(pod.IPAddress.ToString(), pod.HostType));
rPMission.Cameras.Add(new RPCamera(MissionAddress(pod), pod.HostType));
}
}
if (mFootballMode)
@@ -1187,6 +1194,7 @@ public class RPGame : DockContent
{
if (flag.Value)
{
((Pod)dataGridViewRow.Tag).MungaGame.DosBoxAddressShift = mDosBoxAddressShift;
((Pod)dataGridViewRow.Tag).MungaGame.MakeRequested(this);
}
else
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18 -11
View File
@@ -3,8 +3,13 @@
<AssemblyName>TeslaConsole</AssemblyName>
<GenerateAssemblyInfo>False</GenerateAssemblyInfo>
<OutputType>WinExe</OutputType>
<UseWindowsForms>True</UseWindowsForms>
<TargetFramework>net48</TargetFramework>
<!-- net40 (XP11): the newest .NET Framework that installs on Windows XP SP3;
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>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<ApplicationIcon>app.ico</ApplicationIcon>
@@ -12,17 +17,21 @@
<!-- Decompiled from the original net20 TeslaConsole.exe; legacy WinForms 2.0 sources -->
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<!-- .resx files embed BinaryFormatter-serialized bitmaps (non-string resources) -->
<GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources>
<!-- CS0649: decompiled WinForms designer 'components' fields are never assigned -->
<NoWarn>$(NoWarn);CS0649</NoWarn>
</PropertyGroup>
<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" />
<!-- Required to read the pre-serialized binary resources above -->
<PackageReference Include="System.Resources.Extensions" Version="6.0.0" />
</ItemGroup>
<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>
@@ -45,13 +54,11 @@
<Compile Remove="tests\**" />
<None Remove="tests\**" />
<Content Remove="tests\**" />
<!-- vPOD is its own deployable exe under vPOD\; exclude it from the console build. -->
<Compile Remove="vPOD\**" />
<None Remove="vPOD\**" />
<Content Remove="vPOD\**" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Drawing" />
<Reference Include="System.Configuration.Install" />
<Reference Include="System.Xml" />
<Reference Include="System.ServiceProcess" />
+3 -1
View File
@@ -80,7 +80,9 @@ public static class AppRegistry
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 =>
Path.Combine(Path.GetDirectoryName(Application.ExecutablePath), "RedPlanet\\Apps.xml");
+28
View File
@@ -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);
}
}
+34 -1
View File
@@ -32,6 +32,8 @@ public class MungaGame
private MungaConnectionState mState;
private bool mDosBoxAddressShift;
private readonly List<object> mRequestors = new List<object>();
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
{
get
@@ -262,7 +295,7 @@ public class MungaGame
State = MungaConnectionState.Connecting;
ShutdownSocket();
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)
{
+35 -8
View File
@@ -617,6 +617,39 @@ public class PodInfo : Component
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)
{
Exception ex = null;
@@ -624,10 +657,7 @@ public class PodInfo : Component
{
for (int i = 0; i < 3; i++)
{
if (!mConnection.IsOpen)
{
mConnection.Open(new IPEndPoint(mPod.IPAddress, 53290), mPod.Key);
}
EnsureConnectionAlive();
Guid guid = mConnection.InstallProduct(filePath);
if (guid == Guid.Empty)
{
@@ -709,10 +739,7 @@ public class PodInfo : Component
Exception ex = null;
try
{
if (!mConnection.IsOpen)
{
mConnection.Open(new IPEndPoint(mPod.IPAddress, 53290), mPod.Key);
}
EnsureConnectionAlive();
mConnection.UninstallApp(launchKey);
mAllApps.Expire();
}
+16 -1
View File
@@ -1138,12 +1138,27 @@ public class SiteManagement : Form
{
Tuple<PodData, Guid> tuple = e.UserState as Tuple<PodData, Guid>;
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))
{
a.Conn.AddApp(appData);
}
}
catch (Exception ex)
{
Program.LogException(ex, "Registering launch entries failed.");
error = ex;
}
}
a.OperationInProgress = false;
a.Error = e.Error;
a.Error = error;
dgvPods.InvalidateCell(colOperationProgress.Index, mPods.IndexOf(a));
}
+7 -3
View File
@@ -214,7 +214,7 @@ internal class SitePanel : DockContent
this.tsbManageSite.Text = "Manage Site";
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.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.Name = "mPowerDropDown";
this.mPowerDropDown.Size = new System.Drawing.Size(69, 22);
@@ -302,7 +302,7 @@ internal class SitePanel : DockContent
this.lblPodName.Text = "Mistress Quickly";
this.picRefresh.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
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.Name = "picRefresh";
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++)
{
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;
}
}
+2 -2
View File
@@ -758,7 +758,7 @@ public class TeslaConsoleForm : Form
this.mRPPrintPreviewDialog.AutoScrollMinSize = new System.Drawing.Size(0, 0);
this.mRPPrintPreviewDialog.ClientSize = new System.Drawing.Size(400, 300);
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.Visible = false;
this.mRPPrintDialog.UseEXDialog = true;
@@ -773,7 +773,7 @@ public class TeslaConsoleForm : Form
base.ClientSize = new System.Drawing.Size(1168, 591);
base.Controls.Add(this.mMainDockPanel);
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.MainMenuStrip = this.mMenu;
base.Name = "TeslaConsoleForm";
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+5 -4
View File
@@ -2,9 +2,10 @@
:: =============================================================================
:: TeslaConsole - Build ^& Package
:: =============================================================================
:: Publishes the console (net48, framework-dependent) into TeslaConsole\App and
:: assembles the installable package (App\ + install.bat) next to it. net48 is
:: in-box on Windows 10/11, so the target control PC needs no runtime install.
:: Publishes the console (net40, framework-dependent - XP11: runs on XP SP3
:: through Windows 11) into TeslaConsole\App and assembles the installable
:: 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.
::
@@ -24,7 +25,7 @@ set ZIP=%ROOT%dist\TeslaConsole-pkg.zip
echo.
echo ============================================================
echo TeslaConsole - Build ^& Package (net48, framework-dependent)
echo TeslaConsole - Build ^& Package (net40, framework-dependent)
echo Output : %BUILD_DIR%
echo ============================================================
echo.
@@ -6,7 +6,8 @@ namespace TeslaConsole.DiffTests
/// <summary>
/// Locates the two assemblies under comparison:
/// * 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>
public static class AssemblyPaths
{
@@ -39,8 +40,8 @@ namespace TeslaConsole.DiffTests
private static string FindRecoveredExe()
{
string release = Path.Combine(RepoRoot, "bin", "Release", "net48", "TeslaConsole.exe");
string debug = Path.Combine(RepoRoot, "bin", "Debug", "net48", "TeslaConsole.exe");
string release = Path.Combine(RepoRoot, "bin", "Release", "net40", "TeslaConsole.exe");
string debug = Path.Combine(RepoRoot, "bin", "Debug", "net40", "TeslaConsole.exe");
// Test whichever build is freshest, so a stale config never silently wins.
string best = null;
@@ -47,7 +47,7 @@ namespace TeslaConsole.DiffTests
Assert.Contains("TeslaConsole", _fx.Original.AssemblyFullName);
Assert.Contains("TeslaConsole", _fx.Recovered.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 ----
@@ -25,15 +25,15 @@ namespace TeslaConsole.DiffTests
=> _fx.Recovered.Run("CatalogEntry", new[] { _catalog, launchKey, w, h });
[Fact]
public void Catalog_Has_Five_Products_And_Eleven_Entries()
=> Assert.Equal("products=5;entries=11",
public void Catalog_Has_Five_Products_And_Fourteen_Entries()
=> Assert.Equal("products=5;entries=14",
_fx.Recovered.Run("CatalogSummary", new[] { _catalog }));
[Fact]
public void RioJoy_Matches_Expected()
=> Assert.Equal(
@"RIOJoy|fe83e212-45df-48f9-848e-0b3cee0692a3|C:\Games\RIOJoy\app\RioJoy.Tray.exe||C:\Games\RIOJoy\app|True",
Entry("FE83E212-45DF-48F9-848E-0B3CEE0692A3"));
@"RIOJoy|87fbc2e6-6359-4ef4-96a5-df157823cff6|C:\Games\RIOJoy\app\RioJoy.Tray.exe||C:\Games\RIOJoy\app|True",
Entry("87FBC2E6-6359-4EF4-96A5-DF157823CFF6"));
// Each expected string is "DisplayName|LaunchKey|Exe|Args|WorkingDirectory|AutoRestart"
// and matches exactly what the old hardcoded PodInfo_InstallProductCompleted emitted.
@@ -92,39 +92,60 @@ namespace TeslaConsole.DiffTests
[Fact]
public void BattleTech_LiveCamera_Matches_Expected()
=> Assert.Equal(
@"BattleTech 4.11 LC|d393711a-eda0-48b2-82a0-89df12b768af|C:\Games\BT411\btl4.exe|-net 1501 -lc|C:\Games\BT411|True",
Entry("D393711A-EDA0-48B2-82A0-89DF12B768AF"));
@"BattleTech 4.11 LC|f4c957fd-72f7-4c5f-8971-28095007e8d0|C:\Games\BT411\btl4.exe|-net 1501 -lc|C:\Games\BT411|True",
Entry("F4C957FD-72F7-4C5F-8971-28095007E8D0"));
[Fact]
public void BattleTech_MissionReview_Matches_Expected()
=> Assert.Equal(
@"BattleTech 4.11 MR|2e9b8628-9c20-42fb-b070-e9c38d521082|C:\Games\BT411\btl4.exe|-net 1501 -mr|C:\Games\BT411|True",
Entry("2E9B8628-9C20-42FB-B070-E9C38D521082"));
@"BattleTech 4.11 MR|f4c957fd-72f7-4c5f-8971-28095007e8d1|C:\Games\BT411\btl4.exe|-net 1501 -mr|C:\Games\BT411|True",
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]
public void VPod_GameClient_Matches_Expected()
public void Rel410_BT_GameClient_Matches_Expected()
=> Assert.Equal(
@"vPOD|0041c870-6e5e-4f3b-9782-f94f2f76f21d|C:\Games\vPOD\vPOD.exe|-net 1501|C:\Games\vPOD|True",
Entry("0041C870-6E5E-4F3B-9782-F94F2F76F21D"));
@"BT4.10|135019c7-2c2f-4c38-96be-c7db39994ab0|C:\Games\TeslaPod410\pod-launch.exe|bt|C:\Games\TeslaPod410|True",
Entry("135019C7-2C2F-4C38-96BE-C7DB39994AB0"));
[Fact]
public void VPod_GameClient_With_Resolution_Matches_Expected()
public void Rel410_BT_Resolution_Choice_Has_No_Effect()
=> Assert.Equal(
@"vPOD|0041c870-6e5e-4f3b-9782-f94f2f76f21d|C:\Games\vPOD\vPOD.exe|-net 1501 -res 1024 768|C:\Games\vPOD|True",
Entry("0041C870-6E5E-4F3B-9782-F94F2F76F21D", "1024", "768"));
@"BT4.10|135019c7-2c2f-4c38-96be-c7db39994ab0|C:\Games\TeslaPod410\pod-launch.exe|bt|C:\Games\TeslaPod410|True",
Entry("135019C7-2C2F-4C38-96BE-C7DB39994AB0", "1024", "768"));
[Fact]
public void VPod_LiveCamera_Matches_Expected()
public void Rel410_BT_LiveCamera_Matches_Expected()
=> Assert.Equal(
@"vPOD LC|ea0d4129-8950-428d-8399-e6a77d2d566a|C:\Games\vPOD\vPOD.exe|-net 1501 -lc|C:\Games\vPOD|True",
Entry("EA0D4129-8950-428D-8399-E6A77D2D566A"));
@"BT4.10 LC|135019c7-2c2f-4c38-96be-c7db39994ab1|C:\Games\TeslaPod410\pod-launch.exe|bt|C:\Games\TeslaPod410|True",
Entry("135019C7-2C2F-4C38-96BE-C7DB39994AB1"));
[Fact]
public void VPod_MissionReview_Matches_Expected()
public void Rel410_BT_MissionReview_Matches_Expected()
=> Assert.Equal(
@"vPOD MR|fc7ce34e-f4fe-4218-84cd-b13a6fa58e57|C:\Games\vPOD\vPOD.exe|-net 1501 -mr|C:\Games\vPOD|True",
Entry("FC7CE34E-F4FE-4218-84CD-B13A6FA58E57"));
@"BT4.10 MR|135019c7-2c2f-4c38-96be-c7db39994ab2|C:\Games\TeslaPod410\pod-launch.exe|bt|C:\Games\TeslaPod410|True",
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.Collections.Generic;
using System.IO;
using System.Text.Json;
using Tesla.Net;
using Xunit;
@@ -29,8 +28,8 @@ namespace TeslaConsole.DiffTests
var req = PodRpc.ReadRequest(ms);
Assert.Equal("KillApp", req.Method);
Assert.Equal(2, req.Args.Count);
Assert.Equal(Key, req.Args[0].GetGuid());
Assert.Equal(4242, req.Args[1].GetInt32());
Assert.Equal(Key, req.Args[0].ToObject<Guid>(PodRpc.JsonOptions));
Assert.Equal(4242, req.Args[1].ToObject<int>(PodRpc.JsonOptions));
}
[Fact]
@@ -150,7 +149,7 @@ namespace TeslaConsole.DiffTests
ms.Position = 0;
var resp = PodRpc.ReadResponse(ms);
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
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
@@ -29,6 +29,8 @@
<ItemGroup>
<Reference Include="System.Drawing" />
<Reference Include="System.Xml" />
<!-- Zip building for the VPodLauncherServerTests InstallProduct round-trip -->
<Reference Include="System.IO.Compression" />
</ItemGroup>
<ItemGroup>
@@ -48,11 +50,16 @@
</ProjectReference>
<!-- The source-built wire contract (emits TeslaConsoleLaunchLib.dll). Referenced
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" />
<!-- The source-built secure-config (emits TeslaSecureConfiguration.dll), for
SecureConfigCompatTests' byte-identity checks vs the original vendored DLL. -->
<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>
</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();
}
}
}
}
}
-69
View File
@@ -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.
-514
View File
@@ -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();
}
}
-44
View File
@@ -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>
+7 -6
View File
@@ -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
// ILauncherService calls as framed JSON RpcRequest / RpcResponse pairs (see
@@ -8,7 +8,8 @@
// unchanged.
//
// 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;
@@ -16,8 +17,8 @@ using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using Newtonsoft.Json.Linq;
namespace Tesla.Net
{
@@ -129,14 +130,14 @@ namespace Tesla.Net
throw new Exception("Server function threw an exception: " + response.Error);
}
if (resultType == null
|| response.Result.ValueKind == JsonValueKind.Null
|| response.Result.ValueKind == JsonValueKind.Undefined)
|| response.Result == null
|| response.Result.Type == JTokenType.Null)
{
return resultType != null && resultType.IsValueType
? Activator.CreateInstance(resultType)
: null;
}
return response.Result.Deserialize(resultType, PodRpc.JsonOptions);
return response.Result.ToObject(resultType, PodRpc.JsonOptions);
}
}
catch (IOException innerException)
+30 -19
View File
@@ -14,26 +14,34 @@
// Dispatch is by method NAME (RpcRequest.Method) — the old serialized-MethodBase
// + SerializationBinder + MethodInfoProxy machinery is gone. Both ends share this
// 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.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
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 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>
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
}
@@ -45,12 +53,9 @@ namespace Tesla.Net
/// streamed out-of-band, not framed), guarding against hostile lengths.</summary>
public const int MaxFrameBytes = 16 * 1024 * 1024;
public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
// The Tesla.Net wire types (LaunchData, LaunchPair, ...) expose public
// FIELDS, which System.Text.Json ignores unless this is set.
IncludeFields = true,
};
// Newtonsoft serializes public fields of the wire types by default, and
// writes ISO-8601 dates / string Guids — no special options needed.
public static readonly JsonSerializer JsonOptions = JsonSerializer.CreateDefault();
// ── Framing ──────────────────────────────────────────────────────────
@@ -90,31 +95,37 @@ namespace Tesla.Net
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)
foreach (var a in args)
req.Args.Add(JsonSerializer.SerializeToElement(a, JsonOptions));
WriteFrame(stream, JsonSerializer.SerializeToUtf8Bytes(req, JsonOptions));
req.Args.Add(a == null ? JValue.CreateNull() : JToken.FromObject(a, JsonOptions));
WriteFrame(stream, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(req)));
}
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 ─────────────────────────────────────────────────────────
public static void WriteResponse(Stream stream, object result, string error)
{
object payload = error == null ? result : null;
var resp = new RpcResponse
{
// Always a valid element (JSON null when there is no result): a
// default(JsonElement) is ValueKind.Undefined and is not serializable.
Result = JsonSerializer.SerializeToElement(error == null ? result : null, JsonOptions),
Result = payload == null ? JValue.CreateNull() : JToken.FromObject(payload, JsonOptions),
Error = error,
};
WriteFrame(stream, JsonSerializer.SerializeToUtf8Bytes(resp, JsonOptions));
WriteFrame(stream, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(resp)));
}
public static RpcResponse ReadResponse(Stream stream)
=> JsonSerializer.Deserialize<RpcResponse>(ReadFrame(stream), JsonOptions);
=> JsonConvert.DeserializeObject<RpcResponse>(
Encoding.UTF8.GetString(ReadFrame(stream)), ReadSettings);
}
}
+18 -15
View File
@@ -1,9 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- net48 only: both consumers (Console and Launcher) target .NET Framework 4.8.
(Was multi-targeted net48;net8.0-windows while the Launcher was on net8.) -->
<TargetFramework>net48</TargetFramework>
<!-- net40 only (XP11): the oldest framework installable on Windows XP SP3,
and net40 assemblies run in-place on the 4.8 runtime — so the whole
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
version 1.0.0.0. BinaryFormatter embeds the assembly name in the wire
@@ -24,24 +28,23 @@
<NoWarn>$(NoWarn);SYSLIB0011</NoWarn>
</PropertyGroup>
<!-- net48 reference assemblies so the project builds without a full targeting pack,
plus System.Text.Json (built into the net8 shared framework, a package on net48). -->
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<!-- .NET Framework reference assemblies so the project builds without a full
targeting pack (resolves per-TFM, covers net40 and net48). -->
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
<!-- The TCP/OFB client (Client/**) is net48-only: it depends on the crypto-stream
handshake in TeslaSecureConfiguration.dll. The Launcher (net6) is the SERVER
end of this protocol and never references these classes, so they are excluded
from the net6.0-windows build (which carries only the wire data types). -->
<ItemGroup Condition="'$(TargetFramework)' != 'net48'">
<Compile Remove="Client\**\*.cs" />
<!-- JSON serializer: Newtonsoft.Json (still ships lib/net40; System.Text.Json
never had a net40 target). -->
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<ItemGroup>
<!-- 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" />
</ItemGroup>
-31
View File
@@ -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; }
}
}
+273
View File
@@ -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();
}
}
}
+81 -56
View File
@@ -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
TeslaLauncher has three components that work together:
One userland tray application — no Windows Service, no IPC.
### TeslaLauncherService (Windows Service, Session 0)
- Runs at boot before any user logs in
The original Elsewhen software was a single service (on Win2k/XP a service could still
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
- Forwards commands to the Agent via Named Pipe (`TeslaLauncherIPC`)
- Handles first-boot network configuration (SecureConfig)
- Handles game file transfers from the Console (InstallProduct)
- Handles first-boot network configuration (SecureConfig) and shows the
Request ID / Passphrase on screen + COM2 plasma
- 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)
- Runs in the logged-in user's desktop session
- Executes commands that require desktop access: launching/killing apps, volume control
- Manages `LaunchApps.xml` (installed games registry)
- On first boot, displays SecureConfig Request ID and Passphrase
Requires the kiosk account (`Firestorm`) to be in **Administrators** — SecureConfig's
`netsh`/hostname writes and product `postinstall.bat` driver installs need the admin
token (UAC is disabled by the installer on modern Windows, so no prompts).
### SecureConfig (first-boot protocol)
- 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
- 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
```
TeslaConsole ──TCP 53290 (OFB + framed JSON)──> TeslaLauncherService
Named Pipe (JSON)
v
TeslaLauncherAgent
TeslaConsole ──TCP 53290 (OFB + framed JSON)──> TeslaLauncher.exe (user session)
```
## Files
| File | Description |
|------|-------------|
| `TeslaLauncherService.cs` | Windows Service implementation |
| `TeslaLauncherService.csproj` | Service project (net48, generic host + Windows service) |
| `TeslaLauncherAgent.cs` | Userspace Agent implementation |
| `TeslaLauncherAgent.csproj` | Agent project (WinForms, net48) |
| `LaunchModels_Shared.cs` | Service↔Agent IPC types (Tesla.Launcher.Shared). Wire types (Tesla.Net) now come from `../Contract/Tesla.Contract.csproj` |
| `SecureConfig.cs` | First-boot secure configuration protocol |
| `build.bat` | Builds both components |
| `install.bat` | Installs on a cockpit PC (run as Administrator) |
| `TeslaLauncher.cs` | The whole launcher: tray, TCP listener, RPC dispatch, install, processes, volume |
| `TeslaLauncher.csproj` | net40 WinForms exe project |
| `MiniZip.cs` | Central-directory ZIP extractor (net40 has no `ZipFile`; stored + deflate + ZIP64) |
| `SecureConfig.cs` | First-boot secure configuration protocol + OFB duplex stream |
| `build.bat` | Builds + assembles the package |
| `install.bat` | Dual-OS installer (XP SP3 and Win10/11 code paths; run as Administrator) |
## Building
@@ -57,34 +63,47 @@ Requirements:
- Internet access for NuGet restore (first build only)
```
build.bat :: build both components + assemble the package
build.bat /service :: build Service only
build.bat /agent :: build Agent only
build.bat :: build + assemble the package
```
Output goes to `dist\TeslaLauncher\` (with `Service\` and `Agent\` subdirectories plus
`install.bat`) and `dist\TeslaLauncher-podpkg.zip`, mirroring `Console\dist\`. The projects
are published in place (framework-dependent net48) — they reference `../Contract`, so they
cannot be staged into a temp folder. Each folder holds the exe plus its dependency DLLs;
the target pod needs only .NET Framework 4.8 (built into Windows 10/11), no bundled runtime.
Output goes to `dist\TeslaLauncher\` (with `App\` plus `install.bat` and redist
folders) and `dist\TeslaLauncher-podpkg.zip`. The project is published in place
(framework-dependent net40) — it references `../Contract`, so it cannot be staged
into a temp folder. `App\` holds the exe plus `Newtonsoft.Json.dll` and
`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
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
The installer:
- Registers the Service (delayed auto-start)
- Configures the Agent for auto-login startup
- Installs OpenAL and DirectX runtimes
- Enables SMB1 file sharing
- Creates `C:\Games` with appropriate permissions
- Resets network adapters to DHCP for SecureConfig
The installer detects the OS and branches where the tooling differs:
| Step | XP SP3 | Windows 10/11 |
|------|--------|---------------|
| .NET | installs 4.0 redist from `dotnet40\` if missing | 4.8 built in — nothing |
| ACLs | `cacls` | `icacls` |
| 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
1. Cockpit boots with DHCP (unconfigured state)
2. Service runs SecureConfig: broadcasts beacon, displays codes on screen
1. Cockpit boots with DHCP (unconfigured state), auto-logs into the kiosk account
2. Launcher runs SecureConfig: broadcasts beacon, displays codes on screen + plasma
3. Console operator sees the pod's Request ID and enters the Passphrase
4. Console sends encrypted network configuration
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
`<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 |
|------|---------|
| `C:\ProgramData\TeslaLauncher\TeslaKeyStore.key` | Session key (32 bytes) |
| `C:\ProgramData\TeslaLauncher\LaunchApps.xml` | Installed games registry |
| `C:\ProgramData\TeslaLauncher\configuring.json` | Transient: SecureConfig codes for Agent display |
| `<CommonAppData>\TeslaLauncher\TeslaKeyStore.key` | Session key (32 bytes) |
| `<CommonAppData>\TeslaLauncher\LaunchApps.xml` | Installed games registry (same XML shape as the two-process Agent wrote) |
| `<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 |
## Wire Protocol
The Console talks to the Service with **length-prefixed System.Text.Json frames**
over the OFB-encrypted TCP stream (dispatch by method name) — see
`../Contract/PodRpcProtocol.cs`, shared by both ends. This replaced the original
`BinaryFormatter` + serialized-`MethodBase` scheme. The `Tesla.Net` wire types now
live in `../Contract/Tesla.Contract.csproj`, the single source of truth shared with
the Console.
The Console talks to the launcher with **length-prefixed JSON frames** over the
OFB-encrypted TCP stream (dispatch by method name) — see
`../Contract/PodRpcProtocol.cs`, shared by both ends. Since the whole suite went
net40 (XP11), both ends serialize with Newtonsoft.Json and the Contract is
net40-only (its former net48/System.Text.Json leg wrote shape-identical JSON and
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
that avoid the nested struct layout of the wire format.
Volume on XP falls back from CoreAudio (Vista+) to `nircmd.exe` / winmm
`waveOutSetVolume`.
+32 -20
View File
@@ -416,7 +416,10 @@ namespace TeslaSecureConfig
}
else
{
(_adapterIndex, _adapterId, _adapterName) = FindFirstEthernetAdapter();
var adapter = FindFirstEthernetAdapter();
_adapterIndex = adapter.Index;
_adapterId = adapter.Id;
_adapterName = adapter.Name;
}
_log = logger ?? (s => Debug.WriteLine(s));
}
@@ -845,23 +848,23 @@ namespace TeslaSecureConfig
Log("Secure connection to console negotiated.");
// ── 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.");
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.Flush();
}
Log("Receiving console key.");
byte[] sessionKey;
using (var br = new BinaryReader(ofb, Encoding.UTF8, leaveOpen: true))
{
var br = new BinaryReader(ofb, Encoding.UTF8);
int encLen = br.ReadInt32();
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).");
// Store session key — used for OFB on the management port (53290)
@@ -945,11 +948,12 @@ namespace TeslaSecureConfig
_plasma.WriteLine("Passphrase: {0}", _passphrase);
#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(() =>
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
_displayForm = new PasscodeDisplayForm(_requestId, _passphrase);
_displayForm.ShowDialog();
})
@@ -1129,10 +1133,18 @@ namespace TeslaSecureConfig
Log($"Warning: target IP {targetIp} not on any local subnet — keeping [{_adapterIndex}] {_adapterName}");
}
// Returns (IPv4 interface index, adapter GUID).
// Index is used with `netsh interface ipv4 ... interface=N` (avoids name-quoting issues).
// GUID is used for direct registry writes to persist static IP across reboots.
private static (int index, string id, string name) FindFirstEthernetAdapter()
// IPv4 interface index + adapter GUID + display name (a struct instead of a
// ValueTuple — net40 has no System.ValueTuple).
// Index avoids netsh name-quoting issues; GUID is used for direct registry
// 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())
{
@@ -1150,7 +1162,7 @@ namespace TeslaSecureConfig
{
int idx = nic.GetIPProperties().GetIPv4Properties().Index;
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) { }
}
@@ -1165,7 +1177,7 @@ namespace TeslaSecureConfig
string.Equals(nic.Description, displayName, StringComparison.OrdinalIgnoreCase))
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)
@@ -1181,7 +1193,7 @@ namespace TeslaSecureConfig
}
}
// 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 ---
File diff suppressed because it is too large Load Diff
+42
View File
@@ -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>
+1 -7
View File
@@ -2,9 +2,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherAgent", "TeslaLauncherAgent.csproj", "{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherService", "TeslaLauncherService.csproj", "{37D7E6C3-8DFD-CCB8-AFDF-18F2C0A48862}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncher", "TeslaLauncher.csproj", "{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}"
EndProject
Global
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}.Release|Any CPU.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
-989
View File
@@ -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.01.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);
}
}
-30
View File
@@ -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>
File diff suppressed because it is too large Load Diff
-45
View File
@@ -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>
+142
View File
@@ -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.01.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 { }
}
}
}
Binary file not shown.
+30 -56
View File
@@ -1,55 +1,52 @@
@echo off
:: =============================================================================
:: TeslaLauncher Unified Build / Package Script
:: TeslaLauncher - Unified Build / Package Script (XP11 single binary)
:: =============================================================================
:: Publishes TeslaLauncherService.exe and TeslaLauncherAgent.exe as
:: framework-dependent net48 builds and assembles the installable TeslaLauncher\
:: 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.
:: Publishes TeslaLauncher.exe as a framework-dependent net40 build and
:: assembles the installable TeslaLauncher\ package next to install.bat.
::
:: 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:
:: .NET SDK (6.0+) to drive the build https://dotnet.microsoft.com/download
:: Internet access for NuGet restore (first build only)
::
:: Usage:
:: build.bat - build both components + package
:: build.bat /service - build Service only
:: build.bat /agent - build Agent only
:: build.bat - build + package
:: build.bat /q - no pause on exit
::
:: Output (under dist\, parity with Console\dist\):
:: dist\TeslaLauncher\
:: install.bat
:: Service\TeslaLauncherService.exe
:: Agent\TeslaLauncherAgent.exe
:: dx9201006\ openal\ UltraVNC\ (redist, mirrored from assets\)
:: App\TeslaLauncher.exe (+ Newtonsoft.Json.dll, TeslaConsoleLaunchLib.dll)
:: dx9201006\ openal\ UltraVNC\ dotnet40\ (redist, mirrored from assets\)
:: dist\TeslaLauncher-podpkg.zip (the deployable package)
::
:: NOTE: the projects reference ..\Contract\Tesla.Contract.csproj, so they are
:: published IN PLACE (not staged into a temp folder) the project reference
:: NOTE: the project references ..\Contract\Tesla.Contract.csproj, so it is
:: published IN PLACE (not staged into a temp folder) - the project reference
:: must be able to resolve relative to this directory.
:: =============================================================================
setlocal enabledelayedexpansion
set ROOT=%~dp0
:: Package under dist\ (parity with Console\dist\).
set BUILD_DIR=%ROOT%dist\TeslaLauncher
set ZIP=%ROOT%dist\TeslaLauncher-podpkg.zip
:: -- Parse arguments ----------------------------------------------------------
set BUILD_SERVICE=1
set BUILD_AGENT=1
set QUIET=0
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
)
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 ============================================================
echo.
@@ -68,60 +65,38 @@ echo SDK : %SDK_VER%
echo.
:: -- 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%\Agent" rmdir /s /q "%BUILD_DIR%\Agent"
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%\UltraVNC" rmdir /s /q "%BUILD_DIR%\UltraVNC"
if exist "%BUILD_DIR%\dotnet40" rmdir /s /q "%BUILD_DIR%\dotnet40"
:: -- Build Service ------------------------------------------------------------
if %BUILD_SERVICE%==0 goto :skip_service
echo [1/2] Publishing TeslaLauncherService (Session 0 Windows Service)...
:: -- Build --------------------------------------------------------------------
echo [1/1] Publishing TeslaLauncher (net40 single binary)...
echo.
dotnet publish "%ROOT%TeslaLauncherService.csproj" ^
dotnet publish "%ROOT%TeslaLauncher.csproj" ^
-c Release ^
-o "%BUILD_DIR%\Service"
-o "%BUILD_DIR%\App"
if errorlevel 1 (
echo.
echo ERROR: Service build failed.
echo ERROR: build failed.
if "%QUIET%"=="0" pause
exit /b 1
)
echo.
echo Service : %BUILD_DIR%\Service\TeslaLauncherService.exe
echo Launcher : %BUILD_DIR%\App\TeslaLauncher.exe
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 --------------------------------------
:: 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%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\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 ----------------------------------------------------------
echo Zipping package...
@@ -133,14 +108,13 @@ echo ============================================================
echo Build complete
echo ============================================================
echo.
if %BUILD_SERVICE%==1 echo Service : %BUILD_DIR%\Service\TeslaLauncherService.exe
if %BUILD_AGENT%==1 echo Agent : %BUILD_DIR%\Agent\TeslaLauncherAgent.exe
echo Launcher : %BUILD_DIR%\App\TeslaLauncher.exe
echo.
echo Package : %BUILD_DIR%
echo Zip : %ZIP%
echo.
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.
if "%QUIET%"=="0" pause
+240 -237
View File
@@ -1,40 +1,53 @@
@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:
::
:: TeslaLauncher\
:: install.bat <- this file
:: Service\TeslaLauncherService.exe
:: Agent\TeslaLauncherAgent.exe
:: App\TeslaLauncher.exe (net40 single binary)
:: dx9201006\DXSETUP.exe (DirectX June 2010 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
echo ============================================================
echo Tesla Launcher v4.11.4.1 - Installation
echo Tesla Launcher v4.11.4.3 - Installation (single binary)
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 SERVICE_SRC=%ROOT%Service
set AGENT_SRC=%ROOT%Agent
set INSTALL_DIR=C:\Program Files\TeslaLauncher
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 APP_SRC=%ROOT%App
set INSTALL_DIR=%ProgramFiles%\TeslaLauncher
set LAUNCHER_EXE=%INSTALL_DIR%\TeslaLauncher.exe
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
:: 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_PASS=thor6
@@ -42,139 +55,161 @@ set AUTOLOGIN_PASS=thor6
:: the mw4files / c shares browse cleanly. Change once per site if needed.
set WORKGROUP=Tesla
:: ── Admin check ───────────────────────────────────────────────────────────────
:: -- Admin check -----------------------------------------------------------------
net session >nul 2>&1
if %errorlevel% neq 0 (
echo ERROR: This script must be run as Administrator.
pause & exit /b 1
)
:: ── Verify source files ───────────────────────────────────────────────────────
if not exist "%SERVICE_SRC%\TeslaLauncherService.exe" (
echo ERROR: TeslaLauncherService.exe not found in %SERVICE_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%
:: -- Verify source files ---------------------------------------------------------
if not exist "%APP_SRC%\TeslaLauncher.exe" (
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
)
:: ── Detect and clean up existing installation ─────────────────────────────────
set EXISTING=0
if exist "%SERVICE_EXE%" set EXISTING=1
if exist "%AGENT_EXE%" set EXISTING=1
if "%EXISTING%"=="1" (
echo Existing installation detected. Cleaning up...
echo.
:: Stop and kill the Agent process
taskkill /F /IM TeslaLauncherAgent.exe >nul 2>&1
echo Agent process stopped.
:: Stop and remove the service, wait for it to fully stop
sc stop "%SERVICE_NAME%" >nul 2>&1
sc stop "Tesla Application Launcher" >nul 2>&1
timeout /t 3 /nobreak >nul
sc delete "%SERVICE_NAME%" >nul 2>&1
sc delete "Tesla Application Launcher" >nul 2>&1
timeout /t 2 /nobreak >nul
echo Service stopped and removed.
:: Delete old executables, logs, and session key so SecureConfig re-runs
del /F /Q "%SERVICE_EXE%" >nul 2>&1
del /F /Q "%AGENT_EXE%" >nul 2>&1
del /F /Q "%INSTALL_DIR%\podconf.log" >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%\configuring.json" >nul 2>&1
echo Old executables, logs, session key, and app config deleted.
:: Reset all physical Ethernet adapters to DHCP so the SecureConfig
:: protocol triggers correctly on the next boot.
:: We must REMOVE the static IP address entry first, then enable DHCP.
:: 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...
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.
:: -- .NET Framework 4.0 check (XP ships no .NET; Win10/11 has 4.8 built in) ------
reg query "HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" /v Install >nul 2>&1
if not errorlevel 1 goto :dotnet_ok
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 (
:: 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
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
:: ── STEP 1: Create directories ────────────────────────────────────────────────
echo [1/8] Creating directories...
:: -- Detect and clean up existing installation -----------------------------------
echo Cleaning up any existing installation...
:: Stop launcher / legacy agent processes
taskkill /F /IM TeslaLauncher.exe >nul 2>&1
taskkill /F /IM TeslaLauncherAgent.exe >nul 2>&1
:: Remove the legacy two-process service registration (pre-XP11 installs)
sc stop "Tesla Application Launcher" >nul 2>&1
ping -n 4 127.0.0.1 >nul
sc delete "Tesla Application Launcher" >nul 2>&1
reg delete "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /v TeslaLauncherAgent /f >nul 2>&1
:: Delete old executables, logs, and session key so SecureConfig re-runs
if exist "%LAUNCHER_EXE%" (
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%\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%\LaunchApps.xml" >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.
:: Reset all Ethernet adapters to DHCP so the SecureConfig protocol triggers
:: on the next boot. The static address entry must be REMOVED, not just the
:: DHCP flag flipped, or IsMachineConfigured() still sees a static adapter.
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
)
echo Network adapters reset to DHCP.
echo.
:: -- STEP 1: Create directories ---------------------------------------------------
echo [1/7] Creating directories...
if not exist "%INSTALL_DIR%" mkdir "%INSTALL_DIR%"
if not exist "%DATA_DIR%" mkdir "%DATA_DIR%"
if not exist "C:\Games" mkdir "C:\Games"
:: Grant all users modify access to the data and games directories so
:: the Service and Agent can write files (LaunchApps.xml, game installs).
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
:: Grant Users modify access to the data and games directories so the launcher
:: 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 "C:\Games" /grant *S-1-5-32-545:(OI)(CI)M /T >nul 2>&1
)
echo %INSTALL_DIR%
echo %DATA_DIR% (Users: modify access)
echo C:\Games (Users: modify access)
:: ── STEP 2: Copy files ────────────────────────────────────────────────────────
echo [2/8] Copying files...
:: net48 framework-dependent build: each folder holds the exe + its dependency
:: DLLs + .config. Copy both folders into the install dir (shared DLLs overwrite
:: identically; the two .exe.config files coexist).
copy /Y "%SERVICE_SRC%\*.*" "%INSTALL_DIR%\" >nul
copy /Y "%AGENT_SRC%\*.*" "%INSTALL_DIR%\" >nul
:: -- STEP 2: Copy files ------------------------------------------------------------
echo [2/7] Copying files...
:: net40 framework-dependent build: the folder holds the exe + its dependency
:: DLLs (Newtonsoft.Json, TeslaConsoleLaunchLib) + .config.
copy /Y "%APP_SRC%\*.*" "%INSTALL_DIR%\" >nul
:: Install DirectX June 2010 runtime (required by games)
if exist "%ROOT%\dx9201006\DXSETUP.exe" (
:: Install DirectX June 2010 runtime (required by games; supports XP and Win10/11)
if exist "%ROOT%dx9201006\DXSETUP.exe" (
echo Installing DirectX runtime...
"%ROOT%\dx9201006\DXSETUP.exe" /silent
"%ROOT%dx9201006\DXSETUP.exe" /silent
echo DirectX installed.
)
:: Install OpenAL runtime (required by game audio)
if exist "%ROOT%\openal\oalinst.exe" (
if exist "%ROOT%openal\oalinst.exe" (
echo Installing OpenAL runtime...
"%ROOT%\openal\oalinst.exe" /s
"%ROOT%openal\oalinst.exe" /s
echo OpenAL installed.
)
:: Install UltraVNC server (remote diagnostics). The Inno installer lays down
:: the files and (via /loadinf) sets the install dir + VNC password; we then
:: register and start the service EXPLICITLY with winvnc.exe. Doing the service
:: step ourselves means a missing/incomplete "install service" task in the
:: answer file can't leave the pod with the files present but nothing listening.
set UVNC_SETUP=%ROOT%UltraVNC\UltraVNC_x64_Setup.exe
set UVNC_DIR=C:\Program Files\uvnc bvba\UltraVNC
if exist "%UVNC_SETUP%" (
:: Install UltraVNC server (remote diagnostics). x64 build for modern Windows;
:: XP uses the x86 build when the package carries one.
set UVNC_SETUP=
if "%ISXP%"=="1" (
if exist "%ROOT%UltraVNC\UltraVNC_x86_Setup.exe" set UVNC_SETUP=%ROOT%UltraVNC\UltraVNC_x86_Setup.exe
) else (
if exist "%ROOT%UltraVNC\UltraVNC_x64_Setup.exe" set UVNC_SETUP=%ROOT%UltraVNC\UltraVNC_x64_Setup.exe
)
if defined UVNC_SETUP (
echo Installing UltraVNC server...
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
:: reaches SMB1 shares (client) and hosts the mw4files / c shares below (server),
:: and Win10/11 ship both SMB1 sub-features off by default so enable all three
:: (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-Client /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).
:: SMB1 + DirectPlay: native on XP; must be re-enabled on Win10/11.
if "%ISXP%"=="0" (
echo Enabling SMB1 file sharing ^(client + server^)...
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-Server /All /NoRestart >nul 2>&1
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...
if not exist "C:\mw4files" mkdir "C:\mw4files"
:: Grant Everyone modify on the NTFS side so the share grant is effective.
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).
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
)
net share mw4files /delete >nul 2>&1
net share mw4files=C:\mw4files /grant:Everyone,FULL >nul 2>&1
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
echo "Share \\%COMPUTERNAME%\c -> C:\ (Everyone: Full)"
:: Join the site workgroup so every pod shares one browse list. Add-Computer
:: 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).
:: Join the site workgroup so every pod shares one browse list.
echo Joining workgroup "%WORKGROUP%"...
powershell -NoProfile -Command "try { Add-Computer -WorkgroupName '%WORKGROUP%' -Force -ErrorAction Stop } catch { }" >nul 2>&1
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
)
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.
:: ── STEP 3: Install Windows Service ──────────────────────────────────────────
echo [3/8] Installing Windows Service...
sc create "%SERVICE_NAME%" ^
binPath= "\"%SERVICE_EXE%\"" ^
DisplayName= "%SERVICE_DISPLAY%" ^
start= delayed-auto ^
obj= LocalSystem ^
type= own
if %errorlevel% neq 0 (
echo ERROR: Failed to register Windows Service.
pause & exit /b 1
)
sc description "%SERVICE_NAME%" ^
"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 [4/8] Disabling Windows Firewall (closed network)...
:: Disable firewall on all profiles — pods run on a closed network
netsh advfirewall set allprofiles state off
echo Windows Firewall disabled on all profiles.
:: ── STEP 5: Power settings (max performance, no sleep) ──────────────────────
echo.
echo [5/8] Configuring power settings...
:: Activate Ultimate Performance plan (hidden by default, GUID is well-known)
powercfg -duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 >nul 2>&1
for /f "tokens=4" %%g in ('powercfg -list ^| findstr /i "Ultimate"') do (
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 -hibernate-timeout-ac 0
powercfg -change -monitor-timeout-ac 0
powercfg -change -disk-timeout-ac 0
powercfg -h off >nul 2>&1
echo Sleep, hibernate, monitor timeout, and disk timeout disabled.
:: ── STEP 6: Disable notifications ───────────────────────────────────────────
echo.
echo [6/8] Disabling notifications...
:: These are machine-wide Group Policy keys (HKLM) so they apply to EVERY user,
:: including the auto-login kiosk account. Per-user HKCU tweaks would only touch
:: the admin running this installer, not the account the pod actually runs as.
:: Remove Action Center / Notification Center entirely
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Explorer" ^
/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" ^
/v NoToastApplicationNotification /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^
/v NoToastApplicationNotificationOnLockScreen /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^
/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" ^
/v DisableSoftLanding /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^
/v DisableWindowsSpotlightFeatures /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^
/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" ^
/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" ^
/v EnableLUA /t REG_DWORD /d 0 /f >nul
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^
/v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f >nul
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^
/v PromptOnSecureDesktop /t REG_DWORD /d 0 /f >nul
echo UAC disabled (takes effect after reboot).
:: ── STEP 8: Configure Agent auto-start and auto-login ────────────────────────
echo.
echo [8/8] Configuring Agent auto-start and auto-login...
:: -- STEP 3: Launcher auto-start (no Windows Service on XP11) ----------------------
echo [3/7] Configuring launcher auto-start...
:: The single binary runs in the auto-logged-in user session. Restart-after-crash
:: comes from RegisterApplicationRestart (Vista+) inside the launcher itself.
set AUTORUN_KEY=HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
reg add "%AUTORUN_KEY%" /v "TeslaLauncherAgent" /t REG_SZ ^
/d "\"%AGENT_EXE%\"" /f >nul
echo Added TeslaLauncherAgent to HKLM\...\Run.
reg add "%AUTORUN_KEY%" /v "TeslaLauncher" /t REG_SZ ^
/d "\"%LAUNCHER_EXE%\"" /f >nul
echo Added TeslaLauncher to HKLM\...\Run (no service - single userland binary).
:: 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.
:: -- STEP 4: Disable Windows Firewall (closed network) -----------------------------
echo.
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.
:: -- STEP 5: Power settings (max performance, no sleep) ----------------------------
echo.
echo [5/7] Configuring power settings...
if "%ISXP%"=="1" (
powercfg /setactive "Always On" >nul 2>&1
powercfg /change "Always On" /monitor-timeout-ac 0 >nul 2>&1
powercfg /change "Always On" /disk-timeout-ac 0 >nul 2>&1
powercfg /change "Always On" /standby-timeout-ac 0 >nul 2>&1
powercfg /hibernate off >nul 2>&1
echo "Always On" power scheme activated; sleep/hibernate disabled.
) else (
powercfg -duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 >nul 2>&1
for /f "tokens=4" %%g in ('powercfg -list ^| findstr /i "Ultimate"') do (
powercfg -setactive %%g
)
powercfg -change -standby-timeout-ac 0
powercfg -change -hibernate-timeout-ac 0
powercfg -change -monitor-timeout-ac 0
powercfg -change -disk-timeout-ac 0
powercfg -h off >nul 2>&1
echo Ultimate Performance plan activated; sleep/hibernate disabled.
)
:: -- STEP 6: Quiet the OS (notifications / UAC - modern Windows only) --------------
echo.
echo [6/7] Disabling notifications and UAC...
if "%ISXP%"=="1" (
echo XP has no Action Center or UAC - nothing to disable.
) else (
rem Machine-wide Group Policy keys (HKLM) so they apply to EVERY user,
rem including the auto-login kiosk account.
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Explorer" ^
/v DisableNotificationCenter /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^
/v NoToastApplicationNotification /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^
/v NoToastApplicationNotificationOnLockScreen /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^
/v NoCloudApplicationNotification /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^
/v DisableSoftLanding /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^
/v DisableWindowsSpotlightFeatures /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^
/v DisableWindowsConsumerFeatures /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Microsoft\Windows Defender Security Center\Notifications" ^
/v DisableNotifications /t REG_DWORD /d 1 /f >nul
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^
/v EnableLUA /t REG_DWORD /d 0 /f >nul
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^
/v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f >nul
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^
/v PromptOnSecureDesktop /t REG_DWORD /d 0 /f >nul
echo Notifications disabled; UAC disabled ^(takes effect after reboot^).
)
:: -- STEP 7: Auto-login --------------------------------------------------------------
echo.
echo [7/7] Configuring auto-login...
:: Winlogon reads these on every boot (same keys since NT). AutoAdminLogon=1
:: signs DefaultUserName in with DefaultPassword without a prompt.
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 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
:: Clear any logon-count cap that would disable auto-login after N boots.
reg delete "%WINLOGON%" /v AutoLogonCount /f >nul 2>&1
:: Some builds require this flag off for a plain-text auto-login password to
:: be honored (device-passwordless / Windows Hello preference).
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\PasswordLess\Device" ^
if "%ISXP%"=="0" (
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" ^
/v DevicePasswordLessBuildVersion /t REG_DWORD /d 0 /f >nul 2>&1
)
echo Auto-login configured for user "%AUTOLOGIN_USER%".
:: ── Done ──────────────────────────────────────────────────────────────────────
:: -- Done ----------------------------------------------------------------------------
echo.
echo ============================================================
echo Installation Complete!
@@ -332,9 +335,9 @@ echo Config dir : %DATA_DIR%
echo Firewall : disabled (closed network)
echo.
echo Rebooting in 10 seconds...
echo The Service and Agent will start automatically in the correct order.
echo On first boot with DHCP, SecureConfig runs automatically
echo watch plasma or on main screen for the Request ID and Passphrase.
echo The launcher starts automatically at login (single binary, no service).
echo On first boot with DHCP, SecureConfig runs automatically -
echo watch plasma or the main screen for the Request ID and Passphrase.
echo.
echo After reboot: use Console to install apps.
echo.
+35 -14
View File
@@ -4,13 +4,21 @@ The Tesla cockpit-pod software, in one repository:
| 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 |
| [`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 |
| [`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 |
| [`SecureConfig/`](SecureConfig/) | **Tesla.SecureConfig** — the first-boot pod provisioning protocol (UDP beacons, OFB crypto, RSA key exchange). Emits assembly `TeslaSecureConfiguration`. | .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 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.0 |
| [`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
`System.Text.Json` frames over an OFB-encrypted stream** ([`Contract/PodRpcProtocol.cs`](Contract/PodRpcProtocol.cs)),
Everything targets **net40** on purpose (the XP11 port, v4.11.4.3): it is the newest
.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
([`Contract/`](Contract/)) referenced by both sides — a single source of truth, no
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
framework-dependent net48 package into `Launcher/dist/` (~1.6 MB zipped — no runtime to
install, since .NET Framework 4.8 ships in Windows 10/11), and
[`Launcher/install.bat`](Launcher/install.bat) deploys it on a cockpit PC (registers the
Service, sets up the Agent for auto-login, hardens the box). The operator console packages
framework-dependent net40 package into `Launcher/dist/` — the launcher itself is tiny,
but the package bundles the pod redists (DirectX June 2010, OpenAL, UltraVNC, and
`dotNetFx40_Full_x86_x64.exe` for XP-era pods that don't have .NET 4.0 yet).
[`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/`,
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
@@ -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
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
on net8/x64, then settled on net48 to match the console and ship a tiny, runtime-free
package. The whole console↔pod path (provisioning, install, launch) is validated on real
pods.
on net8/x64, then on net48, then (the **XP11** port, v4.11.4.3) the whole suite settled on
**net40** so one set of binaries runs on the original Windows XP SP3 cockpit hardware and
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).
+6 -3
View File
@@ -1,9 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- net48 only: consumed by the net48 Console and the net48 client half of
Tesla.Contract. The pod side is the Launcher's own SecureConfig.cs. -->
<TargetFramework>net48</TargetFramework>
<!-- net40 only (XP11), like everything that consumes it: the client half of
Tesla.Contract, the Console, and vPOD. The protocol code is net20-era
(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
drop-in replacement for the original vendored binary. Unlike the wire
+9 -16
View File
@@ -11,24 +11,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{27C769F3
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaConsole.DiffTests", "Console\tests\TeslaConsole.DiffTests\TeslaConsole.DiffTests.csproj", "{467AA87A-FBD4-45D7-B8F8-9336C95884D2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherService", "Launcher\TeslaLauncherService.csproj", "{910D4404-B3A2-4217-B61C-D43E7F22CDAA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherAgent", "Launcher\TeslaLauncherAgent.csproj", "{916DCDA5-3379-4383-96F0-AC7B26FAF64E}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncher", "Launcher\TeslaLauncher.csproj", "{E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tesla.Contract", "Contract\Tesla.Contract.csproj", "{0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tesla.SecureConfig", "SecureConfig\Tesla.SecureConfig.csproj", "{070A6093-6C46-4A5B-A119-47ED195530E1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "vPOD", "Console\vPOD\vPOD.csproj", "{9EAC97A1-D71A-4AAB-9957-A79A1587D406}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "vPOD", "vPOD\vPOD.csproj", "{9EAC97A1-D71A-4AAB-9957-A79A1587D406}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{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
@@ -38,14 +33,10 @@ Global
{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.Build.0 = Release|Any CPU
{910D4404-B3A2-4217-B61C-D43E7F22CDAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{910D4404-B3A2-4217-B61C-D43E7F22CDAA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{910D4404-B3A2-4217-B61C-D43E7F22CDAA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{910D4404-B3A2-4217-B61C-D43E7F22CDAA}.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
{E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.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.Build.0 = Debug|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.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{27C769F3-F07F-456E-ACB7-F4A4A21AB6D1} = {91E92ADD-9F67-41E4-B43C-CCB7B2D95F15}
{467AA87A-FBD4-45D7-B8F8-9336C95884D2} = {27C769F3-F07F-456E-ACB7-F4A4A21AB6D1}
{9EAC97A1-D71A-4AAB-9957-A79A1587D406} = {91E92ADD-9F67-41E4-B43C-CCB7B2D95F15}
EndGlobalSection
EndGlobal
+428
View File
@@ -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
/// switchable live in the UI)
/// <c>-host &lt;id&gt;</c> the responding host id reported in state responses
///
/// vPOD-only (not a real game-client option):
/// <c>-nomanage</c> disable the virtual launcher / site-management side
/// (no provisioning beacons, no TCP 53290 listener)
/// </summary>
internal sealed class PodArguments
{
@@ -25,6 +29,8 @@ internal sealed class PodArguments
public int HostId { get; private set; } = 1;
public bool NoManage { get; private set; }
public static PodArguments Parse(string[] args)
{
PodArguments result = new PodArguments();
@@ -63,6 +69,9 @@ internal sealed class PodArguments
i++;
}
break;
case "-nomanage":
result.NoManage = true;
break;
}
}
return result;
+378
View File
@@ -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 &lt;RequestId&gt;" button.
/// 2. Operator enters the pod's network settings and the 5-char passphrase shown
/// in vPOD's window; the console broadcasts an AES-encrypted "RPLY" (network
/// config) to UDP 53292, key = PBKDF2(passphrase).
/// 3. The console TCP-connects to the pod's (entered) address on 53292; after the
/// OFB/CONF handshake on the passphrase key, the pod sends an RSA public key
/// and receives the RSA-encrypted 32-byte session key — the key that unlocks
/// the launcher RPC channel (TCP 53290) from then on.
///
/// Reuses the shared TeslaSecureConfiguration pieces where they are public
/// (UdpBeacon, BasicConfigResponse, NegotiateCryptoStreams); the passphrase KDF
/// salt and the "RQST"/"RPLY" tags are internal there and duplicated below.
/// </summary>
internal sealed class PodProvisioning
{
public const int ConsoleRequestPort = 53291; // console listens for RQST beacons
public const int PodReplyPort = 53292; // pod listens for RPLY + the TCP key exchange
// Mirrors of internals in SecureConfig/SecureConfig.cs (PodConfigurationServer):
// the PBKDF2 salt for the passphrase-derived AES key, and the pod-side
// passphrase/request-id alphabet + lengths (SetupPod validates passphrase == 5).
private static readonly byte[] sPassphraseSalt = new byte[32]
{
23, 171, 81, 217, 236, 209, 212, 116, 169, 9,
74, 52, 39, 251, 31, 242, 222, 196, 249, 241,
166, 216, 158, 218, 21, 17, 71, 101, 50, 231,
231, 239
};
private const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
private const int RequestIdLength = 3;
private const int PassphraseLength = 5;
private readonly byte[] mMacAddress;
private readonly object mLock = new object();
private UdpBeacon mBeacon;
private UdpClient mReplyListener;
private TcpListener mKeyExchangeListener;
private Thread mWorker;
private volatile bool mRunning;
// Bumped by every Start/Stop so a worker from a previous session can neither
// tear down nor complete a newer one (e.g. quick power-off/power-on cycles).
private volatile int mGeneration;
public event Action<string> Log;
public event Action<BasicConfigResponse> ConfigReceived; // display-only network config
public event Action<byte[]> Provisioned; // the 32-byte session key
public string RequestId { get; private set; }
public string Passphrase { get; private set; }
public bool IsRunning => mRunning;
/// <summary>The pod's stable fake MAC: locally-administered "VPOD" + host id, so
/// the console recognizes the same virtual pod across reprovisions (Site.FindPod).</summary>
public static byte[] MacForHost(int hostId)
{
return new byte[6] { 0x02, 0x56, 0x50, 0x4F, 0x44, (byte)hostId };
}
public PodProvisioning(byte[] macAddress)
{
mMacAddress = macAddress;
}
public void Start()
{
int generation;
lock (mLock)
{
if (mRunning)
{
return;
}
mRunning = true;
generation = ++mGeneration;
RequestId = GenerateRandomString(RequestIdLength);
Passphrase = GenerateRandomString(PassphraseLength);
byte[] payload = new byte[mMacAddress.Length + RequestIdLength];
mMacAddress.CopyTo(payload, 0);
Encoding.ASCII.GetBytes(RequestId).CopyTo(payload, mMacAddress.Length);
mBeacon = new UdpBeacon(Encoding.UTF8.GetBytes("RQST"), payload, 10000.0, ConsoleRequestPort, null);
mBeacon.Start();
mWorker = new Thread(() => ProvisionWorker(generation)) { IsBackground = true, Name = "vPOD-provision" };
mWorker.Start();
}
Log?.Invoke($"Provisioning: beaconing RQST (Request ID {RequestId}, passphrase {Passphrase}).");
}
public void Stop()
{
lock (mLock)
{
mGeneration++; // orphan any live worker
if (!mRunning)
{
return;
}
mRunning = false;
try { mBeacon?.Stop(); } catch { }
mBeacon = null;
try { mReplyListener?.Close(); } catch { }
mReplyListener = null;
try { mKeyExchangeListener?.Stop(); } catch { }
mKeyExchangeListener = null;
}
}
private bool IsCurrent(int generation)
{
return mRunning && generation == mGeneration;
}
private void ProvisionWorker(int generation)
{
try
{
// ---- Phase 1: wait for the console's RPLY (proves the operator typed
// our passphrase) and surface the assigned network config. ----
byte[] weakKey = DeriveKeyFromPassphrase(Passphrase);
BasicConfigResponse config = ReceiveReply(weakKey, generation);
if (config == null || !IsCurrent(generation))
{
return; // stopped
}
Log?.Invoke($"Provisioning: RPLY received — assigned IP {config.Address} / {config.Mask}" +
(string.IsNullOrEmpty(config.HostName) ? "" : $", host \"{config.HostName}\"") +
" (display only, not applied).");
ConfigReceived?.Invoke(config);
lock (mLock)
{
try { mBeacon?.Stop(); } catch { }
mBeacon = null;
}
// ---- Phase 2: accept the console's TCP key exchange on 53292. ----
TcpListener listener;
lock (mLock)
{
if (!IsCurrent(generation))
{
return;
}
try
{
listener = new TcpListener(IPAddress.Any, PodReplyPort);
listener.Start();
}
catch (Exception ex)
{
Log?.Invoke($"Provisioning: cannot listen on TCP {PodReplyPort}: {ex.Message}");
return;
}
mKeyExchangeListener = listener;
}
Log?.Invoke("Provisioning: waiting for the console's key exchange on TCP " + PodReplyPort + "...");
while (IsCurrent(generation))
{
TcpClient client = null;
try
{
client = listener.AcceptTcpClient();
byte[] sessionKey = ExchangeSessionKey(client, weakKey);
if (sessionKey == null)
{
Log?.Invoke("Provisioning: key-exchange handshake failed (wrong passphrase key?); still waiting.");
continue;
}
if (!IsCurrent(generation))
{
return;
}
Log?.Invoke("Provisioning: session key received — pod is provisioned.");
Provisioned?.Invoke(sessionKey);
return;
}
catch (Exception ex)
{
if (IsCurrent(generation))
{
Log?.Invoke("Provisioning: key exchange error: " + ex.Message);
}
else
{
return; // listener stopped
}
}
finally
{
try { client?.Close(); } catch { }
}
}
}
finally
{
StopGeneration(generation);
}
}
/// <summary>Tears the session down only if it is still the one this worker
/// belongs to — a newer Start() must not be disturbed by an old worker exiting.</summary>
private void StopGeneration(int generation)
{
lock (mLock)
{
if (generation != mGeneration)
{
return;
}
mRunning = false;
try { mBeacon?.Stop(); } catch { }
mBeacon = null;
try { mReplyListener?.Close(); } catch { }
mReplyListener = null;
try { mKeyExchangeListener?.Stop(); } catch { }
mKeyExchangeListener = null;
}
}
/// <summary>Listens on UDP 53292 for an "RPLY" datagram that decrypts and
/// verifies under our passphrase key. Cancellable mirror of the shared
/// UdpBeaconListener (which cannot be stopped once blocked in Receive):
/// packet = "RPLY"(4) + IV(16) + AES-CBC(config + SHA1(config)).</summary>
private BasicConfigResponse ReceiveReply(byte[] weakKey, int generation)
{
UdpClient udp;
lock (mLock)
{
if (!IsCurrent(generation))
{
return null;
}
try
{
udp = new UdpClient(PodReplyPort) { EnableBroadcast = true };
}
catch (Exception ex)
{
Log?.Invoke($"Provisioning: cannot listen on UDP {PodReplyPort}: {ex.Message}");
return null;
}
mReplyListener = udp;
}
byte[] header = Encoding.UTF8.GetBytes("RPLY");
using (Rijndael aes = Rijndael.Create())
using (SHA1 sha1 = SHA1.Create())
{
aes.Key = weakKey;
while (IsCurrent(generation))
{
byte[] packet;
try
{
IPEndPoint remote = new IPEndPoint(IPAddress.Any, 0);
packet = udp.Receive(ref remote);
}
catch
{
return null; // socket closed by Stop()
}
try
{
int ivLength = aes.IV.Length;
if (packet.Length < header.Length + ivLength)
{
continue;
}
bool tagOk = true;
for (int i = 0; i < header.Length; i++)
{
if (packet[i] != header[i]) { tagOk = false; break; }
}
if (!tagOk)
{
continue;
}
byte[] iv = new byte[ivLength];
Buffer.BlockCopy(packet, header.Length, iv, 0, ivLength);
byte[] plain;
using (ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, iv))
using (MemoryStream ms = new MemoryStream())
{
using (CryptoStream cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Write))
{
cs.Write(packet, header.Length + ivLength, packet.Length - header.Length - ivLength);
cs.FlushFinalBlock();
}
plain = ms.ToArray();
}
int messageLength = plain.Length - sha1.HashSize / 8;
if (messageLength <= 0)
{
continue;
}
byte[] hash = sha1.ComputeHash(plain, 0, messageLength);
bool hashOk = true;
for (int i = 0; i < hash.Length; i++)
{
if (plain[messageLength + i] != hash[i]) { hashOk = false; break; }
}
if (!hashOk)
{
continue; // wrong passphrase (or noise) — keep listening
}
byte[] message = new byte[messageLength];
Buffer.BlockCopy(plain, 0, message, 0, messageLength);
return new BasicConfigResponse(message);
}
catch
{
// undecryptable/malformed datagram — keep listening
}
}
}
return null;
}
/// <summary>The RSA leg, mirroring the real pod (PodConfigurationClient): after
/// the OFB/CONF handshake on the passphrase key, send our RSA public key and
/// decrypt the console's session key with it. Returns null if CONF fails.</summary>
private byte[] ExchangeSessionKey(TcpClient client, byte[] weakKey)
{
if (!PodConfigurationServer.NegotiateCryptoStreams(client.GetStream(), weakKey, out Stream outStream, out Stream inStream))
{
return null;
}
BinaryWriter writer = new BinaryWriter(outStream);
BinaryReader reader = new BinaryReader(inStream);
using (RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(2048))
{
writer.Write(rsa.ToXmlString(includePrivateParameters: false));
writer.Flush();
byte[] encryptedKey = reader.ReadBytes(reader.ReadInt32());
return rsa.Decrypt(encryptedKey, fOAEP: false);
}
}
internal static byte[] DeriveKeyFromPassphrase(string passphrase)
{
using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(passphrase, sPassphraseSalt, 1000))
{
return pbkdf2.GetBytes(32);
}
}
private static string GenerateRandomString(int length)
{
// RNGCryptoServiceProvider-quality randomness is unnecessary here (the real
// pod uses System.Random too), but avoid same-seed collisions across quick
// restarts by seeding from Guid entropy.
Random random = new Random(Guid.NewGuid().GetHashCode());
char[] chars = new char[length];
for (int i = 0; i < length; i++)
{
chars[i] = Alphabet[random.Next(Alphabet.Length)];
}
return new string(chars);
}
}
+174
View File
@@ -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 &lt;Request ID&gt;"** button
appears at the bottom. Click it, enter the pod's name/squad, its IP
(**127.0.0.1** when vPOD runs on the console machine — accepted), any
subnet (e.g. 255.255.255.0), and the passphrase from vPOD's window.
3. The console sends the encrypted config + session key; vPOD stores the key
and starts the launcher RPC. The pod row goes healthy (`Idle [<n> ms]`).
The key persists in `%LocalAppData%\vPOD\TeslaKeyStore.key` (launcher format),
so provisioning is one-time. **Reprovision** resets to a fresh pod — drops the
key, clears the installed-apps store (`LaunchApps.json`) and returns to beacon
mode — pair it with deleting the pod in Manage Site (the console's
**Reconfigure…** does both ends automatically: its `ClearStore` makes vPOD do
the same wipe and beacon again). Extracted packages under `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 `050%` receive /
`5095%` extract / `99% Complete` progression. Then:
- Installed apps land in the column's list (and in `GetInstalledApps`, so the
Uninstall menu populates). Registrations persist in
`%LocalAppData%\vPOD\LaunchApps.json`.
- Packages extract 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.
+1053
View File
File diff suppressed because it is too large Load Diff
+679
View File
@@ -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);
}
}
}
+2 -1
View File
@@ -10,7 +10,8 @@ param([string]$Config = 'Release')
$ErrorActionPreference = 'Stop'
$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'
$stage = Join-Path $distDir 'vPOD' # -> C:\Games\vPOD on the pod
$zipPath = Join-Path $distDir 'vPOD.zip'
+86
View File
@@ -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>