22 Commits
Author SHA1 Message Date
CydandClaude Opus 5 2a5a387381 Console: remember Enable Custom Bitmaps, ship the art; bump suite to 4.11.4.5
Settings -> Enable Custom Bitmaps was a static bool with no backing store:
it defaulted to off on every launch, so an operator had to re-tick it each
session and any custom plasma art was silently ignored until they did. New
ConsoleSettings persists machine-level menu toggles as XML in
%ProgramData%\Tesla Console\console.settings, alongside RPDefaults.rpd /
BTDefaults.btd / local.siteconfig. It is loaded once from Main and never
from a static initializer: the differential suite drives PlasmaBitmaps
directly and must keep seeing the original defaults rather than whatever
this machine has saved. A missing file is the first-run case; a corrupt one
is ignored and rewritten by the next toggle, because losing a menu setting
must never stop the console starting.

Custom art is now version-controlled and rolls with the release. New
Console\Plasma Images\ is copied into the package, and the lookup searches
%ProgramData%\Tesla Console\Plasma Images first and the exe-relative folder
second, so a release can never clobber a site's own name bitmaps.
install.bat creates the data-dir folder before the icacls grant so an
unelevated operator can write it. Ships with three 128x32 name bitmaps
(Deadmeat, Muerte, Phrogg); Muerte arrived as "Muerte_128x32-2.bmp", a name
the lookup can never build, so it is renamed to match its pilot.

Two fixes fell out of making the flag sticky. Path.Combine ran on the raw
participant name outside the try block, so with the option on a pilot named
"A:B" threw ArgumentException straight out of egg generation; names that
cannot be a Windows file name now just render procedurally. And the art was
loaded with Image.FromFile, which keeps the bitmap backed by the file and
locked for its whole lifetime — it is copied out through a stream now, so
art can be swapped between missions without restarting the console.

Verified against the built net40 exe: all three shipped bitmaps resolve by
pilot name, ProgramData wins over the shipped copy, a wrong-size file is
ignored, an illegal-character name does not throw, the file is not left
locked, and the settings round-trip and corrupt-file tolerance both hold.
Diff suite 106/106.

Version bumped 4.11.4.4 -> 4.11.4.5 across Console, Launcher, vPOD, the
install/build banners, the diff-suite version assertion and the README's
latest-release pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:16:11 -05:00
CydandClaude Opus 5 33f734da2d Console: internet-session roster from TeslaLobby; vPOD per-address bind
The eight internet pod rows are furniture the operator builds once in
Manage Site — the slot-to-IP map is frozen — but which of those slots a
human actually claimed changes every session. New SessionRoster reads the
roster TeslaLobby writes at the state=launching flip, and both game panes
grow a session strip above Mission Properties: a banner (session key, game,
slots claimed, waiting, written-at), Apply Session, and the Reset Pods that
until now existed only as a right-click on a Go button that is disabled
exactly when the reset is wanted.

Two properties shape all of it. Only claimed slots appear in the file, so
absence is the unclaimed signal and every failure path — truncated, stale,
unreadable, refused — degrades to "no roster", which is byte-for-byte
today's arcade behaviour; museums run this software and a bad JSON file
must never stop a mission that would otherwise run by hand. And enabled
implies claimed, not the reverse: the operator may always sit a pilot out,
never add one, because an enabled row nobody claimed puts a dead IP in the
egg and the pods then wait on a peer that will never boot. Roster issues
join the pane's existing issue text, so the Go button is still the gate.

The roster is one file per launch generation and deliberately not a live
view: pod peer tables are boot-static, so a player whose lobby crashed is
still in every pod's table and still playable, and live tracking would
evict that working pod mid-session. Poll runs at ~1Hz off the existing
network timer with its own deadline, and the strips are built at runtime —
InitializeComponent is decompiled 1995 designer output that the
differential tests compare literally.

Go/Load also re-checks CheckAllValues at the click instead of trusting the
last status tick: a pod that died in that gap went straight into the
mission, and the post-Load barrier in NetworkScan then waited forever for a
WaitingForLaunch that never arrives.

vPOD gains -bind <ip>. Both listeners defaulted to IPAddress.Any, so a
second vPOD on the machine lost the port and the console could only ever
see one fake pod; binding each instance to its own address runs a whole
eight-pod session side by side with no cockpits. Bind all of them or none —
Windows lets IPAddress.Any take a port that specific addresses already
hold, and the unbound instance then answers for every slot nobody claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:14:38 -05:00
CydandClaude Fable 5 3d186293bf Plasma display: send the real clear+home; bump suite to 4.11.4.4
The provisioning text on the pod's plasma panel came up garbled — stale
content bleeding through, new text overlapping at the wrong position.
Root cause recovered from the dumped PD01D221 controller firmware
(vrio/PlasmaNew): PlasmaWriter.ClearAll() sent ESC J, which on this
controller is NOT a clear — it toggles an orientation/mode bit — so the
panel never cleared and the cursor never homed.

Fix: ClearAll() now sends the real commands ESC @ (clear active buffer,
reset text state) + ESC L (home to 0,0), and the writer hides the cursor
once at open with ESC G 0 — exactly what the game and the ROM's own demo
do. Verified by running the launcher's actual byte streams through the
firmware-modeled vPLASMA emulator: the old ESC J path left stale pixels
(732 lit vs 404); the new path renders byte-identical to a freshly
cleared panel (404 lit, cursor hidden).

Version bumped 4.11.4.3 -> 4.11.4.4 across Launcher, Console, vPOD, the
install/build banners, and the diff-suite version assertion. This cuts a
clean release boundary that also carries the earlier field fixes
(process-tree kill on Stop, Win10 install.bat icacls quoting, real system
volume + pre-uninstall opt-in, and the volume menu check-mark fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:03:30 -05:00
CydandClaude Fable 5 6973e7d60c Launcher: kill whole process tree on stop; fix Win10 install.bat icacls
Two field issues from the live rollout, both launcher-side.

1) Console "Stop" did nothing. Every Tesla game runs under a supervisor
   that stays alive and respawns the game (Firestorm -> launcher.exe,
   Red Planet -> a looping .bat under cmd.exe, tesla410revival ->
   pod-launch.exe -> dosbox). net40's Process.Kill() terminates only the
   tracked supervisor PID, so the game survived (and the supervisor/loop
   relaunched it). New KillProcessTree uses `taskkill /PID <pid> /T /F`
   (whole tree; XP Pro + Win10/11), Process.Kill() as fallback. All three
   kill paths (KillApp/KillAllOfType/KillAllApps) now untrack under the
   lock and tree-kill outside it, so the RPC lock isn't held across
   taskkill and our auto-restart watcher won't relaunch. Proven against a
   real supervisor->child: old Kill orphaned the child; tree-kill takes both.

2) install.bat threw "(CI)M was unexpected at this time" at [1/7] on
   Windows 10. The icacls `/grant *S-1-5-32-545:(OI)(CI)M` sat inside an
   `else ( ... )` block; cmd read the literal ) in (OI)(CI) as the block
   end. XP was fine (its branch uses cacls, no parens). Quote the grant
   token at all three icacls sites; move the explanatory notes above the
   blocks (a stray ) even in a rem inside ( ) is the same trap). Verified
   on Win11: as-shipped reproduces the error, fixed form parses (rc=0) and
   applies the identical ACE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:38:36 -05:00
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
108 changed files with 7571 additions and 3585 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+60
View File
@@ -0,0 +1,60 @@
# Plasma Images
Hand-made art for the pods' plasma name displays. Anything dropped in here is
version-controlled and rolls out with the release: `build-package.bat` copies the
folder into `App\`, and `install.bat` puts it beside `TeslaConsole.exe`.
The folder ships empty on purpose — populate it per site.
## How it works
When the console builds a mission egg it renders every participant name into a
1-bit-per-pixel bitmap in two sizes and embeds it in the egg. By default those are
drawn procedurally: `Microsoft Sans Serif`, white on black, no anti-aliasing, the
point size stepped down from 24 until the text fits.
With **Settings → Enable Custom Bitmaps** ticked, the console looks for a matching
file here first and only falls back to the procedural renderer when there isn't
one. The toggle is remembered in `console.settings` (see below), so it survives a
restart.
## File naming
```
<text>_<width>x<height>.bmp
```
The `<text>` part is matched **exactly** against the string being rendered — the
pilot/player name, the Red Planet football team name, or the literal `Camera` for
camera pods. Matching is per-name, not global, so you can override one name and
let the rest render normally.
Two sizes are needed to cover a name completely; supplying only one is fine, the
other falls back to the font renderer:
| Size | Egg section | Example |
| -------- | ------------------ | ---------------------- |
| 128 × 32 | `BitMap::Large::…` | `Camera_128x32.bmp` |
| 64 × 16 | `BitMap::Small::…` | `Camera_64x16.bmp` |
Rules:
- **Dimensions must match exactly.** A 130×32 file is ignored, silently, and the
name renders procedurally instead.
- **Only brightness matters.** Every pixel at or above 50% brightness lights up on
the plasma; everything else is dark. Colour art gets thresholded, so design in
black and white.
- Any name that cannot be a Windows file name (`:`, `\`, `|`, `?`, `*`, …) can't
have an override — those names always render procedurally.
- Files are read at egg-build time and copied into memory, not locked, so art can
be swapped between missions without restarting the console.
Preview how a name looks before a mission with the console's **Plasma Font Tool**,
which renders through the same path — with the option on it shows the override.
## Where the console looks
1. `%ProgramData%\Tesla Console\Plasma Images\` — this machine's own art. Takes
precedence, and survives a reinstall, so a site can keep local overrides that
a release will not overwrite.
2. `Plasma Images\` next to `TeslaConsole.exe` — this folder, as shipped.
+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.5")]
[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.5")]
+28 -16
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,23 +89,30 @@ 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
- `RedPlanet\RPConfig.xml`, `RedPlanet\RPStrings.xml` are loaded relative to the
exe and are copied to the build output automatically.
- `Plasma Images\*.bmp` (under `%ProgramData%`) is an **optional** override set;
when absent the console renders plasma-display text procedurally, so it is not
- `Plasma Images\*.bmp` is an **optional** override set for the pod plasma name
displays, enabled by *Settings → Enable Custom Bitmaps*. The console looks in
`%ProgramData%\Tesla Console\Plasma Images` (the machine's own art, wins) and
then in `Plasma Images\` next to the exe (the set that ships with the release —
see [`Plasma Images/README.md`](Plasma%20Images/README.md) for naming and sizes).
When neither has a match the console renders the text procedurally, so no art is
required to build or run.
- Machine-level Settings-menu toggles persist in
`%ProgramData%\Tesla Console\console.settings` (XML, written on change by
`ConsoleSettings`). Deleting it just restores the defaults.
## Notes
+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;
+166 -2
View File
@@ -182,6 +182,20 @@ internal class BTGame : DockContent
private Timer tmrAutoTranslocate;
private readonly bool mDosBoxAddressShift = BTDefaults.DosBoxAddressShift;
private Panel mSessionStrip;
private Label mSessionBanner;
private Button mSessionApply;
private Button mSessionReset;
private DateTime mNextRosterPoll = DateTime.MinValue;
private DateTime mStateChangePendingSince = DateTime.MinValue;
private string GameStatusText
{
set
@@ -298,10 +312,117 @@ internal class BTGame : DockContent
{
BuildPodRow(item5.A, item5.B);
}
BuildSessionStrip();
SessionRoster.Poll();
UpdateSessionStrip();
CheckAllValues();
ResumeLayout();
}
/// <summary>
/// The internet-session strip: roster banner, Apply, and the Reset that has
/// always existed as a right-click on the (disabled) Go button.
///
/// Built here and not in InitializeComponent on purpose. That block is the
/// decompiled 1995 designer output and the differential tests compare it
/// literally, so anything new has to be assembled at runtime instead.
/// Docked last, which is docked first, so the strip sits above Mission
/// Properties rather than between it and the pilot grid.
/// </summary>
private void BuildSessionStrip()
{
mSessionStrip = new Panel();
mSessionStrip.Dock = DockStyle.Top;
mSessionStrip.Height = 28;
mSessionStrip.Visible = false;
mSessionBanner = new Label();
mSessionBanner.Dock = DockStyle.Fill;
mSessionBanner.TextAlign = ContentAlignment.MiddleLeft;
mSessionBanner.Padding = new Padding(6, 0, 6, 0);
mSessionApply = new Button();
mSessionApply.Dock = DockStyle.Left;
mSessionApply.Width = 110;
mSessionApply.Text = "Apply Session";
mSessionApply.Visible = false;
mSessionApply.UseVisualStyleBackColor = true;
mSessionApply.Click += new EventHandler(mSessionApply_Click);
mSessionReset = new Button();
mSessionReset.Dock = DockStyle.Right;
mSessionReset.Width = 110;
mSessionReset.Text = "Reset Pods";
mSessionReset.Visible = false;
mSessionReset.UseVisualStyleBackColor = true;
// The same action as the hidden context-menu item, which no operator has
// ever found: it lives on a button that is disabled exactly when the reset
// is wanted.
mSessionReset.Click += new EventHandler(resetToolStripMenuItem_Click);
mSessionStrip.Controls.Add(mSessionBanner);
mSessionStrip.Controls.Add(mSessionApply);
mSessionStrip.Controls.Add(mSessionReset);
base.Controls.Add(mSessionStrip);
}
private void UpdateSessionStrip()
{
if (mRequestedState == mCurrentState)
{
mStateChangePendingSince = DateTime.MinValue;
mSessionReset.Visible = false;
}
else
{
if (mStateChangePendingSince == DateTime.MinValue)
{
mStateChangePendingSince = DateTime.Now;
}
// Ten seconds, so a healthy Load/Launch never makes the button flicker
// past. Past that the pane is not slow, it is stuck: the state barrier
// in NetworkScan has no timeout of its own.
mSessionReset.Visible = mStateChangePendingSince.AddSeconds(10.0) < DateTime.Now;
}
string text = SessionBannerText();
mSessionBanner.Text = text;
mSessionBanner.ForeColor = (SessionRoster.Active ? SystemColors.ControlText : Color.Red);
mSessionApply.Visible = SessionRoster.Active;
// Nothing claimed, nothing wrong and nothing stuck: no roster file at all
// leaves the pane looking exactly as it does today. Museums run this.
mSessionStrip.Visible = text.Length > 0 || mSessionReset.Visible;
}
private static string SessionBannerText()
{
if (!SessionRoster.Active)
{
// Empty with no file; when a file was refused this is why, and the
// operator needs to read it before hand-enabling eight rows.
return SessionRoster.LoadError;
}
string text = SessionRoster.SessionKey;
if (text.Length > 8)
{
text = text.Substring(0, 8);
}
// Local time: the lobby writes the file on this machine, and the operator
// is comparing the stamp against the clock on the wall.
return $"SESSION {text} - {SessionRoster.Game} - {SessionRoster.Claims.Count} slots claimed - {SessionRoster.WaitingCount} waiting - written {SessionRoster.WrittenUtc.ToLocalTime():HH:mm:ss}";
}
private void mSessionApply_Click(object sender, EventArgs e)
{
string text = SessionRoster.Validate("bt", mDosBoxAddressShift, mPilotsDataGrid, mEnabledColumn.Index, mPilotColumn.Index);
if (text.Length > 0)
{
// Named offender, never a silent fix: the pilot name is about to become
// an INI section name in the egg, so the operator must see the name that
// will be written.
MessageBox.Show(text, "Can Not Apply Session Roster");
return;
}
SessionRoster.Apply(mPilotsDataGrid, mEnabledColumn.Index, mPilotColumn.Index, this, mDosBoxAddressShift);
CheckAllValues();
mPilotsDataGrid.Refresh();
}
private static void SetupKeyValueColumn(DataGridViewComboBoxColumn column, Dictionary<string, string> options, string defaultKey)
{
int num = 0;
@@ -394,8 +515,28 @@ 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)
{
// Own deadline rather than a count of ticks: mNetworkTimer's interval is
// the designer default and this must stay ~1Hz whatever that becomes. Same
// shape as MungaGame.QueryStateIfNeeded (MungaGame.cs:159-167).
DateTime now = DateTime.Now;
if (mNextRosterPoll < now)
{
mNextRosterPoll = now.AddSeconds(1.0);
if (SessionRoster.Poll() && mCurrentState == BTGameState.Idle && mRequestedState == BTGameState.Idle)
{
// Guarded like SetPodStatus: CheckAllValues owns mGoButton.Enabled,
// and mid-mission that button is Stop Mission.
CheckAllValues();
}
}
UpdateSessionStrip();
if (mCurrentState == BTGameState.Run && mRequestedState == BTGameState.Run && !mProcessingMissionEndStateChangeReq)
{
mProcessingMissionEndStateChangeReq = true;
@@ -728,6 +869,21 @@ internal class BTGame : DockContent
{
case BTGameState.Load:
{
// Re-check at the click, not at the last status tick. Nothing between
// here and the egg validates anything -- mGoButton.Enabled is a UI flag
// CheckAllValues set at some earlier moment -- so a pod that died in
// that gap goes straight into the mission, and the post-Load barrier in
// NetworkScan then waits forever for a WaitingForLaunch that will never
// arrive (that loop has no timeout). Safe only at the very top of this
// case: mMissionLength, mMissionRecorder, mMissionPlayers,
// mRequestedState and SwitchControlsMode are all still untouched below,
// so returning here leaves the pane exactly as the operator left it.
CheckAllValues();
if (!mGoButton.Enabled)
{
MessageBox.Show(mIssuesLabel.Text, "Can Not Load Mission");
return;
}
mMissionLength = ParseMissionLength();
string key = ((BTMap)mMap.SelectedItem).Key;
string key2 = ((KeyValuePair<string, string>)mTimeOfDay.SelectedItem).Key;
@@ -747,14 +903,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();
@@ -847,6 +1003,9 @@ internal class BTGame : DockContent
private void SwitchControlsMode(bool editMode)
{
// Applying a roster rewrites pilot names and enabled rows, so it is an edit
// like any other and rides the same gate as the rest of the pane.
mSessionApply.Enabled = editMode;
mMap.Enabled = editMode;
mWeather.Enabled = editMode;
mTimeOfDay.Enabled = editMode;
@@ -997,6 +1156,10 @@ internal class BTGame : DockContent
{
stringBuilder.AppendLine("The mission length must be at least 10 seconds.");
}
// Enabled implies claimed: an enabled row nobody took in the lobby puts a
// dead IP in the egg and every pod then waits on a peer that never boots.
// Empty with no session roster, which is what keeps the arcade path intact.
stringBuilder.Append(SessionRoster.Issues(mPilotsDataGrid, mEnabledColumn.Index, mDosBoxAddressShift));
mGoButton.Enabled = stringBuilder.Length <= 0;
mIssuesLabel.Text = stringBuilder.ToString();
}
@@ -1077,6 +1240,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();
}
+169 -3
View File
@@ -176,6 +176,20 @@ public class RPGame : DockContent
private Timer tmrAutoTranslocate;
private readonly bool mDosBoxAddressShift = RPDefaults.DosBoxAddressShift;
private Panel mSessionStrip;
private Label mSessionBanner;
private Button mSessionApply;
private Button mSessionReset;
private DateTime mNextRosterPoll = DateTime.MinValue;
private DateTime mStateChangePendingSince = DateTime.MinValue;
private string GameStatusText
{
set
@@ -333,10 +347,119 @@ public class RPGame : DockContent
{
BuildPodRow(item6.A, item6.B);
}
BuildSessionStrip();
SessionRoster.Poll();
UpdateSessionStrip();
CheckAllValues();
ResumeLayout();
}
/// <summary>
/// The internet-session strip: roster banner, Apply, and the Reset that has
/// always existed as a right-click on the (disabled) Go button.
///
/// Built here and not in InitializeComponent on purpose. That block is the
/// decompiled 1995 designer output and the differential tests compare it
/// literally, so anything new has to be assembled at runtime instead.
/// Docked last, which is docked first, so the strip sits above Mission
/// Properties rather than between it and the pilot grid.
/// </summary>
private void BuildSessionStrip()
{
mSessionStrip = new Panel();
mSessionStrip.Dock = DockStyle.Top;
mSessionStrip.Height = 28;
mSessionStrip.Visible = false;
mSessionBanner = new Label();
mSessionBanner.Dock = DockStyle.Fill;
mSessionBanner.TextAlign = ContentAlignment.MiddleLeft;
mSessionBanner.Padding = new Padding(6, 0, 6, 0);
mSessionApply = new Button();
mSessionApply.Dock = DockStyle.Left;
mSessionApply.Width = 110;
mSessionApply.Text = "Apply Session";
mSessionApply.Visible = false;
mSessionApply.UseVisualStyleBackColor = true;
mSessionApply.Click += new EventHandler(mSessionApply_Click);
mSessionReset = new Button();
mSessionReset.Dock = DockStyle.Right;
mSessionReset.Width = 110;
mSessionReset.Text = "Reset Pods";
mSessionReset.Visible = false;
mSessionReset.UseVisualStyleBackColor = true;
// The same action as the hidden context-menu item, which no operator has
// ever found: it lives on a button that is disabled exactly when the reset
// is wanted.
mSessionReset.Click += new EventHandler(resetToolStripMenuItem_Click);
mSessionStrip.Controls.Add(mSessionBanner);
mSessionStrip.Controls.Add(mSessionApply);
mSessionStrip.Controls.Add(mSessionReset);
base.Controls.Add(mSessionStrip);
}
private void UpdateSessionStrip()
{
if (mRequestedState == mCurrentState)
{
mStateChangePendingSince = DateTime.MinValue;
mSessionReset.Visible = false;
}
else
{
if (mStateChangePendingSince == DateTime.MinValue)
{
mStateChangePendingSince = DateTime.Now;
}
// Ten seconds, so a healthy Load/Launch never makes the button flicker
// past. Past that the pane is not slow, it is stuck: the state barrier
// in NetworkScan has no timeout of its own.
mSessionReset.Visible = mStateChangePendingSince.AddSeconds(10.0) < DateTime.Now;
}
string text = SessionBannerText();
mSessionBanner.Text = text;
mSessionBanner.ForeColor = (SessionRoster.Active ? SystemColors.ControlText : Color.Red);
mSessionApply.Visible = SessionRoster.Active;
// Nothing claimed, nothing wrong and nothing stuck: no roster file at all
// leaves the pane looking exactly as it does today. Museums run this.
mSessionStrip.Visible = text.Length > 0 || mSessionReset.Visible;
}
private static string SessionBannerText()
{
if (!SessionRoster.Active)
{
// Empty with no file; when a file was refused this is why, and the
// operator needs to read it before hand-enabling eight rows.
return SessionRoster.LoadError;
}
string text = SessionRoster.SessionKey;
if (text.Length > 8)
{
text = text.Substring(0, 8);
}
// Local time: the lobby writes the file on this machine, and the operator
// is comparing the stamp against the clock on the wall.
return $"SESSION {text} - {SessionRoster.Game} - {SessionRoster.Claims.Count} slots claimed - {SessionRoster.WaitingCount} waiting - written {SessionRoster.WrittenUtc.ToLocalTime():HH:mm:ss}";
}
private void mSessionApply_Click(object sender, EventArgs e)
{
// "rp": both Red Planet modes -- Death Race and Martian Football -- are
// one game to the lobby, which knows only which title the pods will boot.
string text = SessionRoster.Validate("rp", mDosBoxAddressShift, mPilotsDataGrid, mEnabledColumn.Index, mPilotColumn.Index);
if (text.Length > 0)
{
// Named offender, never a silent fix: the pilot name is about to become
// an INI section name in the egg, so the operator must see the name that
// will be written.
MessageBox.Show(text, "Can Not Apply Session Roster");
return;
}
SessionRoster.Apply(mPilotsDataGrid, mEnabledColumn.Index, mPilotColumn.Index, this, mDosBoxAddressShift);
CheckAllValues();
mPilotsDataGrid.Refresh();
}
private void BuildPodRow(Squad squad, Pod pod)
{
int index = mPilotsDataGrid.Rows.Add();
@@ -421,8 +544,28 @@ 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)
{
// Own deadline rather than a count of ticks: mNetworkTimer's interval is
// the designer default and this must stay ~1Hz whatever that becomes. Same
// shape as MungaGame.QueryStateIfNeeded (MungaGame.cs:159-167).
DateTime now = DateTime.Now;
if (mNextRosterPoll < now)
{
mNextRosterPoll = now.AddSeconds(1.0);
if (SessionRoster.Poll() && mCurrentState == RPGameState.Idle && mRequestedState == RPGameState.Idle)
{
// Guarded like SetPodStatus: CheckAllValues owns mGoButton.Enabled,
// and mid-mission that button is Stop Mission.
CheckAllValues();
}
}
UpdateSessionStrip();
if (mCurrentState == RPGameState.Run && mRequestedState == RPGameState.Run && !mProcessingMissionEndStateChangeReq)
{
mProcessingMissionEndStateChangeReq = true;
@@ -768,6 +911,21 @@ public class RPGame : DockContent
{
case RPGameState.Load:
{
// Re-check at the click, not at the last status tick. Nothing between
// here and the egg validates anything -- mGoButton.Enabled is a UI flag
// CheckAllValues set at some earlier moment -- so a pod that died in
// that gap goes straight into the mission, and the post-Load barrier in
// NetworkScan then waits forever for a WaitingForLaunch that will never
// arrive (that loop has no timeout). Safe only at the very top of this
// case: mMissionLength, mMissionRecorder, mMissionPlayers,
// mRequestedState and SwitchControlsMode are all still untouched below,
// so returning here leaves the pane exactly as the operator left it.
CheckAllValues();
if (!mGoButton.Enabled)
{
MessageBox.Show(mIssuesLabel.Text, "Can Not Load Mission");
return;
}
mMissionLength = ParseMissionLength();
string key = ((RPMap)mMap.SelectedItem).Key;
string key2 = ((KeyValuePair<string, string>)mTimeOfDay.SelectedItem).Key;
@@ -791,7 +949,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 +958,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 +966,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)
@@ -907,6 +1065,9 @@ public class RPGame : DockContent
private void SwitchControlsMode(bool editMode)
{
// Applying a roster rewrites pilot names and enabled rows, so it is an edit
// like any other and rides the same gate as the rest of the pane.
mSessionApply.Enabled = editMode;
ComboBox comboBox = mMap;
ComboBox comboBox2 = mWeather;
ComboBox comboBox3 = mTimeOfDay;
@@ -1104,6 +1265,10 @@ public class RPGame : DockContent
{
stringBuilder.AppendLine("The mission length must be at least 10 seconds.");
}
// Enabled implies claimed: an enabled row nobody took in the lobby puts a
// dead IP in the egg and every pod then waits on a peer that never boots.
// Empty with no session roster, which is what keeps the arcade path intact.
stringBuilder.Append(SessionRoster.Issues(mPilotsDataGrid, mEnabledColumn.Index, mDosBoxAddressShift));
mGoButton.Enabled = stringBuilder.Length <= 0;
mIssuesLabel.Text = stringBuilder.ToString();
}
@@ -1187,6 +1352,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
+24 -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>
@@ -32,6 +41,12 @@
<Content Include="RedPlanet\Apps.xml" CopyToOutputDirectory="PreserveNewest" />
<Content Include="BattleTech\BTConfig.xml" CopyToOutputDirectory="PreserveNewest" />
<Content Include="BattleTech\BTStrings.xml" CopyToOutputDirectory="PreserveNewest" />
<!-- Optional plasma-display art (PlasmaBitmaps.LoadCustomBitmap), shipped with
the release. The glob is normally empty; the README goes along so the drop
folder exists on the control PC. The machine's own
%ProgramData%\Tesla Console\Plasma Images overrides whatever ships here. -->
<Content Include="Plasma Images\*.bmp" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Plasma Images\README.md" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
@@ -45,13 +60,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");
+79
View File
@@ -0,0 +1,79 @@
using System;
using System.IO;
using System.Windows.Forms;
using System.Xml;
namespace TeslaConsole;
/// <summary>
/// Machine-level console settings that belong to no single game — the Settings
/// menu toggles that used to live only in memory and reset on every restart.
/// Stored as XML in %ProgramData%\Tesla Console\console.settings, next to
/// RPDefaults.rpd / BTDefaults.btd / local.siteconfig.
///
/// The values themselves stay where they are used (PlasmaBitmaps owns
/// EnableCustomBitmaps); this class only moves them to and from disk, so the
/// original decompiled classes keep their shape. Load() is called once from
/// Program.Main and never from a static initializer: the differential test suite
/// drives PlasmaBitmaps directly, and must keep seeing the original defaults
/// rather than whatever this operator happens to have saved.
/// </summary>
internal static class ConsoleSettings
{
private static readonly string sSettingsFilePath = Path.Combine(Program.GetCommonAppDataDirectory(), "console.settings");
/// <summary>
/// Applies the saved settings. A missing file is the normal first-run case;
/// a corrupt one is ignored and rewritten by the next Save(), because losing
/// a menu toggle must never stop the console from starting.
/// </summary>
public static void Load()
{
try
{
if (!File.Exists(sSettingsFilePath))
{
return;
}
XmlDocument xmlDocument = new XmlDocument();
xmlDocument.Load(sSettingsFilePath);
foreach (XmlNode childNode in xmlDocument.DocumentElement.ChildNodes)
{
switch (childNode.Name)
{
case "EnableCustomBitmaps":
{
if (bool.TryParse(childNode.InnerText, out var result))
{
PlasmaBitmaps.EnableCustomBitmaps = result;
}
break;
}
}
}
}
catch (Exception)
{
}
}
public static void Save()
{
try
{
XmlDocument xmlDocument = new XmlDocument();
xmlDocument.AppendChild(xmlDocument.CreateElement("ConsoleSettings"));
xmlDocument.DocumentElement.AppendChild(xmlDocument.CreateElement("EnableCustomBitmaps")).InnerText = PlasmaBitmaps.EnableCustomBitmaps.ToString();
string directoryName = Path.GetDirectoryName(sSettingsFilePath);
if (!Directory.Exists(directoryName))
{
Directory.CreateDirectory(directoryName);
}
xmlDocument.Save(sSettingsFilePath);
}
catch (Exception)
{
MessageBox.Show("The console settings file could not be saved. This setting will only be remembered until the application is closed.", "Error Saving Console Settings!", MessageBoxButtons.OK);
}
}
}
+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)
{
+57 -14
View File
@@ -4,6 +4,7 @@ using System.Drawing.Drawing2D;
using System.Drawing.Text;
using System.IO;
using System.Text;
using System.Windows.Forms;
namespace TeslaConsole;
@@ -45,21 +46,10 @@ public class PlasmaBitmaps
{
if (sEnableCustomBitmaps)
{
string text = Path.Combine(Program.GetCommonAppDataDirectory(), $"Plasma Images\\{str}_{width}x{height}.bmp");
if (File.Exists(text))
Bitmap bitmap = LoadCustomBitmap(width, height, str);
if (bitmap != null)
{
try
{
Bitmap bitmap = (Bitmap)Image.FromFile(text);
if (bitmap.Width == width && bitmap.Height == height)
{
return bitmap;
}
bitmap.Dispose();
}
catch
{
}
return bitmap;
}
}
Bitmap bitmap2 = new Bitmap(width, height);
@@ -87,6 +77,59 @@ public class PlasmaBitmaps
}
}
/// <summary>
/// The operator-supplied plasma image for this string at this size, or null
/// when there is none (the caller then renders the text procedurally).
///
/// Two folders are searched, in order:
/// 1. %ProgramData%\Tesla Console\Plasma Images - this machine's own art,
/// which survives reinstalls and wins over anything shipped.
/// 2. Plasma Images\ next to TeslaConsole.exe - the set that rolls with
/// the release, so art can be version-controlled and deployed.
/// The file name is "&lt;text&gt;_&lt;width&gt;x&lt;height&gt;.bmp", e.g. Camera_128x32.bmp.
///
/// Nothing here may throw: participant names come from operator input, and a
/// name containing a character that is illegal in a path used to take out
/// egg generation entirely once this option was switched on.
/// </summary>
private static Bitmap LoadCustomBitmap(int width, int height, string str)
{
string fileName = $"{str}_{width}x{height}.bmp";
if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
return null;
}
string[] searchRoots = new string[2]
{
Program.GetCommonAppDataDirectory(),
Path.GetDirectoryName(Application.ExecutablePath)
};
foreach (string searchRoot in searchRoots)
{
try
{
string path = Path.Combine(Path.Combine(searchRoot, "Plasma Images"), fileName);
if (!File.Exists(path))
{
continue;
}
// Copied out of the file rather than Image.FromFile'd: that keeps the
// bitmap backed by the file for its whole lifetime, which locks the art
// against an operator swapping it while the console is running.
using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using Image image = Image.FromStream(stream);
if (image.Width == width && image.Height == height)
{
return new Bitmap(image);
}
}
catch
{
}
}
return null;
}
public static void GenerateStrings(out string large, out string small, string str)
{
GenerateStrings(out large, out small, "Microsoft Sans Serif", str);
+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();
}
+2
View File
@@ -59,6 +59,8 @@ internal static class Program
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(defaultValue: false);
// Machine-level Settings-menu toggles, before the form reads them to set its check marks.
ConsoleSettings.Load();
Application.Run(new TeslaConsoleForm());
}
+857
View File
@@ -0,0 +1,857 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Net;
using System.Text;
using System.Windows.Forms;
using Newtonsoft.Json.Linq;
namespace TeslaConsole;
/// <summary>
/// Reads the session roster TeslaLobby writes when an internet session
/// launches: which of the eight internet slots a human actually claimed, and
/// what that human is called. The slot-to-IP map is frozen
/// (<c>emulator\steam\SESSION-CONTRACT.md</c> section 1), so the eight internet
/// pod rows are furniture the operator builds once in Manage Site; only the
/// claims change from session to session.
///
/// Two properties are load-bearing and everything here is shaped around them:
///
/// 1. ONLY CLAIMED SLOTS APPEAR IN THE FILE. Absence is the unclaimed signal,
/// so a truncated, stale, unreadable or refused file yields FEWER
/// participants, never more. Every failure path below therefore degrades to
/// "no roster", which is byte-for-byte today's arcade behaviour. Museums run
/// this software; a bad JSON file must never be able to stop a mission that
/// would otherwise run by hand.
/// 2. ENABLED IMPLIES CLAIMED, not the reverse. The operator may always
/// subtract from an applied roster (sit a pilot out); the operator may never
/// add, because an enabled row nobody claimed puts a dead IP in the mission
/// egg and the pods sit waiting on a peer that will never boot.
///
/// The file is ONE PER LAUNCH GENERATION -- written at the state=launching flip
/// and not rewritten as the lobby churns -- because pod peer tables are
/// boot-static: a player whose TeslaLobby crashes is still in every pod's peer
/// table and still perfectly playable, and a live-tracking roster would evict
/// that working pod from the mission. Do not "fix" this into a live view.
///
/// UI thread only. <see cref="Poll"/> is cheap enough to call at ~1Hz from the
/// existing mission timers.
/// </summary>
internal static class SessionRoster
{
/// <summary>One claimed slot. Absence of a slot from <see cref="Claims"/> means nobody claimed it.</summary>
internal sealed class Claim
{
internal Claim(int slot, IPAddress address, string pilot, string steamId, bool host)
{
Slot = slot;
Address = address;
Pilot = pilot;
SteamId = steamId;
Host = host;
}
internal int Slot { get; private set; }
/// <summary>The game IP for the slot: 200.0.0.(111 + slot). Frozen, see SESSION-CONTRACT.md section 1.</summary>
internal IPAddress Address { get; private set; }
internal string Pilot { get; private set; }
internal string SteamId { get; private set; }
/// <summary>True for the session host (slot 0). Informational -- the console drives every claim the same way.</summary>
internal bool Host { get; private set; }
}
private const int SupportedSchema = 1;
private const int MaxSlots = 8;
// Slot 0 -> 200.0.0.111 ... slot 7 -> 200.0.0.118. Frozen in
// SESSION-CONTRACT.md section 1 and mirrored by the lobby's SlotPlan.cs;
// .119/.120 are reserved for the live-review / camera roles, which is why
// the block stops at eight.
private const byte SlotNetA = 200;
private const byte SlotNetB = 0;
private const byte SlotNetC = 0;
private const int SlotZeroOctet = 111;
// Pilot-name rules, decided by the operator 2026-07-25. The name becomes an
// INI section name in the egg (BTMission builds [BitMap::Large::<name>]) and
// the egg is ASCII, so '[', ']' and '=' would corrupt the egg's structure
// silently. The lobby sanitizes; the console only re-validates and refuses,
// because a name the operator never saw must not be quietly rewritten on its
// way into a mission file.
private const int MaxPilotNameLength = 12;
private const string BarredCharacters = "[]=";
// A roster older than one evening is a leftover from a previous session.
private const double MaxAgeHours = 12.0;
// Sanity bound. The real file is well under 2 KB; anything larger is not ours.
private const long MaxFileBytes = 64L * 1024L;
private static readonly IList<Claim> sNoClaims = new ReadOnlyCollection<Claim>(new List<Claim>());
private static bool sSeenExists;
private static DateTime sSeenStamp = DateTime.MinValue;
private static long sSeenLength = -1L;
private static bool sParsed;
private static bool sActive;
private static int sSchema;
private static string sSessionId = "";
private static string sSessionKey = "";
private static string sGame = "";
private static bool sAddressShift;
private static DateTime sWrittenUtc = DateTime.MinValue;
private static int sWaiting;
private static string sLoadError = "";
private static IList<Claim> sClaims = sNoClaims;
private static string sLoggedError = "";
/// <summary>
/// A roster file is present, parses, and is fresh. NOT "usable": call
/// <see cref="Validate"/> before applying, which is what catches a roster
/// for the wrong game, the wrong schema or the wrong addressing mode.
/// </summary>
internal static bool Active => sActive;
/// <summary>Matches steam_session.json. A change means a NEW launch generation, so re-apply.</summary>
internal static string SessionKey => sSessionKey;
internal static string SessionId => sSessionId;
/// <summary>"bt" or "rp", as written. Compare case-insensitively.</summary>
internal static string Game => sGame;
/// <summary>The addressing the session REQUIRES; internet sessions are flat, so this is false.</summary>
internal static bool AddressShift => sAddressShift;
internal static DateTime WrittenUtc => sWrittenUtc;
/// <summary>Lobby members holding no slot, for the banner. Not a blocker -- spectators are legal.</summary>
internal static int WaitingCount => sWaiting;
/// <summary>Empty when nothing is wrong. Operator-facing; show it in the banner, it is why there is no roster.</summary>
internal static string LoadError => sLoadError;
// IList, not IReadOnlyList: the read-only interfaces are net45+ and the
// XP11 console targets net40. The instance is already immutable.
internal static IList<Claim> Claims => sClaims;
internal static string FilePath =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Tesla Console", "session_roster.json");
/// <summary>
/// Re-reads the roster if the file changed. Returns true when the roster
/// state changed and the caller should repaint. Never throws.
/// </summary>
internal static bool Poll()
{
string before = StateStamp();
try
{
PollCore();
}
catch (Exception ex)
{
// Belt and braces: PollCore already catches everything it expects to
// fail. Anything reaching here is a surprise, and the answer to a
// surprise is still "there is no roster".
Unsee();
Clear("The session roster could not be read: " + ex.Message);
}
return StateStamp() != before;
}
/// <summary>The claim on an address, or null. The address is the game IP, i.e. what the egg will carry.</summary>
internal static Claim Find(IPAddress ip)
{
if (!sActive || ip == null)
{
return null;
}
for (int i = 0; i < sClaims.Count; i++)
{
if (ip.Equals(sClaims[i].Address))
{
return sClaims[i];
}
}
return null;
}
/// <summary>
/// Gate A. Returns "" when the roster may be applied to this mission
/// window, else operator-facing text explaining what to fix.
///
/// Returns "" when no roster is active: with no roster the console is the
/// 1995 console and must not grow a new way to refuse a mission.
/// <paramref name="enabledCol"/> and <paramref name="pilotCol"/> are taken
/// for call-shape symmetry with <see cref="Apply"/>; the checks here need
/// only each row's pod (row.Tag).
/// </summary>
internal static string Validate(string game, bool paneShift, DataGridView grid, int enabledCol, int pilotCol)
{
if (!sActive)
{
return "";
}
try
{
if (sSchema != SupportedSchema)
{
return $"This session roster is format version {sSchema}; this console reads version {SupportedSchema}. "
+ "Install matching TeslaLobby and TeslaConsole builds, then start the session again.";
}
if (!string.IsNullOrEmpty(game) && !string.Equals(game, sGame, StringComparison.OrdinalIgnoreCase))
{
return $"This session roster is for {GameName(sGame)}. This is a {GameName(game)} mission. "
+ $"Start a {GameName(game)} session in TeslaLobby, or close this window and open the {GameName(sGame)} one.";
}
if (paneShift != sAddressShift)
{
return AddressShiftMessage(paneShift);
}
if (sClaims.Count == 0)
{
return "This session roster has no claimed slots. Nobody took a pod in TeslaLobby, so there is nothing to launch.";
}
StringBuilder problems = new StringBuilder();
for (int i = 0; i < sClaims.Count; i++)
{
Claim claim = sClaims[i];
string problem = PilotNameProblem(claim.Pilot);
if (problem != null)
{
problems.AppendLine(string.IsNullOrEmpty(claim.Pilot)
? $"Slot {claim.Slot} has no pilot name. Set one in TeslaLobby and start the session again."
: $"Pilot name \"{claim.Pilot}\" (slot {claim.Slot}) {problem}. Fix it in TeslaLobby and start the "
+ "session again -- the console will not rewrite a name that is about to be written into the mission egg.");
continue;
}
// Ordinal, matching the panes' own duplicate check in
// CheckAllValues (BTGame.cs:965), so the two agree on what a
// duplicate is.
for (int j = i + 1; j < sClaims.Count; j++)
{
if (string.Equals(claim.Pilot, sClaims[j].Pilot, StringComparison.Ordinal))
{
problems.AppendLine($"Pilot name \"{claim.Pilot}\" is claimed by slot {claim.Slot} and slot {sClaims[j].Slot}. "
+ "Pilot names must be unique. Rename one in TeslaLobby and start the session again.");
}
}
}
if (grid != null)
{
for (int i = 0; i < sClaims.Count; i++)
{
Claim claim = sClaims[i];
Pod pod = FindPod(grid, claim.Address, paneShift);
if (pod == null)
{
problems.AppendLine($"No pod is configured at {claim.Address} (slot {claim.Slot}). "
+ "Add the Internet squad in Manage Site.");
}
else if (pod.HostType != HostType.GameMachineHostType)
{
problems.AppendLine($"The pod at {claim.Address} (slot {claim.Slot}) is not a game machine. "
+ "Internet slots must be Game Machine pods in Manage Site.");
}
}
}
return problems.ToString();
}
catch (Exception ex)
{
LogOnce("SessionRoster.Validate failed: " + ex);
// Fail closed: an active roster we cannot check is not a roster the
// operator should be allowed to apply.
return "The session roster could not be checked. Enable pods by hand, or restart the console.";
}
}
/// <summary>
/// Fills the mission grid in from the roster: every claimed pod row gets its
/// pilot name, is enabled and is connected; every other player row is
/// cleared, disabled and released.
///
/// Callers gate on <see cref="Validate"/> first and repaint their own issue
/// list afterwards (CheckAllValues) -- this method deliberately knows
/// nothing about either pane's UI. Never throws.
/// </summary>
internal static void Apply(DataGridView grid, int enabledCol, int pilotCol, object requestor, bool paneShift)
{
if (!sActive || grid == null || requestor == null)
{
return;
}
try
{
foreach (DataGridViewRow row in grid.Rows)
{
Pod pod = row.Tag as Pod;
if (pod == null || pod.HostType != HostType.GameMachineHostType)
{
// Camera and mission-review rows are site furniture, never
// lobby slots. Leave them exactly as the operator set them:
// the roster describes players, and turning off a recording
// host the operator armed would be an edit nobody asked for.
continue;
}
if (enabledCol < 0 || enabledCol >= row.Cells.Count || pilotCol < 0 || pilotCol >= row.Cells.Count)
{
continue;
}
Claim claim = MatchClaim(pod, paneShift);
if (claim != null)
{
row.Cells[pilotCol].Value = claim.Pilot;
row.Cells[enabledCol].Value = true;
// Setting cell values connects NOTHING -- programmatic writes
// raise no CellEndEdit. The only thing that opens a pod
// connection is MungaGame.MakeRequested, so reproduce the
// hand-edit sequence exactly, shift first: assigning
// DosBoxAddressShift while connected drops the socket
// (MungaGame.cs:120-143), so it must be settled before the
// request that dials. Modelled on
// mPilotsDataGrid_CellEndEdit, BTGame.cs:1085-1089 (and the
// identical RPGame.cs:1195-1199).
pod.MungaGame.DosBoxAddressShift = paneShift;
pod.MungaGame.MakeRequested(requestor);
}
else
{
// The clear-and-release the Delete/Backspace path uses,
// BTGame.cs:1102-1105: "" rather than null, so the pane's
// blank-name check sees what a hand-cleared cell leaves.
row.Cells[pilotCol].Value = "";
row.Cells[enabledCol].Value = false;
pod.MungaGame.ReleaseRequest(requestor);
}
}
}
catch (Exception ex)
{
// Rows already processed keep their state; a half-applied roster is
// still enabled-implies-claimed, and Issues() is the net under it.
LogOnce("SessionRoster.Apply failed: " + ex);
}
}
/// <summary>
/// Gate B. One line per enabled player row that nobody claimed. Empty when
/// there is nothing to say -- and always empty with no active roster, which
/// is what keeps the arcade path untouched.
/// </summary>
internal static string Issues(DataGridView grid, int enabledCol, bool paneShift)
{
if (!sActive || grid == null)
{
return "";
}
try
{
StringBuilder issues = new StringBuilder();
foreach (DataGridViewRow row in grid.Rows)
{
Pod pod = row.Tag as Pod;
if (pod == null || pod.HostType != HostType.GameMachineHostType || !IsEnabled(row, enabledCol))
{
continue;
}
// The pane's shift, matching Apply's signature. It equals
// sAddressShift by the time this can matter (Validate refuses a
// disagreeing pane before Apply can run), but the join from a row
// to a claim is now spelled the same way in both methods -- two
// spellings of one rule is how they drift apart later.
if (MatchClaim(pod, paneShift) != null)
{
continue;
}
IPAddress address = MissionAddress(pod, paneShift);
int slot = SlotForAddress(address);
string who = (slot >= 0)
? $"Slot {slot} ({address})"
: (string.IsNullOrEmpty(pod.Name) ? address.ToString() : $"{pod.Name} ({address})");
issues.AppendLine($"{who} is enabled but nobody claimed it in this session.");
}
return issues.ToString();
}
catch (Exception ex)
{
LogOnce("SessionRoster.Issues failed: " + ex);
// Fail closed, as in Validate: an active roster we cannot check
// against must not be allowed to launch.
return "The session roster could not be checked against the enabled pods. Restart the console.";
}
}
private static void PollCore()
{
string path = FilePath;
FileInfo info = new FileInfo(path);
bool exists = info.Exists;
DateTime stamp = exists ? info.LastWriteTimeUtc : DateTime.MinValue;
long length = exists ? info.Length : -1L;
if (exists == sSeenExists && stamp == sSeenStamp && length == sSeenLength)
{
// Freshness is time-dependent, not file-dependent: a console left
// open overnight must drop a roster that ages out of the window even
// though nothing on disk moved.
RefreshActive();
return;
}
if (!exists)
{
See(false, stamp, length);
// No file is not an error. This is the arcade path.
Clear("");
return;
}
if (length > MaxFileBytes || length <= 0L)
{
See(true, stamp, length);
Clear($"The session roster at {path} is {length} bytes, which is not a roster file. It was ignored.");
LogOnce(sLoadError);
return;
}
JObject root;
try
{
root = JObject.Parse(ReadAllTextShared(path));
}
catch (Exception ex)
{
// Leave the stat uncommitted so the next tick retries: the usual
// cause is a read that raced the lobby's write, and latching that
// failure until something touches the file again would strand a
// perfectly good roster.
Unsee();
Clear("The session roster could not be read: " + ex.Message);
LogOnce(sLoadError + " (" + path + ")");
return;
}
See(true, stamp, length);
Parse(root, path);
}
private static void Parse(JObject root, string path)
{
int schema = ReadInt(root["schema"], 0);
string sessionId = ReadString(root["sessionId"]);
string sessionKey = ReadString(root["sessionKey"]);
string game = ReadString(root["game"]).Trim();
// Missing means flat addressing: that is what an internet session
// requires (SESSION-CONTRACT.md section 1) and it is the safe default,
// since a wrong "false" is caught by Validate against the pane's flag.
bool addressShift = ReadBool(root["addressShift"], defaultValue: false);
int waiting = ReadInt(root["waiting"], 0);
DateTime writtenUtc;
if (!ReadUtc(root["writtenUtc"], out writtenUtc))
{
Clear("The session roster has no usable writtenUtc timestamp, so its age cannot be checked. It was ignored.");
LogOnce(sLoadError + " (" + path + ")");
return;
}
JArray slots = root["slots"] as JArray;
if (slots == null)
{
Clear("The session roster has no slots list. It was ignored.");
LogOnce(sLoadError + " (" + path + ")");
return;
}
List<Claim> claims = new List<Claim>();
bool[] seen = new bool[MaxSlots];
foreach (JToken token in slots)
{
JObject entry = token as JObject;
if (entry == null)
{
Refuse("a slot entry is not an object", path);
return;
}
int slot;
if (!ReadIntStrict(entry["slot"], out slot) || slot < 0 || slot >= MaxSlots)
{
Refuse($"a slot entry has an out-of-range or unreadable slot number ({ReadString(entry["slot"])})", path);
return;
}
if (seen[slot])
{
Refuse($"slot {slot} is claimed twice", path);
return;
}
seen[slot] = true;
IPAddress address;
if (!IPAddress.TryParse(ReadString(entry["ip"]), out address))
{
Refuse($"slot {slot} has an unreadable ip", path);
return;
}
// The mapping is frozen. A disagreement here means the lobby and the
// console do not share an addressing plan, and every downstream
// decision -- which row to enable, which slot to name in an issue --
// would be built on a guess. Refuse the file and let the operator
// enable pods by hand instead.
IPAddress expected = AddressForSlot(slot);
if (!expected.Equals(address))
{
Refuse($"slot {slot} claims {address}, but slot {slot} is {expected}", path);
return;
}
claims.Add(new Claim(slot, address, ReadString(entry["pilot"]), ReadString(entry["steamId"]),
ReadBool(entry["host"], defaultValue: false)));
}
sParsed = true;
sSchema = schema;
sSessionId = sessionId;
sSessionKey = sessionKey;
sGame = game;
sAddressShift = addressShift;
sWrittenUtc = writtenUtc;
sWaiting = waiting;
sClaims = new ReadOnlyCollection<Claim>(claims);
sLoadError = "";
RefreshActive();
}
/// <summary>Structural refusals: the file contradicts itself, so none of it is trusted.</summary>
private static void Refuse(string reason, string path)
{
Clear($"The session roster was ignored: {reason}. Enable pods by hand for this mission.");
LogOnce(sLoadError + " (" + path + ")");
}
/// <summary>
/// Applies the freshness window to already-parsed data. Split out of
/// <see cref="Parse"/> because age changes with the clock, not with the file.
/// </summary>
private static void RefreshActive()
{
if (!sParsed)
{
return;
}
// Absolute difference: a timestamp far in the future is clock skew
// between the lobby machine and this one, which is no more trustworthy
// than one from last night.
double hours = Math.Abs((DateTime.UtcNow - sWrittenUtc).TotalHours);
if (hours > MaxAgeHours)
{
string stamp = sWrittenUtc.ToString("u", CultureInfo.InvariantCulture);
Clear($"The session roster was written {stamp} and is more than {(int)MaxAgeHours} hours old. "
+ "It is left over from an earlier session and was ignored.");
return;
}
sActive = true;
sLoadError = "";
}
private static void Clear(string error)
{
sParsed = false;
sActive = false;
sSchema = 0;
sSessionId = "";
sSessionKey = "";
sGame = "";
sAddressShift = false;
sWrittenUtc = DateTime.MinValue;
sWaiting = 0;
sClaims = sNoClaims;
sLoadError = error;
}
private static void See(bool exists, DateTime stamp, long length)
{
sSeenExists = exists;
sSeenStamp = stamp;
sSeenLength = length;
}
/// <summary>Forgets the stat so the next Poll re-reads the same file.</summary>
private static void Unsee()
{
sSeenExists = false;
sSeenStamp = DateTime.MinValue;
sSeenLength = -1L;
}
private static string ReadAllTextShared(string path)
{
// FileShare.ReadWrite | Delete: the lobby may still hold the file open,
// and a sharing violation here would read as "no roster" for a whole
// second. UTF-8 without BOM per the contract; BOM detection costs
// nothing and forgives a writer that adds one.
using (FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
{
using (StreamReader reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
{
return reader.ReadToEnd();
}
}
}
private static string StateStamp()
{
return string.Concat(sActive ? "1|" : "0|", sSessionKey, "|", sGame, "|",
sWrittenUtc.ToString("u", CultureInfo.InvariantCulture), "|",
sClaims.Count.ToString(CultureInfo.InvariantCulture), "|",
sWaiting.ToString(CultureInfo.InvariantCulture), "|", sLoadError);
}
private static void LogOnce(string message)
{
// Once per distinct message: Poll runs at 1Hz and a permanently broken
// file would otherwise fill ExceptionLog.txt overnight.
if (sLoggedError == message)
{
return;
}
sLoggedError = message;
Program.LogMessage(message);
}
/// <summary>The address this pod will carry into the mission -- MissionAddress() in both game panes.</summary>
private static IPAddress MissionAddress(Pod pod, bool shift)
{
return shift ? DosBox.ShiftAddress(pod.IPAddress) : pod.IPAddress;
}
private static Claim MatchClaim(Pod pod, bool shift)
{
if (pod == null || pod.IPAddress == null)
{
return null;
}
// Join on the mission address, not the raw site-config address: the
// claim's IP is the game IP, which is what the egg gets. With flat
// addressing (every internet session) the two are the same value.
return Find(MissionAddress(pod, shift));
}
private static Pod FindPod(DataGridView grid, IPAddress address, bool shift)
{
foreach (DataGridViewRow row in grid.Rows)
{
Pod pod = row.Tag as Pod;
if (pod != null && pod.IPAddress != null && address.Equals(MissionAddress(pod, shift)))
{
return pod;
}
}
return null;
}
private static bool IsEnabled(DataGridViewRow row, int enabledCol)
{
if (enabledCol < 0 || enabledCol >= row.Cells.Count)
{
return false;
}
object value = row.Cells[enabledCol].Value;
return value is bool && (bool)value;
}
private static IPAddress AddressForSlot(int slot)
{
return new IPAddress(new byte[4] { SlotNetA, SlotNetB, SlotNetC, (byte)(SlotZeroOctet + slot) });
}
/// <summary>The slot an address belongs to, or -1 when it is outside the internet block.</summary>
private static int SlotForAddress(IPAddress address)
{
if (address == null)
{
return -1;
}
byte[] octets = address.GetAddressBytes();
if (octets.Length != 4 || octets[0] != SlotNetA || octets[1] != SlotNetB || octets[2] != SlotNetC)
{
return -1;
}
int slot = octets[3] - SlotZeroOctet;
return (slot >= 0 && slot < MaxSlots) ? slot : -1;
}
private static string GameName(string game)
{
if (string.Equals(game, "bt", StringComparison.OrdinalIgnoreCase))
{
return "BattleTech";
}
if (string.Equals(game, "rp", StringComparison.OrdinalIgnoreCase))
{
return "Red Planet";
}
return string.IsNullOrEmpty(game) ? "an unnamed game" : "\"" + game + "\"";
}
private static string AddressShiftMessage(bool paneShift)
{
// This mismatch is the silent hang this whole path exists to kill: the
// console dials pod+100, nothing answers, and the mission never starts.
// The part operators get wrong is that the pane LATCHES the flag when
// the mission window opens (BTGame.cs:185 / RPGame.cs:179 read the
// default into a readonly field at construction), so changing the
// default with the window open does nothing at all. Say so.
string state = paneShift ? "ON" : "OFF";
string wanted = sAddressShift ? "ON" : "OFF";
return $"This session needs the +100 DOSBox address shift {wanted}, but this mission window has it {state}. "
+ $"Turn \"Shift all pod IPs +100 (DOSBox-X preservation build)\" {wanted} in the Defaults dialog, "
+ "then CLOSE AND REOPEN this mission window -- the shift is latched when the window opens, "
+ "so changing the default alone will not take effect.";
}
/// <summary>
/// Why this pilot name is unusable, or null when it is clean. Mirrors the
/// lobby's sanitizer; the console refuses rather than rewrites, so the name
/// the player saw in the lobby is the name that reaches the egg.
/// </summary>
private static string PilotNameProblem(string name)
{
if (string.IsNullOrEmpty(name))
{
return "is blank";
}
if (name.Length > MaxPilotNameLength)
{
return $"is longer than {MaxPilotNameLength} characters";
}
for (int i = 0; i < name.Length; i++)
{
char c = name[i];
if (BarredCharacters.IndexOf(c) >= 0)
{
return $"contains '{c}', which the mission egg cannot carry -- the pilot name becomes an INI section "
+ "name, and '[', ']' and '=' corrupt the egg's structure silently";
}
if (c < ' ' || c > '~')
{
return "contains a character that is not printable ASCII";
}
}
if (name != name.Trim())
{
return "has leading or trailing spaces";
}
if (name.IndexOf(" ", StringComparison.Ordinal) >= 0)
{
return "has a doubled space";
}
return null;
}
private static string ReadString(JToken token)
{
if (token == null || token.Type == JTokenType.Null || token.Type == JTokenType.Undefined)
{
return "";
}
if (token.Type == JTokenType.Object || token.Type == JTokenType.Array)
{
return "";
}
// SteamID64s are written as strings but survive being written as numbers.
return token.ToString();
}
private static int ReadInt(JToken token, int defaultValue)
{
int value;
return ReadIntStrict(token, out value) ? value : defaultValue;
}
private static bool ReadIntStrict(JToken token, out int value)
{
value = 0;
if (token == null)
{
return false;
}
if (token.Type == JTokenType.Integer)
{
try
{
value = token.Value<int>();
return true;
}
catch
{
return false;
}
}
return token.Type == JTokenType.String
&& int.TryParse(token.Value<string>(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
}
private static bool ReadBool(JToken token, bool defaultValue)
{
if (token == null)
{
return defaultValue;
}
if (token.Type == JTokenType.Boolean)
{
return token.Value<bool>();
}
if (token.Type == JTokenType.String)
{
bool value;
return bool.TryParse(token.Value<string>(), out value) ? value : defaultValue;
}
return defaultValue;
}
private static bool ReadUtc(JToken token, out DateTime value)
{
value = DateTime.MinValue;
if (token != null && token.Type == JTokenType.Date)
{
// Newtonsoft already parsed it; normalise the kind rather than trust it.
value = token.Value<DateTime>().ToUniversalTime();
return true;
}
string text = ReadString(token);
if (string.IsNullOrEmpty(text))
{
return false;
}
DateTime parsed;
if (!DateTime.TryParse(text, CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out parsed))
{
return false;
}
value = parsed;
return true;
}
}
+18 -3
View File
@@ -1138,12 +1138,27 @@ public class SiteManagement : Form
{
Tuple<PodData, Guid> tuple = e.UserState as Tuple<PodData, Guid>;
PodData a = tuple.A;
foreach (LaunchData appData in BuildLaunchData(tuple.B, 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)
{
a.Conn.AddApp(appData);
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;
}
}
+3 -2
View File
@@ -478,6 +478,7 @@ public class TeslaConsoleForm : Form
private void enableCustomBitmapsToolStripMenuItem_Click(object sender, EventArgs e)
{
enableCustomBitmapsToolStripMenuItem.Checked = (PlasmaBitmaps.EnableCustomBitmaps = !PlasmaBitmaps.EnableCustomBitmaps);
ConsoleSettings.Save();
}
private void mThrowHandledExceptionMenuItem_Click(object sender, EventArgs e)
@@ -758,7 +759,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 +774,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 -1
View File
@@ -64,8 +64,13 @@ echo Files copied.
:: -- STEP 2: Data directory (site config, mission recordings) -----------------
echo [2/4] Preparing data directory...
if not exist "%DATA_DIR%" mkdir "%DATA_DIR%"
:: Per-site plasma-display art. Created empty and never overwritten: what is in
:: here wins over the copy shipped in App\Plasma Images, so a release cannot
:: clobber a site's own name bitmaps.
if not exist "%DATA_DIR%\Plasma Images" mkdir "%DATA_DIR%\Plasma Images"
:: Grant the local Users group modify access so a normal operator account can
:: write local.siteconfig / RP Missions when the console is not run elevated.
:: write local.siteconfig / console.settings / RP Missions when the console is
:: not run elevated.
icacls "%DATA_DIR%" /grant *S-1-5-32-545:(OI)(CI)M /T >nul 2>&1
echo %DATA_DIR% (Users: modify access)
@@ -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.5", _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`.
+51 -26
View File
@@ -156,6 +156,12 @@ namespace TeslaSecureConfig
_port = new System.IO.Ports.SerialPort(comPort, baud,
System.IO.Ports.Parity.None, 8, System.IO.Ports.StopBits.One);
_port.Open();
// Hide the text cursor once at startup, exactly as the game and the
// controller's own ROM demo do (ESC G 0). ESC @ does not restore it,
// so a single hide holds for the session.
_port.BaseStream.WriteByte(0x1B); // ESC
_port.BaseStream.WriteByte(0x47); // G
_port.BaseStream.WriteByte(0x00); // 0 -> cursor hidden
ClearAll();
}
catch (Exception ex)
@@ -166,12 +172,19 @@ namespace TeslaSecureConfig
}
}
// ClearAll: send ESC J (clear display) as per Plasma protocol
// Clear + home, per the PD01D221 controller's recovered command set (EPROM
// dump -> vrio/PlasmaNew/FIRMWARE.md; the ROM's own demo prefixes every
// screen with exactly these two). The previous code sent ESC J, which on
// this controller is NOT a clear -- it toggles an orientation/mode bit -- so
// the panel never cleared and never homed, and each provisioning cycle wrote
// stale text at a stale cursor position (garbled overlap in the field).
public void ClearAll()
{
if (_port == null || !_port.IsOpen) return;
_port.BaseStream.WriteByte(0x1B); // ESC
_port.BaseStream.WriteByte(0x4A); // J (clear screen)
_port.BaseStream.WriteByte(0x40); // @ -> clear active buffer, reset text state
_port.BaseStream.WriteByte(0x1B); // ESC
_port.BaseStream.WriteByte(0x4C); // L -> home cursor to (0,0)
_port.BaseStream.Flush();
}
@@ -416,7 +429,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 +861,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))
{
bw.Write(rsa.ToXmlString(false)); // public key only
bw.Flush();
}
// 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))
{
int encLen = br.ReadInt32();
byte[] enc = br.ReadBytes(encLen);
sessionKey = rsa.Decrypt(enc, RSAEncryptionPadding.Pkcs1);
}
var br = new BinaryReader(ofb, Encoding.UTF8);
int encLen = br.ReadInt32();
byte[] enc = br.ReadBytes(encLen);
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 +961,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 +1146,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 +1175,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 +1190,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 +1206,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.5</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.
+32 -58
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\)
:: dist\TeslaLauncher-podpkg.zip (the deployable package)
:: 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
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.5 - 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
+250 -242
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.5 - 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,166 @@ 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.
:: The icacls grant token MUST stay quoted: its (OI)(CI) inheritance parens would
:: otherwise be read as the end of the if-block -- "(CI)M was unexpected at this
:: time" on Win10. Keep this note ABOVE the block; a stray ) inside ( ) breaks it.
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).
:: The icacls grant token stays quoted so its (OI)(CI) parens are not read as the
:: end of the if-block. Keep this note ABOVE the block, not inside the ( ).
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,146 +222,114 @@ 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
reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d "%AUTOLOGIN_PASS%" /f >nul
reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d "1" /f >nul
reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d "%AUTOLOGIN_USER%" /f >nul
reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d "%AUTOLOGIN_PASS%" /f >nul
reg add "%WINLOGON%" /v DefaultDomainName /t REG_SZ /d "." /f >nul
:: Re-arm auto-login after every sign-out (kiosk should never sit at a prompt).
reg add "%WINLOGON%" /v ForceAutoLogon /t REG_SZ /d "1" /f >nul
reg add "%WINLOGON%" /v ForceAutoLogon /t REG_SZ /d "1" /f >nul
:: Clear any logon-count cap that would disable auto-login after N boots.
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" ^
/v DevicePasswordLessBuildVersion /t REG_DWORD /d 0 /f >nul 2>&1
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 +340,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.5**).
## 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
+432
View File
@@ -0,0 +1,432 @@
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 readonly IPAddress mBind; // null = every interface (the default)
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, IPAddress bind = null)
{
mLauncher = launcher;
mPort = port;
mBind = bind; // null keeps the historical every-interface behaviour
}
/// <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(mBind ?? IPAddress.Any, mPort);
mListener.Start();
mRunning = true;
mAcceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "vPOD-launcher-accept" };
mAcceptThread.Start();
Log?.Invoke(mBind == null
? $"Launcher RPC listening on TCP {mPort}."
: $"Launcher RPC listening on TCP {mPort} ({mBind} only).");
}
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;
}
}
@@ -28,6 +28,7 @@ internal sealed class MungaPodServer
}
private readonly int mPort;
private readonly IPAddress mBind; // null = every interface (the default)
private TcpListener mListener;
private Thread mAcceptThread;
private volatile bool mRunning;
@@ -54,8 +55,16 @@ internal sealed class MungaPodServer
}
public MungaPodServer(int port)
: this(port, null)
{
}
// bind == null keeps the historical every-interface behaviour, so existing
// callers and single-pod runs are unchanged.
public MungaPodServer(int port, IPAddress bind)
{
mPort = port;
mBind = bind;
}
public void Start()
@@ -64,12 +73,14 @@ internal sealed class MungaPodServer
{
return;
}
mListener = new TcpListener(IPAddress.Any, mPort);
mListener = new TcpListener(mBind ?? IPAddress.Any, mPort);
mListener.Start();
mRunning = true;
mAcceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "vPOD-accept" };
mAcceptThread.Start();
Log?.Invoke($"Listening on TCP {mPort} (all interfaces).");
Log?.Invoke(mBind == null
? $"Listening on TCP {mPort} (all interfaces)."
: $"Listening on TCP {mPort} ({mBind} only).");
}
public void Stop()
@@ -1,4 +1,5 @@
using System;
using System.Net;
using Munga.Net;
namespace VPod;
@@ -14,17 +15,47 @@ 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)
/// <c>-bind &lt;ip&gt;</c> listen on ONE address instead of every interface.
/// Both listeners default to IPAddress.Any, which means
/// the second vPOD on a machine loses the port and the
/// console can only ever see one fake pod. Binding each
/// instance to its own address lets a whole roster run
/// side by side -- 200.0.0.111..118 are the internet
/// session's slot addresses, so an eight-pod session can
/// be exercised end to end with no cockpits and nobody
/// else in the room. Add the aliases first, e.g.
/// netsh interface ipv4 add address "Loopback" 200.0.0.113 255.255.255.0
///
/// DO NOT leave an unbound vPOD running alongside a bound
/// roster. Verified on this box 2026-07-25: Windows lets
/// IPAddress.Any bind the SAME port while specific
/// addresses already hold it (Linux would refuse). The
/// specific listeners still win for their own addresses,
/// but the unbound one silently swallows every address no
/// one claimed -- so a slot you thought was absent answers
/// anyway, which is precisely the stale-pod confusion the
/// console's claim gate exists to catch. Bind all of them
/// or none of them.
/// </summary>
internal sealed class PodArguments
{
public int Port { get; private set; } = MungaSocket.ConsolePort; // 1501
/// <summary>Address both listeners bind to; null = every interface (default).</summary>
public IPAddress Bind { get; private set; }
public ApplicationID Application { get; private set; } = ApplicationID.RPL4;
public HostType HostType { get; private set; } = HostType.GameMachineHostType;
public int HostId { get; private set; } = 1;
public bool NoManage { get; private set; }
public static PodArguments Parse(string[] args)
{
PodArguments result = new PodArguments();
@@ -40,6 +71,16 @@ internal sealed class PodArguments
i++;
}
break;
case "-bind":
// Unparseable means "every interface" rather than a hard failure:
// vPOD is a test tool and a typo here should not stop the run, but
// it must be visible -- Program logs the resolved bind at startup.
if (i + 1 < args.Length && IPAddress.TryParse(args[i + 1], out IPAddress bind))
{
result.Bind = bind;
i++;
}
break;
case "-lc":
result.HostType = HostType.MissionReviewHostType;
break;
@@ -63,6 +104,9 @@ internal sealed class PodArguments
i++;
}
break;
case "-nomanage":
result.NoManage = true;
break;
}
}
return result;

Some files were not shown because too many files have changed in this diff Show More